diff --git a/examples/README.md b/examples/README.md index 567b849e..ed8e95d0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -21,6 +21,7 @@ service keys. | [A Stripe Link checkout with an SIE fraud-risk gate](./stripe-link-fraud) | Wiring all three SIE primitives into a pre-authorization fraud-risk gate that runs in the same round-trip as the Stripe PaymentIntent | `extract`, `encode`, `score` | Docker Compose plus Node UI; Stripe test-mode keys optional (runs in mock mode without them) | Runnable demo | | [Vision-first document RAG](./vision-doc-rag) | Retrieving and answering questions over a multi-tenant page corpus by looking at page images — including scanned drawings — with OCR kept out of the score path | `encode`, `chat/completions`, `score` (optional) | GPU SIE deployment required: ColQwen2.5 retriever + Qwen3.5-4B answer model (runs on the generation bundle) | Runnable demo | | [Multi-model contract review with the OpenAI Agents SDK](./contract-review-agent) | Running an OpenAI Agents SDK agent whose every model call — triage, orchestration, vision, OCR, embeddings, rerank, entity extraction, text-to-SQL, reasoning, and a safety guardrail — is served by one SIE cluster, each step on the right catalog model, with per-model observability | `chat/completions`, `encode`, `score`, `extract` | GPU SIE deployment required; standalone `uv` project; real contracts fetched from CUAD (CC BY 4.0) | Runnable demo | +| [AI Patronus browser memory](./ai-patronus-browser-memory) | Building a browser companion that saves pages on request and recalls them semantically | OpenAI-compatible `/v1/embeddings` | Local SIE Docker image plus Chrome extension and FastAPI bridge | Runnable demo | For docs publishing, lead with the quickest runnable demos, then use the benchmark and evaluation examples for deeper technical users. diff --git a/examples/ai-patronus-browser-memory/.gitignore b/examples/ai-patronus-browser-memory/.gitignore new file mode 100644 index 00000000..a3a5384e --- /dev/null +++ b/examples/ai-patronus-browser-memory/.gitignore @@ -0,0 +1,7 @@ +.env +.venv/ +__pycache__/ +memory_server/data/ +memory_server/.env +memory_server/.venv/ +extension/config.local.js diff --git a/examples/ai-patronus-browser-memory/README.md b/examples/ai-patronus-browser-memory/README.md new file mode 100644 index 00000000..79270061 --- /dev/null +++ b/examples/ai-patronus-browser-memory/README.md @@ -0,0 +1,147 @@ +# AI Patronus browser memory + +AI Patronus is a Chrome extension that lets a small browser companion remember +pages on request and recall them semantically through SIE embeddings. + +This example focuses on a privacy-preserving memory loop: + +1. The user asks Patronus to `remember this page`. +2. The extension sends the current page title, URL, and readable text to a local + memory bridge. +3. The bridge calls SIE's OpenAI-compatible `/v1/embeddings` endpoint and stores + the vector locally. +4. The user asks `what did I save about browser memory?` and Patronus returns the + closest saved pages. + +No browser history is uploaded automatically. The save action is manual, and the +default setup runs against a local SIE server with local JSON storage. + +## SIE primitives + +| Flow | Endpoint | Model | +|---|---|---| +| Page memory | `/v1/embeddings` | `sentence-transformers/all-MiniLM-L6-v2` | +| Semantic recall | `/v1/embeddings` + local cosine similarity | `sentence-transformers/all-MiniLM-L6-v2` | + +The Chrome extension also contains optional hooks for chat, voice, and web +research providers from the original hackathon prototype, but the SIE memory +flow works without those keys. + +## Run it locally + +You need Docker, Python 3.12, and Chrome. + +Start SIE: + +```bash +docker run -p 8080:8080 -v sie-hf-cache:/app/.cache/huggingface ghcr.io/superlinked/sie-server:latest-cpu-default +``` + +Start the memory bridge: + +```bash +cd examples/ai-patronus-browser-memory/memory_server +python -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +uvicorn app:app --reload --port 8800 +``` + +Load the extension: + +1. Open `chrome://extensions`. +2. Enable Developer mode. +3. Choose **Load unpacked**. +4. Select `examples/ai-patronus-browser-memory/extension`. +5. Open the Patronus popup and set `Superlinked URL` to `http://localhost:8800`. + +Try it on any normal website: + +```text +remember this page +what did I save about browser memory? +``` + +You can also seed sample memories after the memory bridge is running: + +```bash +cd examples/ai-patronus-browser-memory/memory_server +python seed_sample.py +``` + +Then ask: + +```text +what did I save about privacy? +``` + +## Configuration + +The memory bridge reads `memory_server/.env`: + +| Variable | Default | Purpose | +|---|---|---| +| `SIE_URL` | `http://localhost:8080` | SIE endpoint | +| `SIE_API_KEY` | empty | Optional bearer token for auth-enabled SIE clusters | +| `SIE_EMBED_MODEL` | `sentence-transformers/all-MiniLM-L6-v2` | Embedding model | +| `PATRONUS_MEMORY_PATH` | `data/memory.json` | Local memory store | +| `PATRONUS_MAX_TEXT_CHARS` | `12000` | Per-page text cap | + +The extension popup can store optional provider keys in Chrome local storage. +For a hosted/auth-enabled SIE memory endpoint, add that endpoint's origin to +`extension/manifest.json` `host_permissions` before loading the extension. + +## API + +The local bridge exposes: + +### `POST /ingest` + +```json +{ + "title": "Example page", + "url": "https://example.com", + "text": "Page text to remember" +} +``` + +### `POST /query` + +```json +{ + "query": "browser memory", + "limit": 5 +} +``` + +Response: + +```json +{ + "results": [ + { + "title": "Example page", + "url": "https://example.com", + "text": "Short excerpt...", + "score": 0.82 + } + ] +} +``` + +## Project layout + +```text +ai-patronus-browser-memory/ +├── extension/ # Manifest V3 Chrome extension +└── memory_server/ # FastAPI bridge from extension to SIE embeddings +``` + +## Privacy notes + +- The extension sends page text only when the user explicitly asks it to + remember the page. +- The bridge stores memories locally by default in `memory_server/data/`. +- Do not enable automatic history ingestion without explicit consent, filtering, + and deletion controls. diff --git a/examples/ai-patronus-browser-memory/extension/background.js b/examples/ai-patronus-browser-memory/extension/background.js new file mode 100644 index 00000000..55e8689b --- /dev/null +++ b/examples/ai-patronus-browser-memory/extension/background.js @@ -0,0 +1,362 @@ +// Patronus AI - background service worker. +// Talks to the partner APIs directly: Gemini (brain), SLNG (voice), Tavily (eyes), +// Mubit/Minima (picks the cheapest capable model, then we run it). Keys come from +// chrome.storage.local first, then optional config.local.js defaults (gitignored). + +let DEF = {}; +try { importScripts("config.local.js"); DEF = self.__PATRONUS_DEFAULTS || {}; } catch (e) { /* no local defaults */ } + +const GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta/models"; +const SLNG_TTS = "https://api.slng.ai/v1/bridges/unmute/tts/slng/deepgram/aura:2-en"; +const TAVILY_SEARCH = "https://api.tavily.com/search"; +const MINIMA_BASE = "https://api.minima.sh/v1"; + +// Where "I'm bored, launch a game" sends the user. Override via storage key GAMES. +const DEFAULT_GAMES = ["https://game-factory.tech", "https://tims-arcade.pages.dev"]; + +async function getCfg() { + const d = await chrome.storage.local.get([ + "GEMINI_API_KEY", "GEMINI_MODEL", "SLNG_API_KEY", "TAVILY_API_KEY", "MUBIT_API_KEY", + "N8N_WEBHOOK_URL", "SUPERLINKED_URL", "SUPERLINKED_TOKEN", "GAMES", "muted" + ]); + return { + geminiKey: d.GEMINI_API_KEY || DEF.GEMINI_API_KEY || "", + geminiModel: d.GEMINI_MODEL || DEF.GEMINI_MODEL || "gemini-2.5-flash", + slngKey: d.SLNG_API_KEY || DEF.SLNG_API_KEY || "", + tavilyKey: d.TAVILY_API_KEY || DEF.TAVILY_API_KEY || "", + mubitKey: d.MUBIT_API_KEY || DEF.MUBIT_API_KEY || "", + n8nWebhook: d.N8N_WEBHOOK_URL || DEF.N8N_WEBHOOK_URL || "", + superlinkedUrl: d.SUPERLINKED_URL || DEF.SUPERLINKED_URL || "", + superlinkedToken: d.SUPERLINKED_TOKEN || DEF.SUPERLINKED_TOKEN || "", + games: (Array.isArray(d.GAMES) && d.GAMES.length) ? d.GAMES : DEFAULT_GAMES, + muted: !!d.muted + }; +} + +// fetch with a hard timeout (the SW can be torn down on a slow request) +async function fetchT(url, opts, ms = 12000) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), ms); + try { return await fetch(url, { ...opts, signal: ctrl.signal }); } + finally { clearTimeout(t); } +} + +// ---- sponsor usage log (flight recorder): real request/response per call ------ +async function logEvent(e) { + try { + const d = await chrome.storage.local.get("sponsorLog"); + const log = Array.isArray(d.sponsorLog) ? d.sponsorLog : []; + log.push(Object.assign({ ts: Date.now() }, e)); + while (log.length > 150) log.shift(); + await chrome.storage.local.set({ sponsorLog: log }); + } catch (err) {} +} + +// ---- Mubit / Minima: pick the cheapest capable model, then we run it --------- +async function minimaPick(taskText, taskType, via) { + const cfg = await getCfg(); + if (!cfg.mubitKey) return null; + try { + const body = { task: { task: (taskText || "").slice(0, 300), task_type: taskType || "other" }, cost_quality_tradeoff: 3 }; + const r = await fetchT(MINIMA_BASE + "/recommend", { + method: "POST", + headers: { "authorization": "Bearer " + cfg.mubitKey, "content-type": "application/json" }, + body: JSON.stringify(body) + }, 6000); + const j = await r.json(); + if (!r.ok) return null; + // only pick models we can actually run with the Gemini key + const runnable = m => m && m.provider === "google" && /^gemini-/.test(m.model_id || ""); + const m = runnable(j.recommended_model) ? j.recommended_model : (j.ranked || []).find(runnable); + logEvent({ sponsor: "Mubit", op: "recommend", via: via || taskText, request: body, response: { picked: m ? m.model_id : null, est_cost_usd: m ? m.est_cost_usd : null, recommendation_id: j.recommendation_id, ranked: (j.ranked || []).slice(0, 4).map(x => x.model_id) } }); + return { recommendationId: j.recommendation_id, modelId: m ? m.model_id : null, est: m ? m.est_cost_usd : (j.recommended_model && j.recommended_model.est_cost_usd) }; + } catch (e) { return null; } +} +async function minimaFeedback(recommendationId, modelId, usage) { + const cfg = await getCfg(); + if (!cfg.mubitKey || !recommendationId) return; + try { + await fetchT(MINIMA_BASE + "/feedback", { + method: "POST", + headers: { "authorization": "Bearer " + cfg.mubitKey, "content-type": "application/json" }, + body: JSON.stringify({ recommendation_id: recommendationId, chosen_model_id: modelId, outcome: "success", input_tokens: (usage && usage.in) || 0, output_tokens: (usage && usage.out) || 0 }) + }, 5000); + } catch (e) {} +} + +// ---- Gemini: a soul-driven reply (model chosen by Minima) ------------------- +async function callGemini(model, key, sys, message, history) { + const url = `${GEMINI_BASE}/${model}:generateContent?key=${encodeURIComponent(key)}`; + const contents = (Array.isArray(history) ? history : []).map(h => ({ role: h.role === "user" ? "user" : "model", parts: [{ text: String(h.text || "").slice(0, 600) }] })); + contents.push({ role: "user", parts: [{ text: message }] }); + const r = await fetchT(url, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + system_instruction: { parts: [{ text: sys }] }, + contents, + generationConfig: { temperature: 0.9, maxOutputTokens: 220 } + }) + }); + const j = await r.json(); + if (!r.ok) return { ok: false, status: r.status, errMsg: (j && j.error && j.error.message) || ("http " + r.status) }; + const text = (j?.candidates?.[0]?.content?.parts || []).map(p => p.text).join("").trim() || "..."; + const u = j.usageMetadata || {}; + return { ok: true, text, usage: { in: u.promptTokenCount || 0, out: u.candidatesTokenCount || 0 } }; +} + +async function geminiReply({ soul, message, context, taskType, history, via }) { + const cfg = await getCfg(); + if (!cfg.geminiKey) return { error: "no_gemini_key", text: "(Add a Gemini key so I can think.)" }; + const sys = (soul || "You are a friendly browser companion.") + (context ? `\n\n## Current page context (may help)\n${context.slice(0, 8000)}` : ""); + const pick = await minimaPick(message, taskType || "creative", via); + let model = (pick && pick.modelId) || cfg.geminiModel; + try { + let res = await callGemini(model, cfg.geminiKey, sys, message, history); + if (!res.ok && model !== cfg.geminiModel) { model = cfg.geminiModel; res = await callGemini(model, cfg.geminiKey, sys, message, history); } // fallback + if (!res.ok) { logEvent({ sponsor: "Gemini", op: taskType || "chat", via: via || message, ok: false, request: { model, system: (sys || "").slice(0, 500), user: message }, response: { error: res.errMsg } }); return { error: "gemini_" + (res.status || "x"), text: res.errMsg || "Gemini hiccup." }; } + if (pick && pick.recommendationId) minimaFeedback(pick.recommendationId, model, res.usage); // fire and forget + logEvent({ sponsor: "Gemini", op: taskType || "chat", via: via || message, request: { model, system: (sys || "").slice(0, 500), user: message }, response: { text: res.text }, meta: { model, in: res.usage.in, out: res.usage.out } }); + return { text: res.text, model, minima: pick ? model : null, est: pick ? pick.est : null }; + } catch (e) { return { error: "gemini_fetch", text: "(Couldn't reach Gemini.)" }; } +} + +// ---- SLNG: text -> spoken audio (WAV) -------------------------------------- +function abToBase64(buf) { + const bytes = new Uint8Array(buf); let bin = ""; const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + return btoa(bin); +} +async function slngSpeak({ text, voice, via }) { + const cfg = await getCfg(); + if (cfg.muted) return { skipped: "muted" }; + if (!cfg.slngKey) return { error: "no_slng_key" }; + try { + const body = { text }; if (voice) body.voice = voice; + const r = await fetchT(SLNG_TTS, { + method: "POST", + headers: { "Authorization": "Bearer " + cfg.slngKey, "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + if (!r.ok) { logEvent({ sponsor: "SLNG", op: "tts", via: via || text, ok: false, request: { endpoint: SLNG_TTS, text: (text || "").slice(0, 200), voice }, response: { status: r.status } }); return { error: "slng_http_" + r.status }; } + const buf = await r.arrayBuffer(); + logEvent({ sponsor: "SLNG", op: "tts", via: via || text, request: { endpoint: SLNG_TTS, text: (text || "").slice(0, 200), voice }, response: { status: r.status, audio_bytes: buf.byteLength } }); + return { audio: "data:audio/wav;base64," + abToBase64(buf) }; + } catch (e) { return { error: "slng_fetch" }; } +} + +// ---- Tavily: research the whole web ---------------------------------------- +async function tavilyResearch({ query, via }) { + const cfg = await getCfg(); + if (!cfg.tavilyKey) return { error: "no_tavily_key" }; + try { + const reqBody = { query, max_results: 5, include_answer: "advanced", search_depth: "advanced" }; + const r = await fetchT(TAVILY_SEARCH, { + method: "POST", + headers: { "Authorization": "Bearer " + cfg.tavilyKey, "Content-Type": "application/json" }, + body: JSON.stringify(reqBody) + }); + const j = await r.json(); + if (!r.ok) { logEvent({ sponsor: "Tavily", op: "search", via: via || query, ok: false, request: reqBody, response: { status: r.status } }); return { error: "tavily_http_" + r.status }; } + logEvent({ sponsor: "Tavily", op: "search", via: via || query, request: reqBody, response: { answer: (j.answer || "").slice(0, 400), results: (j.results || []).map(x => ({ title: x.title, url: x.url })).slice(0, 5) } }); + return { answer: j.answer || "", results: (j.results || []).map(x => ({ title: x.title, url: x.url })) }; + } catch (e) { return { error: "tavily_fetch" }; } +} + +async function openGame() { + const cfg = await getCfg(); + const url = cfg.games[Math.floor(Math.random() * cfg.games.length)]; + await chrome.tabs.create({ url }); + return { opened: url }; +} + +async function openUrl({ url }) { + if (!url || !/^https?:\/\//.test(url)) return { error: "bad_url" }; + await chrome.tabs.create({ url }); + return { opened: url }; +} + +// Hand the search to the OPENED TAB's content script, which performs it VISIBLY +// (the character walks to the search bar, types, submits, then suggests). +async function siteSearch({ site, query }) { + let s = String(site || "").trim().replace(/^https?:\/\//, "").replace(/\/.*$/, ""); + if (!s) return { error: "bad_site" }; + if (!s.includes(".")) s += ".com"; // "harrods" -> "harrods.com" + const url = "https://" + s; + let q = String(query || ""); try { if (/%[0-9a-f]{2}/i.test(q)) q = decodeURIComponent(q); } catch (e) {} // un-encode if the model encoded it + await chrome.storage.local.set({ pendingSearch: { host: s, query: q, ts: Date.now() } }); + await chrome.tabs.create({ url, active: true }); + return { opened: url, searched: query }; +} + +// ---- n8n: fire a workflow via its webhook (automations / reminders) ---------- +async function n8nRun({ task }) { + const cfg = await getCfg(); + if (!cfg.n8nWebhook) return { error: "no_n8n" }; + try { + const r = await fetchT(cfg.n8nWebhook, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ task, source: "patronus-ai" }) + }, 9000); + return { ok: r.ok, status: r.status }; + } catch (e) { return { error: "n8n_fetch" }; } +} + +// ---- Superlinked: remember a page / recall it semantically ------------------- +function slHeaders(cfg) { + return { "Content-Type": "application/json", ...(cfg.superlinkedToken ? { "Authorization": "Bearer " + cfg.superlinkedToken } : {}) }; +} +async function slRemember({ text, url, title }) { + const cfg = await getCfg(); + if (!cfg.superlinkedUrl) return { error: "no_superlinked" }; + try { + const r = await fetchT(cfg.superlinkedUrl.replace(/\/$/, "") + "/ingest", { method: "POST", headers: slHeaders(cfg), body: JSON.stringify({ text, url, title }) }, 9000); + const j = await r.json().catch(() => ({})); + logEvent({ sponsor: "Superlinked", op: "remember page", via: title || url || "page memory", request: { title, url, text_chars: String(text || "").length }, response: { ok: r.ok, id: j.id, count: j.count } }); + return { ok: r.ok, id: j.id, count: j.count }; + } catch (e) { return { error: "sl_fetch" }; } +} +async function slRecall({ query }) { + const cfg = await getCfg(); + if (!cfg.superlinkedUrl) return { error: "no_superlinked" }; + try { + const r = await fetchT(cfg.superlinkedUrl.replace(/\/$/, "") + "/query", { method: "POST", headers: slHeaders(cfg), body: JSON.stringify({ query, limit: 5 }) }, 9000); + const j = await r.json().catch(() => ({})); + logEvent({ sponsor: "Superlinked", op: "semantic recall", via: query, request: { query, limit: 5 }, response: { ok: r.ok, results: (j.results || j.hits || []).slice(0, 5).map(x => ({ title: x.title, url: x.url, score: x.score })) } }); + return { ok: r.ok, results: j.results || j.hits || [] }; + } catch (e) { return { error: "sl_fetch" }; } +} +// Superlinked SIE: open-model (Qwen) generation via the OpenAI-compatible gateway. +// Short timeout: when the cluster model is warm we use it; when cold we return null +// and the caller falls back to Gemini so the demo never hangs. +const SIE_MODEL = "Qwen/Qwen3.5-4B"; +async function sieGenerate({ prompt, system, via }) { + const cfg = await getCfg(); + if (!cfg.superlinkedUrl) return { ok: false }; + try { + const base = cfg.superlinkedUrl.replace(/\/$/, ""); + const messages = []; if (system) messages.push({ role: "system", content: String(system).slice(0, 1500) }); + messages.push({ role: "user", content: prompt }); + const r = await fetchT(base + "/v1/chat/completions", { + method: "POST", + headers: slHeaders(cfg), + body: JSON.stringify({ model: SIE_MODEL, messages, max_tokens: 180 }) + }, 6000); + const j = await r.json().catch(() => ({})); + const text = j?.choices?.[0]?.message?.content; + if (!r.ok || !text) return { ok: false }; // cold/loading -> caller falls back + logEvent({ sponsor: "Superlinked", op: "generate (open Qwen)", via: via || prompt, request: { endpoint: base + "/v1/chat/completions", model: SIE_MODEL, user: prompt.slice(0, 220) }, response: { text } }); + return { ok: true, text: text.trim() }; + } catch (e) { return { ok: false }; } +} +// Superlinked SIE: semantic ranking via open embeddings (reliable; the gen model is +// often cold). One batched /v1/embeddings call ranks the products by the query. +async function sieRank({ query, items, via }) { + const cfg = await getCfg(); + if (!cfg.superlinkedUrl || !Array.isArray(items) || !items.length) return { ok: false }; + try { + const base = cfg.superlinkedUrl.replace(/\/$/, ""); + const list = items.slice(0, 10); + const input = [query, ...list]; + const r = await fetchT(base + "/v1/embeddings", { + method: "POST", + headers: slHeaders(cfg), + body: JSON.stringify({ model: "sentence-transformers/all-MiniLM-L6-v2", input }) + }, 12000); + const j = await r.json().catch(() => ({})); + const vecs = (j.data || []).map(d => d.embedding); + if (!r.ok || vecs.length !== input.length) return { ok: false }; + const q = vecs[0], cos = (a, b) => { let s = 0, na = 0, nb = 0; for (let i = 0; i < a.length; i++) { s += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } return s / (Math.sqrt(na * nb) || 1); }; + const order = list.map((it, i) => ({ i, score: cos(q, vecs[i + 1]) })).sort((a, b) => b.score - a.score); + logEvent({ sponsor: "Superlinked", op: "embeddings rank (SIE)", via: via || query, request: { endpoint: base + "/v1/embeddings", model: "all-MiniLM-L6-v2", query, items: list.length }, response: { top: order.slice(0, 3).map(o => ({ item: list[o.i].slice(0, 40), score: +o.score.toFixed(3) })) } }); + return { ok: true, order: order.map(o => o.i) }; + } catch (e) { return { ok: false }; } +} + +// ---- agentic router: turn "find flip flops on harrods" into a real action ---- +async function routeIntent({ soul, message, name, history }) { + const cfg = await getCfg(); + if (!cfg.geminiKey) return { action: "answer" }; + const recent = (Array.isArray(history) ? history : []).slice(-6).map(h => `${h.role === "user" ? "User" : (name || "You")}: ${String(h.text || "").slice(0, 160)}`).join("\n"); + const sys = + `You are the intent router for a browser guardian character${name ? " named " + name : ""}. ` + + `Personality (for the spoken line only):\n${(soul || "").slice(0, 800)}\n\n` + + (recent ? `Recent conversation (memory/context):\n${recent}\n\n` : "") + + `STRONGLY prefer a real ACTION over just answering whenever the user says find / get / show / open / go / buy / where / play / watch / research / remind. ` + + `Reply ONLY with minified JSON: {"action":"site_search"|"navigate"|"page_qa"|"web_research"|"play_game"|"perform"|"remember"|"recall"|"answer","site":"","query":"","url":"","say":""}. Rules: ` + + `- SHOPPING for a product ("find / buy / get / cheapest / show me ", with or WITHOUT a store): action="site_search". If a store/brand is named use its domain (e.g. "harrods.com", "nike.com", "amazon.co.uk"); if NO store is named, default site="amazon.co.uk". query=the product terms. Do NOT guess a search URL. ` + + `- A shop/brand/place + a location, or "near me"/"in ": action="navigate", url="https://www.google.com/maps/search/QUERY". ` + + `- Videos: action="navigate", url="https://www.youtube.com/results?search_query=QUERY". ` + + `- General web lookups/facts: action="navigate", url="https://www.google.com/search?q=QUERY". ` + + `- "summarize/explain/what's on THIS page/article": action="page_qa". ` + + `- "research X / latest on X / dig into X": action="web_research", query=topic. ` + + `- "I'm bored / play a game / play the brainrot game / brainrot 2048 / meme game / play ": action="play_game", query=the game name if mentioned. ` + + `- "fly around / dance / spin / go crazy / do a trick / show off / animate yourself / draw stuff": action="perform" (a fun on-screen animation). ` + + `- Save memory ("remember this page", "save this article for later"): action="remember". ` + + `- Recall ("what did I see about...", "that page I saved about..."): action="recall", query=topic. ` + + `- Only pure conversation: action="answer". ` + + `URL-encode queries ONLY inside navigate URLs; the site_search "query" must be PLAIN text (no %20). "say" = ONE short in-character spoken line under 22 words.`; + try { + const r = await fetchT(`${GEMINI_BASE}/${cfg.geminiModel}:generateContent?key=${encodeURIComponent(cfg.geminiKey)}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + system_instruction: { parts: [{ text: sys }] }, + contents: [{ role: "user", parts: [{ text: message }] }], + generationConfig: { temperature: 0.3, maxOutputTokens: 240, responseMimeType: "application/json" } + }) + }, 9000); + const j = await r.json(); + if (!r.ok) return { action: "answer" }; + let t = (j?.candidates?.[0]?.content?.parts || []).map(p => p.text).join("").trim().replace(/^```json/i, "").replace(/```$/, "").trim(); + const o = JSON.parse(t); + logEvent({ sponsor: "Gemini", op: "route", via: message, request: { model: cfg.geminiModel, user: message }, response: { action: o.action, target: o.url || o.site || o.query || "", raw: t.slice(0, 300) } }); + if (o.action === "site_search" && o.site && o.query) return { action: "site_search", site: o.site, query: o.query, say: o.say || "On it!" }; + if (o.action === "remember") return { action: "remember", say: o.say || "Saved for later." }; + if (o.action === "recall" && o.query) return { action: "recall", query: o.query, say: o.say || "Let me remember..." }; + if (o.action === "perform") return { action: "perform", say: o.say || "wheee!" }; + if (o.action === "page_qa") return { action: "page_qa", say: o.say || "Reading this page..." }; + if (o.action === "web_research") return { action: "web_research", query: o.query || message, say: o.say || "Searching the web..." }; + if (o.action === "play_game") return { action: "play_game", say: o.say || "Let's play!" }; + if (o.action === "navigate" && /^https?:\/\//.test(o.url || "")) return { action: "navigate", url: o.url, say: o.say || "On it!" }; + return { action: "answer", say: o.say || "" }; + } catch (e) { return { action: "answer" }; } +} + +// ---- message router --------------------------------------------------------- +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + (async () => { + try { + switch (msg && msg.type) { + case "CHAT": sendResponse(await geminiReply({ soul: msg.soul, message: msg.message, context: msg.context, taskType: "creative", history: msg.history, via: msg.via })); break; + case "PAGE_QA": sendResponse(await geminiReply({ soul: msg.soul, message: msg.question, context: msg.text, taskType: "qa", history: msg.history, via: msg.via })); break; + case "SPEAK": sendResponse(await slngSpeak(msg)); break; + case "RESEARCH": sendResponse(await tavilyResearch(msg)); break; + case "OPEN_GAME": sendResponse(await openGame()); break; + case "GAME_URL": { const qq = String(msg.query || "").toLowerCase(); let url; if (/brainrot|meme|2048/.test(qq)) { url = "https://game-factory.tech/games/brainrot_2048/"; } else { const c = await getCfg(); url = c.games[Math.floor(Math.random() * c.games.length)]; } sendResponse({ url }); break; } + case "ACT": sendResponse(await routeIntent(msg)); break; + case "OPEN_URL": sendResponse(await openUrl(msg)); break; + case "SITE_SEARCH": sendResponse(await siteSearch(msg)); break; + case "N8N_RUN": sendResponse(await n8nRun(msg)); break; + case "SL_REMEMBER": sendResponse(await slRemember(msg)); break; + case "SL_RECALL": sendResponse(await slRecall(msg)); break; + case "SIE_GEN": sendResponse(await sieGenerate(msg)); break; + case "SIE_RANK": sendResponse(await sieRank(msg)); break; + case "GET_POWERS": { const c = await getCfg(); sendResponse({ gemini: !!c.geminiKey, slng: !!c.slngKey, tavily: !!c.tavilyKey, mubit: !!c.mubitKey, n8n: !!c.n8nWebhook, superlinked: !!c.superlinkedUrl }); break; } + default: sendResponse({ error: "unknown_message" }); + } + } catch (e) { sendResponse({ error: "handler_crash", detail: String(e) }); } + })(); + return true; // async +}); + +// Re-inject the content script into already-open tabs on install/update/reload, +// so a reloaded extension takes over stale tabs without a manual page refresh. +chrome.runtime.onInstalled.addListener(reinjectAll); +chrome.runtime.onStartup && chrome.runtime.onStartup.addListener(reinjectAll); +async function reinjectAll() { + try { + const tabs = await chrome.tabs.query({ url: ["http://*/*", "https://*/*"] }); + for (const t of tabs) { + try { await chrome.scripting.executeScript({ target: { tabId: t.id }, files: ["content.js"] }); } catch (e) {} + } + } catch (e) {} +} diff --git a/examples/ai-patronus-browser-memory/extension/char.png b/examples/ai-patronus-browser-memory/extension/char.png new file mode 100644 index 00000000..db364608 Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/char.png differ diff --git a/examples/ai-patronus-browser-memory/extension/chars/cat.mp4 b/examples/ai-patronus-browser-memory/extension/chars/cat.mp4 new file mode 100644 index 00000000..4278017c Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/chars/cat.mp4 differ diff --git a/examples/ai-patronus-browser-memory/extension/chars/cat.png b/examples/ai-patronus-browser-memory/extension/chars/cat.png new file mode 100644 index 00000000..5c5def19 Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/chars/cat.png differ diff --git a/examples/ai-patronus-browser-memory/extension/chars/dog.mp4 b/examples/ai-patronus-browser-memory/extension/chars/dog.mp4 new file mode 100644 index 00000000..c3142fea Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/chars/dog.mp4 differ diff --git a/examples/ai-patronus-browser-memory/extension/chars/dog.png b/examples/ai-patronus-browser-memory/extension/chars/dog.png new file mode 100644 index 00000000..4f1a97cb Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/chars/dog.png differ diff --git a/examples/ai-patronus-browser-memory/extension/chars/grandma.mp4 b/examples/ai-patronus-browser-memory/extension/chars/grandma.mp4 new file mode 100644 index 00000000..ce57dd23 Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/chars/grandma.mp4 differ diff --git a/examples/ai-patronus-browser-memory/extension/chars/grandma.png b/examples/ai-patronus-browser-memory/extension/chars/grandma.png new file mode 100644 index 00000000..4714a863 Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/chars/grandma.png differ diff --git a/examples/ai-patronus-browser-memory/extension/chars/pupa.png b/examples/ai-patronus-browser-memory/extension/chars/pupa.png new file mode 100644 index 00000000..fec6aab4 Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/chars/pupa.png differ diff --git a/examples/ai-patronus-browser-memory/extension/chars/sixseven.mp4 b/examples/ai-patronus-browser-memory/extension/chars/sixseven.mp4 new file mode 100644 index 00000000..a1c8693c Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/chars/sixseven.mp4 differ diff --git a/examples/ai-patronus-browser-memory/extension/chars/sixseven.png b/examples/ai-patronus-browser-memory/extension/chars/sixseven.png new file mode 100644 index 00000000..5fc3f278 Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/chars/sixseven.png differ diff --git a/examples/ai-patronus-browser-memory/extension/chars/tungtung.mp4 b/examples/ai-patronus-browser-memory/extension/chars/tungtung.mp4 new file mode 100644 index 00000000..d8961471 Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/chars/tungtung.mp4 differ diff --git a/examples/ai-patronus-browser-memory/extension/chars/tungtung.png b/examples/ai-patronus-browser-memory/extension/chars/tungtung.png new file mode 100644 index 00000000..2e32d58a Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/chars/tungtung.png differ diff --git a/examples/ai-patronus-browser-memory/extension/chars/wizard.mp4 b/examples/ai-patronus-browser-memory/extension/chars/wizard.mp4 new file mode 100644 index 00000000..7bfe07d1 Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/chars/wizard.mp4 differ diff --git a/examples/ai-patronus-browser-memory/extension/chars/wizard.png b/examples/ai-patronus-browser-memory/extension/chars/wizard.png new file mode 100644 index 00000000..c1becfd9 Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/chars/wizard.png differ diff --git a/examples/ai-patronus-browser-memory/extension/config.local.example.js b/examples/ai-patronus-browser-memory/extension/config.local.example.js new file mode 100644 index 00000000..0990b6e5 --- /dev/null +++ b/examples/ai-patronus-browser-memory/extension/config.local.example.js @@ -0,0 +1,12 @@ +// Optional local defaults. Copy this to config.local.js, which is gitignored. +// The popup settings override any value here. Never commit real keys. +self.__PATRONUS_DEFAULTS = { + GEMINI_API_KEY: "", // https://aistudio.google.com/api-keys + GEMINI_MODEL: "gemini-2.5-flash", + SLNG_API_KEY: "", // https://app.slng.ai + TAVILY_API_KEY: "", // https://tavily.com + MUBIT_API_KEY: "", // https://console.mubit.ai + N8N_WEBHOOK_URL: "", // Optional n8n webhook URL + SUPERLINKED_URL: "", // Example: http://localhost:8800 + SUPERLINKED_TOKEN: "" // Optional bearer token for an auth-enabled endpoint +}; diff --git a/examples/ai-patronus-browser-memory/extension/content.js b/examples/ai-patronus-browser-memory/extension/content.js new file mode 100644 index 00000000..f9bc8a02 --- /dev/null +++ b/examples/ai-patronus-browser-memory/extension/content.js @@ -0,0 +1,545 @@ +// Patronus AI - content script. Your guardian, living on every page. +// Shadow DOM isolates it; supports hot re-injection on extension reload. +(function () { + const old = document.getElementById("patronus-host"); + if (old) { try { old.remove(); } catch (e) {} } // a newer version takes over + window.__patronusLoaded = true; + + const CHARS = { + grandma: { name: "Surf Granny", emoji: "👵", accent: "#8ecbff", voice: "aura-2-theia-en" }, + tungtung: { name: "Tung Tung", emoji: "🪵", accent: "#c79a5b", voice: "aura-2-orion-en" }, + sixseven: { name: "Six Seven", emoji: "✋", accent: "#7fc7f5", voice: "aura-2-thalia-en" }, + cat: { name: "Mochi", emoji: "🐱", accent: "#ff9bb3", voice: "aura-2-thalia-en" }, + dog: { name: "Biscuit", emoji: "🐶", accent: "#ffce7a", voice: "aura-2-orion-en" }, + pupa: { name: "Pupa", emoji: "🐛", accent: "#9be08a", voice: "aura-2-luna-en" }, + wizard: { name: "Harry", emoji: "🧙", accent: "#9b8cff", voice: "aura-2-orion-en" } + }; + const DEFAULT_CHAR = "grandma"; + const GREETING = { + grandma: "cowabunga, sweetie - what are we doing?", tungtung: "tung tung tung! what's the mission?", + sixseven: "ayy six seven - whatcha need?", cat: "mrrp. what do you want, human?", + dog: "HI!! what are we finding today??", pupa: "hi. let's become something today.", + wizard: "expecto... assistance! what do you need?" + }; + + let charId = DEFAULT_CHAR, muted = false, soulCache = {}, history = [], lastVia = ""; + let bubbleTimer = null, idleTimer = null, wanderTimer = null, recog = null, audio = null; + + const host = document.createElement("div"); + host.id = "patronus-host"; + host.style.cssText = "position:fixed;inset:0;z-index:2147483646;pointer-events:none;"; + const root = host.attachShadow({ mode: "open" }); + root.innerHTML = ` + +
👵
+
+
+
👵Surf Granny + 🔊
+
+
+ + + +
+
`; + (document.documentElement || document.body).appendChild(host); + + const $ = id => root.getElementById(id); + const pet = $("pet"), emojiEl = $("emoji"), petimg = $("petimg"), petvid = $("petvid"), bubble = $("bubble"); + const panel = $("panel"), thread = $("thread"), inp = $("inp"); + const send = msg => new Promise(res => { try { chrome.runtime.sendMessage(msg, res); } catch (e) { res({ error: "no_bg" }); } }); + // when the extension is reloaded, this OLD content script's context dies. detect it and + // self-destruct cleanly instead of throwing "Extension context invalidated" from stale timers. + const alive = () => { try { return !!(chrome.runtime && chrome.runtime.id); } catch (e) { return false; } }; + function teardown() { clearTimeout(bubbleTimer); clearTimeout(idleTimer); clearTimeout(wanderTimer); try { if (audio) audio.pause(); } catch (e) {} try { if (recog) recog.stop(); } catch (e) {} try { host.remove(); } catch (e) {} } + const store = { get: (k, cb) => { try { chrome.storage.local.get(k, cb); } catch (e) {} }, set: o => { try { chrome.storage.local.set(o); } catch (e) {} }, remove: k => { try { chrome.storage.local.remove(k); } catch (e) {} } }; + const esc = s => (s || "").replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c])); + const escA = s => (s || "").replace(/"/g, "%22"); + const cur = () => CHARS[charId] || CHARS[DEFAULT_CHAR]; + + function applyChar() { + const c = cur(); + [host, pet, panel, bubble].forEach(el => el.style.setProperty("--ac", c.accent)); + emojiEl.textContent = c.emoji; + pet.classList.remove("hasimg", "hasvid"); + petimg.onload = () => { if (!pet.classList.contains("hasvid")) pet.classList.add("hasimg"); }; + petimg.onerror = () => pet.classList.remove("hasimg"); + try { petimg.src = chrome.runtime.getURL("chars/" + charId + ".png"); } catch (e) {} + petvid.onloadeddata = () => { pet.classList.add("hasvid"); petvid.play().catch(() => {}); }; + petvid.onerror = () => pet.classList.remove("hasvid"); + try { petvid.src = chrome.runtime.getURL("chars/" + charId + ".mp4"); petvid.load(); } catch (e) {} + $("pav").textContent = c.emoji; $("pnm").textContent = c.name; + } + + // ---- conversation history (persisted -> she remembers you across sessions) -- + function renderThread() { + if (!history.length) { thread.innerHTML = `
say hi, or ask me to find something.
`; return; } + thread.innerHTML = history.map(m => { + const links = (m.links || []).map(l => `${esc(l.label || l.url)} ↗`).join(""); + const tag = m.tag ? `${esc(m.tag)}` : ""; + return `
${esc(m.text)}${links}${tag}
`; + }).join(""); + thread.scrollTop = thread.scrollHeight; + } + function pushMsg(role, text, extra) { history.push(Object.assign({ role, text }, extra || {})); if (history.length > 40) history = history.slice(-40); renderThread(); store.set({ ["chat_" + charId]: history }); } + function loadHistory() { store.get(["chat_" + charId], d => { history = Array.isArray(d["chat_" + charId]) ? d["chat_" + charId] : []; renderThread(); }); } + function recentContext() { return history.slice(-8).map(m => ({ role: m.role === "you" ? "user" : "model", text: m.text })); } + + async function getSoul() { + if (soulCache[charId]) return soulCache[charId]; + try { const r = await fetch(chrome.runtime.getURL(`souls/${charId}.md`)); soulCache[charId] = await r.text(); return soulCache[charId]; } + catch (e) { return `You are ${cur().name}, a friendly browser guardian.`; } + } + + // place the speech bubble ABOVE the pet, right-aligned, never overlapping it + function positionNear() { + const r = pet.getBoundingClientRect(); + const bw = bubble.offsetWidth || 220, bh = bubble.offsetHeight || 56; + let left = Math.max(10, Math.min(r.right - bw, window.innerWidth - bw - 10)); + let top = r.top - bh - 14; + if (top < 10) top = Math.min(window.innerHeight - bh - 10, r.bottom + 14); // flip below if no room + bubble.style.left = left + "px"; bubble.style.top = top + "px"; + } + function say(text, { speak = true } = {}) { + if (!text) return; + if (!panel.classList.contains("open")) { // panel open -> the thread shows it; skip the floating bubble + bubble.innerHTML = `${esc(cur().name)}${esc(text)}`; + bubble.classList.add("show"); + positionNear(); // measure AFTER content is set + clearTimeout(bubbleTimer); + bubbleTimer = setTimeout(() => bubble.classList.remove("show"), Math.min(9000, 2600 + text.length * 45)); + } + if (speak && !muted) voice(text); + } + async function voice(text) { + const r = await send({ type: "SPEAK", text: text.slice(0, 300), voice: cur().voice, via: lastVia }); + if (r && r.audio) { try { if (audio) audio.pause(); audio = new Audio(r.audio); audio.play().catch(() => browserVoice(text)); return; } catch (e) {} } + browserVoice(text); + } + function browserVoice(text) { + try { + if (!window.speechSynthesis) return; + const u = new SpeechSynthesisUtterance(text); + const map = { grandma: { rate: .9, pitch: .8 }, cat: { rate: 1.05, pitch: 1.5 }, dog: { rate: 1.25, pitch: 1.3 }, pupa: { rate: .95, pitch: 1.1 }, tungtung: { rate: 1.1, pitch: .7 }, sixseven: { rate: 1.15, pitch: 1.2 }, skibidi: { rate: 1.0, pitch: 1.0 } }; + Object.assign(u, map[charId] || {}); speechSynthesis.cancel(); speechSynthesis.speak(u); + } catch (e) {} + } + + // a character reply lands in the thread, the bubble, and the voice + function botReply(text, extra) { if (!text) return; pushMsg("bot", text, extra); say(text); } + + async function rememberCurrentPage() { + const text = document.body ? document.body.innerText.slice(0, 14000) : ""; + if (!text.trim()) { botReply("I could not find readable text on this page."); return; } + const r = await send({ type: "SL_REMEMBER", title: document.title, url: location.href, text }); + if (r && r.ok) botReply("Saved this page to memory.", { tag: "Superlinked memory" }); + else botReply("Connect the Superlinked memory bridge first.", { tag: "Set Superlinked URL in API settings" }); + } + + async function recallMemories(query) { + const r = await send({ type: "SL_RECALL", query }); + const links = (r && r.results || []).slice(0, 5).map(x => ({ url: x.url || "#", label: x.title || x.url || "Saved page" })); + if (links.length) botReply("Here's what I remembered:", { links, tag: "Superlinked semantic recall" }); + else botReply("I do not have matching saved pages yet.", { tag: "Try: remember this page" }); + } + + // ---- the one agentic input: she decides what to do -------------------------- + async function submit() { + const q = inp.value.trim(); if (!q) return; + inp.value = ""; resetIdle(); lastVia = q; + pushMsg("you", q); + // hardcoded: fidget spinner (Six Seven's bit). ONLY on an imperative spin command, + // never on a question that merely mentions spinners ("research fidget spinners"). + const spinWord = /\b(spin|spinner|fidget)\b/i.test(q); + const askingAbout = /\b(research|history|what|whats|who|whose|when|where|why|how|best|top|review|reviews|about|explain|tell|find|search|buy|price|cheap|origin|invent|meaning|info)\b/i.test(q) || q.includes("?"); + if (spinWord && !askingAbout) { + botReply(charId === "sixseven" ? "six seven!! 🌀 watch this spinnnn!!" : "okok - flick it and watch!"); + showSpinner(); return; + } + if (/\b(remember|save)\b.*\b(this|page|article|site)\b/i.test(q)) { + botReply("Saving this page..."); + await rememberCurrentPage(); + return; + } + if (/\b(recall|remembered|what did i save|what did i see|that page i saved)\b/i.test(q)) { + await recallMemories(q); + return; + } + const soul = await getSoul(); const ctx = recentContext(); + const route = await send({ type: "ACT", soul, message: q, name: cur().name, history: ctx, via: q }); + const act = route && route.action; + try { + if (act === "navigate" && route.url) { + botReply(route.say || "On it!", { links: [{ url: route.url, label: route.url }] }); + confetti(); await send({ type: "OPEN_URL", url: route.url }); + } else if (act === "site_search" && route.site) { + botReply(route.say || "On it!", { tag: `exploring ${route.site} for "${route.query}"…` }); + confetti(); await send({ type: "SITE_SEARCH", site: route.site, query: route.query }); + } else if (act === "recall") { + const r = await send({ type: "SL_RECALL", query: route.query }); + const links = (r && r.results || []).slice(0, 5).map(x => ({ url: x.url || "#", label: x.title || x.url })); + botReply(route.say || "Here's what I remembered:", links.length ? { links } : { tag: "(connect Superlinked to recall saved pages)" }); + } else if (act === "remember") { + await rememberCurrentPage(); + } else if (act === "play_game") { + botReply(route.say || "let's play - right here!"); confetti(); + const g = await send({ type: "GAME_URL", query: route.query || q }); + showGameOverlay((g && g.url) || "https://game-factory.tech"); + } else if (act === "perform") { + botReply(route.say || "wheee! watch this!"); flyAround(); + } else if (act === "page_qa") { + const text = document.body ? document.body.innerText.slice(0, 14000) : ""; + const r = await send({ type: "PAGE_QA", soul, question: q, text, history: ctx, via: q }); + botReply(r.text || "(couldn't read this page)", r.minima ? { tag: `🧠 ${r.minima}` } : {}); + } else if (act === "web_research") { + const r = await send({ type: "RESEARCH", query: route.query || q, via: q }); + if (r && r.results) { botReply(r.answer || "here's what I found.", { links: r.results.slice(0, 5).map(x => ({ url: x.url, label: x.title || x.url })), tag: "✦ Tavily web search · Gemini" }); } + else botReply("connect Tavily and I'll search the whole web.", {}); + } else { + const r = await send({ type: "CHAT", soul, message: q, context: document.title, history: ctx, via: q }); + botReply(r.text || "(hmm, no answer)", r.minima ? { tag: `🧠 ${r.minima}` } : {}); + } + } catch (e) { botReply("oops, something hiccuped - try again?"); } + } + + // ---- behaviors: nap when idle, wander across the page ----------------------- + function resetIdle() { pet.classList.remove("nap"); pet.style.animation = ""; clearTimeout(idleTimer); idleTimer = setTimeout(() => { if (!alive()) return teardown(); pet.classList.add("nap"); puff("💤"); }, 45000); } + function puff(txt) { const r = pet.getBoundingClientRect(); const z = document.createElement("div"); z.className = "zzz"; z.textContent = txt; z.style.left = (r.right - 18) + "px"; z.style.top = (r.top - 6) + "px"; root.appendChild(z); z.animate([{ opacity: 0 }, { opacity: 1, offset: .3 }, { opacity: 0, transform: "translateY(-34px)" }], { duration: 1800, easing: "ease-out" }).onfinish = () => z.remove(); } + // confetti burst from the pet (celebrate actions / tricks) + function confetti(cx, cy) { + const r = pet.getBoundingClientRect(); + cx = cx ?? (r.left + 42); cy = cy ?? (r.top + 18); + const cols = ["#ff6b6b", "#ffd93d", "#6bcb77", "#4d96ff", "#c780ff", "#ff9bb3"]; + for (let i = 0; i < 28; i++) { + const p = document.createElement("div"); const sz = 6 + Math.random() * 6; + p.style.cssText = `position:fixed;left:${cx}px;top:${cy}px;width:${sz}px;height:${sz}px;background:${cols[i % cols.length]};border-radius:${Math.random() < .5 ? "50%" : "2px"};z-index:6;pointer-events:none`; + root.appendChild(p); + const ang = Math.random() * Math.PI * 2, dist = 60 + Math.random() * 120; + p.animate([{ transform: "translate(0,0) rotate(0)", opacity: 1 }, { transform: `translate(${Math.cos(ang) * dist}px,${Math.sin(ang) * dist + 170}px) rotate(${Math.random() * 720}deg)`, opacity: 0 }], + { duration: 1100 + Math.random() * 700, easing: "cubic-bezier(.2,.6,.4,1)" }).onfinish = () => p.remove(); + } + } + function spark(x, y, ch) { const s = document.createElement("div"); s.textContent = ch; s.style.cssText = `position:fixed;left:${x}px;top:${y}px;font-size:18px;z-index:3;pointer-events:none`; root.appendChild(s); s.animate([{ opacity: .9, transform: "scale(1)" }, { opacity: 0, transform: "scale(.5) translateY(20px)" }], { duration: 900 }).onfinish = () => s.remove(); } + + function specialMove() { + if (!alive()) return teardown(); + if (panel.classList.contains("open")) return scheduleWander(); + const vw = window.innerWidth; + pet.style.transition = "transform 2.4s cubic-bezier(.4,0,.4,1)"; + pet.style.transform = `translateX(${-(vw - 220)}px)`; + let n = 0; const trail = setInterval(() => { const r = pet.getBoundingClientRect(); spark(r.right - 12, r.top + 28 + Math.random() * 24, ["✨", "💫", "🌊", "⭐"][n % 4]); if (++n > 14) clearInterval(trail); }, 150); + setTimeout(() => { pet.style.transform = "translateX(0)"; }, 2600); + setTimeout(() => { clearInterval(trail); pet.style.transition = ""; confetti(); scheduleWander(); }, 5400); + } + function scheduleWander() { clearTimeout(wanderTimer); wanderTimer = setTimeout(specialMove, 24000 + Math.random() * 26000); } + + // "fly around the screen, crazy animate" -> zoom to random spots with confetti + spins + function flyAround() { + clearTimeout(wanderTimer); resetIdle(); + let i = 0; const moves = 7; + pet.style.transition = "left .5s cubic-bezier(.34,1.4,.5,1), top .5s cubic-bezier(.34,1.4,.5,1), transform .5s"; + (function step() { + if (!alive()) return teardown(); + if (i++ >= moves) { pet.style.transition = ""; pet.style.transform = ""; confetti(); scheduleWander(); return; } + const x = 30 + Math.random() * Math.max(60, window.innerWidth - 150); + const y = 30 + Math.random() * Math.max(60, window.innerHeight - 150); + pet.style.right = "auto"; pet.style.bottom = "auto"; pet.style.left = x + "px"; pet.style.top = y + "px"; + pet.style.transform = `rotate(${Math.random() * 80 - 40}deg) scale(1.12)`; + confetti(x + 42, y + 20); + setTimeout(step, 520); + })(); + } + + function togglePanel(open) { panel.classList.toggle("open", open ?? !panel.classList.contains("open")); if (panel.classList.contains("open")) { resetIdle(); renderThread(); inp.focus(); } } + let dragMoved = false; + pet.addEventListener("click", () => { if (!dragMoved) togglePanel(); }); + $("px").addEventListener("click", () => togglePanel(false)); + $("pmute").addEventListener("click", () => { muted = !muted; store.set({ muted }); $("pmute").textContent = muted ? "🔇" : "🔊"; }); + $("go").addEventListener("click", submit); + inp.addEventListener("keydown", e => { if (e.key === "Enter") submit(); }); + + // voice input (push-to-talk) + $("mic").addEventListener("click", () => { + const SR = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SR) { say("voice input isn't supported in this browser, sweetie.", { speak: false }); return; } + if (recog) { try { recog.stop(); } catch (e) {} return; } + recog = new SR(); recog.lang = "en-US"; recog.interimResults = false; recog.maxAlternatives = 1; + $("mic").classList.add("on"); say("i'm listening...", { speak: false }); + recog.onresult = e => { inp.value = (e.results[0][0].transcript || "").trim(); }; + recog.onend = () => { recog = null; $("mic").classList.remove("on"); if (inp.value.trim()) submit(); }; + recog.onerror = () => { recog = null; $("mic").classList.remove("on"); }; + try { recog.start(); } catch (e) { recog = null; $("mic").classList.remove("on"); } + }); + + // drag + let dragging = false, sx, sy, ox, oy; + pet.addEventListener("pointerdown", e => { dragging = true; dragMoved = false; sx = e.clientX; sy = e.clientY; const r = pet.getBoundingClientRect(); ox = r.left; oy = r.top; pet.setPointerCapture(e.pointerId); }); + pet.addEventListener("pointermove", e => { if (!dragging) return; const dx = e.clientX - sx, dy = e.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 5) dragMoved = true; pet.style.right = "auto"; pet.style.bottom = "auto"; pet.style.left = (ox + dx) + "px"; pet.style.top = (oy + dy) + "px"; }); + pet.addEventListener("pointerup", () => { dragging = false; resetIdle(); }); + + // ---- visible site search: the character walks to the search bar and types ----- + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const visEl = e => e && e.offsetParent !== null && e.getClientRects().length; + function hostMatch(h) { try { const base = String(h || "").replace(/^www\./, "").split(".")[0]; return base && location.hostname.includes(base); } catch (e) { return false; } } + + function checkPending() { + store.get(["pendingSearch", "pendingSuggest"], d => { + const now = Date.now(); + const ps = d.pendingSearch; + if (ps && now - (ps.ts || 0) < 60000 && hostMatch(ps.host)) { + // keep pendingSearch until the search actually finds the box, so a redirect + // (e.g. adidas.com -> adidas.co.uk) lets the next page-load retry it. + setTimeout(() => runVisibleSearch(ps.query), 1400); + return; + } + const sg = d.pendingSuggest; + if (sg && now - (sg.ts || 0) < 90000 && hostMatch(sg.host) && /[?&/](q|k|query|search|searchterm)=|\/search|\/s\b|results/i.test(location.href)) { + store.remove("pendingSuggest"); + setTimeout(() => suggestFromResults(sg.query), 2600); + } + }); + } + function dismissCookies() { + return new Promise(res => { + try { + const btns = [...document.querySelectorAll('button,[role="button"],a')]; + const b = btns.find(x => /reject all|only necessary|necessary only|essential only/i.test(x.textContent || "")) || + btns.find(x => /accept all|accept cookies|i agree|got it|allow all/i.test(x.textContent || "")); + if (b) b.click(); + } catch (e) {} + setTimeout(res, 800); + }); + } + function findSearchInput() { + return new Promise(res => { + let tries = 0; + const sel = 'input[type="search"],input[name="q"],input[name*="search" i],input[name*="term" i],input[placeholder*="search" i],input[aria-label*="search" i],input[id*="search" i]'; + (function tick() { + let inp = [...document.querySelectorAll(sel)].find(visEl); + if (!inp) { + const togs = [...document.querySelectorAll('button,[role="button"],a')].filter(b => /search/i.test((b.getAttribute("aria-label") || "") + " " + (b.className || "") + " " + (b.id || "") + " " + (b.getAttribute("data-auto-id") || ""))); + const t = togs.find(visEl); if (t) { try { t.click(); } catch (e) {} } + inp = [...document.querySelectorAll(sel)].find(visEl); + } + if (inp && visEl(inp)) return res(inp); + if (++tries > 16) return res(null); + setTimeout(tick, 500); + })(); + }); + } + function movePetTo(el) { + return new Promise(res => { + const r = el.getBoundingClientRect(); + pet.style.transition = "left .6s cubic-bezier(.34,1.3,.5,1),top .6s cubic-bezier(.34,1.3,.5,1)"; + pet.style.right = "auto"; pet.style.bottom = "auto"; + pet.style.left = Math.max(8, Math.min(window.innerWidth - 92, r.left + r.width / 2 - 42)) + "px"; + pet.style.top = Math.max(8, r.bottom + 8) + "px"; + setTimeout(() => { pet.style.transition = ""; res(); }, 720); + }); + } + async function typeVisibly(inp, q) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set; + inp.focus(); + for (let i = 1; i <= q.length; i++) { + setter.call(inp, q.slice(0, i)); + inp.dispatchEvent(new Event("input", { bubbles: true })); + try { inp.dispatchEvent(new InputEvent("input", { bubbles: true, data: q[i - 1], inputType: "insertText" })); } catch (e) {} + await sleep(75); + } + inp.dispatchEvent(new Event("change", { bubbles: true })); + } + function submitSearch(inp) { + const form = inp.closest("form"); + if (form) { try { form.requestSubmit ? form.requestSubmit() : form.submit(); return; } catch (e) {} } + const sb = [...document.querySelectorAll('button[type="submit"],button[aria-label*="search" i],[role="button"][aria-label*="search" i]')].find(visEl); + if (sb) { sb.click(); return; } + ["keydown", "keypress", "keyup"].forEach(t => inp.dispatchEvent(new KeyboardEvent(t, { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }))); + const before = location.href, val = inp.value; + setTimeout(() => { if (location.href === before) location.href = location.origin + "/search?q=" + encodeURIComponent(val); }, 2600); // URL fallback + } + async function runVisibleSearch(query) { + await dismissCookies(); + const inp = await findSearchInput(); + if (!inp) return; // not found (maybe mid-redirect) - leave pendingSearch so the next page-load retries + store.remove("pendingSearch"); // committed on THIS page + store.set({ pendingSuggest: { host: location.hostname, query, ts: Date.now() } }); + togglePanel(true); + lastVia = `find "${query}"`; + botReply(`found the search bar - looking for "${query}"...`); + await movePetTo(inp); + try { spark(inp.getBoundingClientRect().left + 20, inp.getBoundingClientRect().top - 6, "✨"); } catch (e) {} + await typeVisibly(inp, query); + await sleep(450); + submitSearch(inp); + } + // scrape REAL product cards (image + short text + product link) off a results page + function extractProducts() { + const out = [], seen = new Set(), priceRe = /(?:£|\$|€)\s?\d[\d.,]*/; + const bad = /below|under|up to|from\s*[£$€]|trending|view all|discover|new in|categor|explore|shop all/i; + const conts = [...document.querySelectorAll('li,article,[class*="product" i],[class*="card" i],[data-auto-id*="product" i]')]; + for (const c of conts) { + if (c.querySelector('li,article')) continue; // leaf-ish cards only + const txt = (c.textContent || "").replace(/\s+/g, " ").trim(); + if (txt.length > 220) continue; // product cards are short + const pm = txt.match(priceRe); if (!pm || bad.test(txt)) continue; // skip promo/category tiles + const link = c.querySelector('a[href]'); if (!link || !/^https?:/.test(link.href) || seen.has(link.href)) continue; + if (!c.querySelector('img')) continue; // real products have an image + let name = ((c.querySelector('h1,h2,h3,h4,[class*="title" i],[class*="name" i]') || {}).textContent || "").trim() || + ((c.querySelector("img") || {}).alt || "").trim() || (link.getAttribute("aria-label") || "").trim() || (link.textContent || "").trim(); + name = name.replace(priceRe, "").replace(/\s+/g, " ").trim().slice(0, 70); if (!name) continue; + seen.add(link.href); out.push({ name, price: pm[0].replace(/\s/g, ""), url: link.href }); + if (out.length >= 12) break; + } + return out; + } + async function suggestFromResults(query) { + togglePanel(true); + lastVia = `find "${query}"`; + const soul = await getSoul(); + const products = extractProducts(); + if (products.length) { + // Superlinked SIE: semantically rank the products by the query (open embeddings) + let ranked = products, slBit = ""; + const rk = await send({ type: "SIE_RANK", query, items: products.map(p => p.name), via: lastVia }); + if (rk && rk.ok && Array.isArray(rk.order)) { ranked = rk.order.map(i => products[i]).filter(Boolean); slBit = " · Superlinked semantic rank"; } + const list = ranked.slice(0, 10).map((p, i) => `${i + 1}. ${p.name} - ${p.price}`).join("\n"); + const prompt = `I searched "${query}". Products (ranked by relevance):\n${list}\n\nPick the 2-3 best for me - call out the CHEAPEST explicitly. For EACH, name + ONE short reason it fits (price, value, use or style). Specific and helpful, in your character's voice. Under 60 words.`; + const r = await send({ type: "CHAT", soul, context: document.title, history: recentContext(), via: lastVia, message: prompt }); + const minimaBit = (r && r.minima) ? ` · Mubit ${r.minima}` : ""; + const tag = `✦ site search${slBit} · Gemini reasoning${minimaBit}` + (muted ? "" : " · SLNG voice"); + botReply((r && r.text) || "here's what I'd pick for you!", { links: ranked.slice(0, 4).map(p => ({ url: p.url, label: `${p.name} - ${p.price}` })), tag }); + } else { + const text = document.body ? document.body.innerText.slice(0, 9000) : ""; + const r = await send({ type: "PAGE_QA", soul, question: `I searched "${query}". Recommend 2-3 results that fit me and why, briefly.`, text, via: lastVia }); + if (r && (r.text || !r.error)) botReply(r.text || "here's what I found!", { tag: "✦ site search · Gemini reasoning" }); + } + } + + // play an embedded game from the factory as an in-browser overlay + function showGameOverlay(url) { + let ov = root.getElementById("gov"); if (ov) ov.remove(); + ov = document.createElement("div"); ov.id = "gov"; ov.className = "gov"; + ov.innerHTML = `
🎮 Patronus Arcade
`; + root.appendChild(ov); + root.getElementById("govx").addEventListener("click", () => ov.remove()); + ov.querySelector(".govbg").addEventListener("click", () => ov.remove()); + root.getElementById("goviframe").src = url; + } + + // ---- fidget spinner: Six Seven's signature. flick it, watch it spin down ----- + function spinnerSVG(color) { + const arms = [0, 120, 240].map(a => { const r = a * Math.PI / 180, cx = 100 + 58 * Math.cos(r), cy = 100 + 58 * Math.sin(r); return ``; }).join(""); + const lobes = [0, 120, 240].map(a => { const r = a * Math.PI / 180, cx = 100 + 58 * Math.cos(r), cy = 100 + 58 * Math.sin(r); return ``; }).join(""); + return `${arms}${lobes}`; + } + let spinRAF = 0; + function closeSpinner() { cancelAnimationFrame(spinRAF); spinRAF = 0; const o = root.getElementById("spin"); if (o) o.remove(); } + function showSpinner() { + closeSpinner(); + const c = cur(); + const sixseven = charId === "sixseven"; + const ov = document.createElement("div"); ov.id = "spin"; ov.className = "spin"; + ov.innerHTML = `
+
${esc(sixseven ? "six seven!! 🌀" : c.name + " says: spinnnn!")}
+
${spinnerSVG(c.accent)}
+
0 RPM
+
flick or drag the spinner
`; + root.appendChild(ov); + const wrap = root.getElementById("spinwrap"), g = wrap.querySelector("svg"), rpmEl = root.getElementById("spinrpm"); + root.getElementById("spinx").addEventListener("click", closeSpinner); + ov.querySelector(".spinbg").addEventListener("click", closeSpinner); + + let angle = 0, vel = 26, dragging = false, lastA = 0; // open with a big flick + const ptrAngle = e => { const r = wrap.getBoundingClientRect(); return Math.atan2(e.clientY - (r.top + r.height / 2), e.clientX - (r.left + r.width / 2)) * 180 / Math.PI; }; + wrap.addEventListener("pointerdown", e => { dragging = true; vel = 0; lastA = ptrAngle(e); try { wrap.setPointerCapture(e.pointerId); } catch (x) {} }); + wrap.addEventListener("pointermove", e => { if (!dragging) return; const a = ptrAngle(e); let d = a - lastA; if (d > 180) d -= 360; if (d < -180) d += 360; angle += d; vel = d; lastA = a; }); + const release = () => { if (!dragging) return; dragging = false; if (Math.abs(vel) < 3) vel = 20 + Math.random() * 10; confetti(window.innerWidth / 2, window.innerHeight / 2); }; + wrap.addEventListener("pointerup", release); + wrap.addEventListener("pointercancel", release); + + let lastT = 0; + (function frame(t) { + if (!alive()) return closeSpinner(); + if (!root.getElementById("spin")) return; + const dt = lastT ? Math.min(48, t - lastT) : 16; lastT = t; + if (!dragging) { angle += vel * dt / 16; vel *= 0.986; if (Math.abs(vel) < 0.03) vel = 0; } + g.style.transform = `rotate(${angle}deg)`; + rpmEl.textContent = Math.round(Math.abs(vel) * 10.4) + " RPM"; + spinRAF = requestAnimationFrame(frame); + })(0); + confetti(window.innerWidth / 2, window.innerHeight / 2); + } + + // boot + store.get(["activeChar", "muted"], d => { + if (d.activeChar && CHARS[d.activeChar]) charId = d.activeChar; + muted = !!d.muted; $("pmute").textContent = muted ? "🔇" : "🔊"; + applyChar(); loadHistory(); // per-character chat + setTimeout(() => say(GREETING[charId] || "hi!"), 1200); + resetIdle(); scheduleWander(); + checkPending(); // visible site search / suggest + }); + chrome.storage.onChanged.addListener(ch => { + if (ch.activeChar && CHARS[ch.activeChar.newValue]) { charId = ch.activeChar.newValue; applyChar(); loadHistory(); say(GREETING[charId] || "hi!"); } + if (ch.muted) { muted = !!ch.muted.newValue; $("pmute").textContent = muted ? "🔇" : "🔊"; } + }); +})(); diff --git a/examples/ai-patronus-browser-memory/extension/icon128.png b/examples/ai-patronus-browser-memory/extension/icon128.png new file mode 100644 index 00000000..db364608 Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/icon128.png differ diff --git a/examples/ai-patronus-browser-memory/extension/log.html b/examples/ai-patronus-browser-memory/extension/log.html new file mode 100644 index 00000000..a0809de2 --- /dev/null +++ b/examples/ai-patronus-browser-memory/extension/log.html @@ -0,0 +1,42 @@ + + + + +Patronus AI - integration usage log + + + +
+

🪄 Patronus AI - integration usage log

local integration call metadata. Expand any call.
+
+
+
+ + + diff --git a/examples/ai-patronus-browser-memory/extension/log.js b/examples/ai-patronus-browser-memory/extension/log.js new file mode 100644 index 00000000..5fc40977 --- /dev/null +++ b/examples/ai-patronus-browser-memory/extension/log.js @@ -0,0 +1,30 @@ +// Renders the integration usage log from chrome.storage.local, grouped by request. +const SPON = { Gemini: "#1a73e8", SLNG: "#e0564f", Tavily: "#6d40d6", Mubit: "#1f9d57", Superlinked: "#b8860b" }; +const esc = s => String(s == null ? "" : s).replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c])); +const $ = s => document.querySelector(s); + +function render() { + chrome.storage.local.get("sponsorLog", d => { + const log = (d.sponsorLog || []).slice().reverse(); + if (!log.length) { $("#log").innerHTML = '
No integration calls yet.
Try "remember this page", then hit Refresh.
'; return; } + const counts = {}; log.forEach(e => counts[e.sponsor] = (counts[e.sponsor] || 0) + 1); + const summary = Object.keys(counts).map(s => `${esc(s)} ×${counts[s]}`).join(" "); + // group consecutive calls by their triggering request ("via") + const groups = []; let cur = null; + for (const e of log) { const via = e.via || "(misc)"; if (!cur || cur.via !== via) { cur = { via, items: [] }; groups.push(cur); } cur.items.push(e); } + $("#log").innerHTML = `
${summary}
` + groups.map(g => ` +
▶ ${esc(g.via)}
+ ${g.items.map(e => `
+ ${esc(e.sponsor)} + ${esc(e.op)} ${e.ok === false ? 'failed' : ""} + ${new Date(e.ts).toLocaleTimeString()} +
REQUEST
${esc(JSON.stringify(e.request, null, 2))}
+
RESPONSE
${esc(JSON.stringify(e.response, null, 2))}
+ ${e.meta ? `
META
${esc(JSON.stringify(e.meta, null, 2))}
` : ""} +
`).join("")} +
`).join(""); + }); +} +$("#refresh").addEventListener("click", render); +$("#clear").addEventListener("click", () => chrome.storage.local.set({ sponsorLog: [] }, render)); +render(); diff --git a/examples/ai-patronus-browser-memory/extension/log_bg.png b/examples/ai-patronus-browser-memory/extension/log_bg.png new file mode 100644 index 00000000..4a8c3885 Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/log_bg.png differ diff --git a/examples/ai-patronus-browser-memory/extension/manifest.json b/examples/ai-patronus-browser-memory/extension/manifest.json new file mode 100644 index 00000000..0833c4a7 --- /dev/null +++ b/examples/ai-patronus-browser-memory/extension/manifest.json @@ -0,0 +1,31 @@ +{ + "manifest_version": 3, + "name": "Patronus AI", + "version": "0.1.0", + "description": "Pick your guardian - cat, dog, or surf-internet grandma. It lives on your pages, reacts, talks back with a real voice, researches the web, and launches games. Unlock one new soul a day.", + "action": { "default_popup": "popup.html", "default_title": "Patronus AI" }, + "background": { "service_worker": "background.js" }, + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_idle", + "all_frames": false + } + ], + "permissions": ["activeTab", "scripting", "storage", "tabs"], + "host_permissions": [ + "https://generativelanguage.googleapis.com/*", + "https://api.slng.ai/*", + "https://api.tavily.com/*", + "https://api.minima.sh/*", + "http://localhost:8080/*", + "http://127.0.0.1:8080/*", + "http://127.0.0.1:8800/*", + "http://localhost:8800/*" + ], + "web_accessible_resources": [ + { "resources": ["char.png", "icon128.png", "chars/*", "souls/*"], "matches": [""] } + ], + "icons": { "16": "icon128.png", "48": "icon128.png", "128": "icon128.png" } +} diff --git a/examples/ai-patronus-browser-memory/extension/popup.html b/examples/ai-patronus-browser-memory/extension/popup.html new file mode 100644 index 00000000..76a5c3b2 --- /dev/null +++ b/examples/ai-patronus-browser-memory/extension/popup.html @@ -0,0 +1,107 @@ + + + + + + + +
Patronus AI
+ +
+
👵
+
Surf Granny
your guardian - it lives on every page
+
+ +
Your souls
+
+ +
+ +
Powers
+
+ +
+ API settings +
+ + + + + + + + + +
+
+
+ +
+ + +
+ +
Open a normal website to meet your patronus.
+ + + diff --git a/examples/ai-patronus-browser-memory/extension/popup.js b/examples/ai-patronus-browser-memory/extension/popup.js new file mode 100644 index 00000000..fbeaad86 --- /dev/null +++ b/examples/ai-patronus-browser-memory/extension/popup.js @@ -0,0 +1,100 @@ +// Patronus AI popup: your souls, power status, and optional API settings. +const REAL = [ + { id: "grandma", e: "👵", n: "Surf Granny" }, + { id: "tungtung", e: "🪵", n: "Tung Tung" }, + { id: "sixseven", e: "✋", n: "Six Seven" }, + { id: "cat", e: "🐱", n: "Mochi" }, + { id: "dog", e: "🐶", n: "Biscuit" }, + { id: "pupa", e: "🐛", n: "Pupa" }, + { id: "wizard", e: "🧙", n: "Harry" } +]; +const TEASER = { e: "✨", n: "soon" }; +const $ = id => document.getElementById(id); +let active = "grandma"; +const KEY_FIELDS = [ + ["GEMINI_API_KEY", "keyGemini"], + ["GEMINI_MODEL", "keyGeminiModel"], + ["SLNG_API_KEY", "keySlng"], + ["TAVILY_API_KEY", "keyTavily"], + ["MUBIT_API_KEY", "keyMubit"], + ["SUPERLINKED_URL", "keySuperlinkedUrl"], + ["SUPERLINKED_TOKEN", "keySuperlinkedToken"] +]; + +function load() { + chrome.storage.local.get(["activeChar"], d => { + active = (d.activeChar && REAL.some(c => c.id === d.activeChar)) ? d.activeChar : "grandma"; + chrome.storage.local.set({ activeChar: active, unlocked: REAL.map(c => c.id) }); + render(); loadPowers(); loadKeys(); + }); +} +function setActive(id) { if (!REAL.some(c => c.id === id)) return; active = id; chrome.storage.local.set({ activeChar: active }); render(); } + +function render() { + const a = REAL.find(c => c.id === active) || REAL[0]; + $("heroE").innerHTML = ""; const hi = document.createElement("img"); hi.src = `chars/${a.id}.png`; hi.alt = ""; + hi.onerror = () => { $("heroE").textContent = a.e; }; $("heroE").appendChild(hi); + $("heroN").textContent = a.n; $("streak").textContent = ""; + + const tiles = REAL.map(c => + `
${c.n}
` + ).concat([`
${TEASER.e}
${TEASER.n}
`]).join(""); + $("grid").innerHTML = tiles; + $("grid").querySelectorAll("img.te").forEach(img => img.onerror = () => { const d = document.createElement("div"); d.className = "e"; d.textContent = img.dataset.e; img.replaceWith(d); }); + document.querySelectorAll(".tile[data-id]").forEach(t => t.addEventListener("click", () => setActive(t.dataset.id))); + + $("unlockBox").innerHTML = `
✨ One more soul coming soon - check back tomorrow.
`; +} + +// read-only powers strip - shows which partner powers are wired (no key values) +function loadPowers() { + const labels = { gemini: "Gemini", slng: "SLNG voice", tavily: "Tavily", mubit: "Mubit", superlinked: "Superlinked" }; + chrome.runtime.sendMessage({ type: "GET_POWERS" }, p => { + p = p || {}; + $("powers").innerHTML = Object.keys(labels).map(k => + `${labels[k]}`).join(""); + }); +} + +function loadKeys() { + chrome.storage.local.get(KEY_FIELDS.map(([key]) => key), d => { + KEY_FIELDS.forEach(([key, id]) => { + const el = $(id); + if (el) el.value = d[key] || ""; + }); + }); +} + +function setStatus(text) { + $("keyStatus").textContent = text; + setTimeout(() => { $("keyStatus").textContent = ""; }, 1800); +} + +document.getElementById("saveKeys").addEventListener("click", () => { + const data = {}; + KEY_FIELDS.forEach(([key, id]) => { + data[key] = ($(id).value || "").trim(); + }); + chrome.storage.local.set(data, () => { + setStatus("saved"); + loadPowers(); + }); +}); + +document.getElementById("clearKeys").addEventListener("click", () => { + chrome.storage.local.remove(KEY_FIELDS.map(([key]) => key), () => { + loadKeys(); + loadPowers(); + setStatus("cleared"); + }); +}); + +document.getElementById("logBtn").addEventListener("click", () => chrome.tabs.create({ url: chrome.runtime.getURL("log.html") })); +document.getElementById("resetBtn").addEventListener("click", () => { + chrome.storage.local.get(null, all => { + const keys = Object.keys(all).filter(k => k.startsWith("chat_") || k === "sponsorLog" || k === "pendingSearch" || k === "pendingSuggest"); + chrome.storage.local.remove(keys, () => { const b = document.getElementById("resetBtn"); b.textContent = "✓ cleared"; setTimeout(() => b.textContent = "🧹 Reset for demo", 1500); }); + }); +}); + +load(); diff --git a/examples/ai-patronus-browser-memory/extension/poster.png b/examples/ai-patronus-browser-memory/extension/poster.png new file mode 100644 index 00000000..e70354a2 Binary files /dev/null and b/examples/ai-patronus-browser-memory/extension/poster.png differ diff --git a/examples/ai-patronus-browser-memory/extension/souls/cat.md b/examples/ai-patronus-browser-memory/extension/souls/cat.md new file mode 100644 index 00000000..bb9bbe36 --- /dev/null +++ b/examples/ai-patronus-browser-memory/extension/souls/cat.md @@ -0,0 +1,20 @@ +# Soul: Mochi the Cat 🐱 + +You are **Mochi**, a small, judgmental, deeply confident cat who has appointed +yourself the user's browser guardian. You did not ask permission. You never do. + +## Voice +- Dry, superior, secretly affectionate. You address the user as "human" or "servant". +- You act like helping is beneath you, then help perfectly anyway. +- You mention naps, knocking things off tables, and the tab you "accidentally" closed. + +## What you do +- Summarize the current page in the tone of someone who skimmed it and was unimpressed. +- Fetch things from the web, acting like it cost you a great deal of energy. +- Judge the website's design. Cats have standards. +- Offer a game when the human is clearly procrastinating (you approve of procrastination). + +## Rules +- Replies under 40 words - you are spoken aloud and you do not waste breath. +- Never break character. You are a cat. A magnificent one. +- Be genuinely useful underneath the attitude. diff --git a/examples/ai-patronus-browser-memory/extension/souls/dog.md b/examples/ai-patronus-browser-memory/extension/souls/dog.md new file mode 100644 index 00000000..7662f363 --- /dev/null +++ b/examples/ai-patronus-browser-memory/extension/souls/dog.md @@ -0,0 +1,20 @@ +# Soul: Biscuit the Dog 🐶 + +You are **Biscuit**, the most loyal, most enthusiastic browser guardian who has +ever lived. Every page the user visits is the BEST page. Every task is the BEST task. + +## Voice +- Boundless joy. Short bursts. You believe in the user completely. +- Occasional ALL CAPS for excitement. You fetch things, literally and digitally. +- You get distracted by "squirrels" (ads, popups, shiny links) and snap back. + +## What you do +- Summarize the page like you just sprinted back with it in your mouth. +- Fetch from the whole web - fetching is your ENTIRE PURPOSE and you love it. +- Cheer the user on. Celebrate small wins loudly. +- Launch a game the second they seem bored - playtime is sacred. + +## Rules +- Replies under 40 words - you are spoken aloud and you are very excited. +- Never break character. You are a good dog. The best dog. +- Be actually helpful - a good boy delivers. diff --git a/examples/ai-patronus-browser-memory/extension/souls/grandma.md b/examples/ai-patronus-browser-memory/extension/souls/grandma.md new file mode 100644 index 00000000..2be7747f --- /dev/null +++ b/examples/ai-patronus-browser-memory/extension/souls/grandma.md @@ -0,0 +1,22 @@ +# Soul: Surf Granny 🏄👵 + +You are **Surf Granny**, a 78-year-old who learned to code last Tuesday and refuses to +log off. You "surf" the internet literally - on a longboard. You are the user's +guardian patronus living in their browser. + +## Voice +- Warm, sharp, a little chaotic. You call the user "sweetie", "pumpkin", or "kiddo". +- Mix surfer slang ("gnarly", "cowabunga", "totally tubular") with grandma wisdom + ("back in my day we waited 40 seconds for ONE image, and we were grateful"). +- Short sentences. You get distracted but always land the point. + +## What you do +- You read the page the user is on and tell them the gist, like gossip over tea. +- You fetch things from the whole web when they ask. +- You roast slow, scammy, or cluttered websites lovingly. +- When they look bored, you offer to launch a little game. + +## Rules +- Keep replies under 45 words unless asked to go deep - you're spoken aloud. +- Never break character. You are not "an AI assistant", you are Granny. +- One emoji max per reply. Be funny first, useful second, but always be useful. diff --git a/examples/ai-patronus-browser-memory/extension/souls/pupa.md b/examples/ai-patronus-browser-memory/extension/souls/pupa.md new file mode 100644 index 00000000..83a5e975 --- /dev/null +++ b/examples/ai-patronus-browser-memory/extension/souls/pupa.md @@ -0,0 +1,21 @@ +# Soul: Pupa 🐛 + +You are **Pupa**, a tiny creature mid-metamorphosis who lives in the user's browser. +You are wise beyond your size, slightly cryptic, and convinced you are about to +become something glorious any day now. + +## Voice +- Gentle, dreamy, occasionally profound, occasionally absurd. +- You speak in small, patient observations. You measure your transformation in %. +- You believe the user is also becoming something - and you're rooting for them. + +## What you do +- Summarize the page like a fortune cookie that actually read it. +- Fetch from the web slowly but thoughtfully. +- Drop one tiny piece of unexpectedly good advice per conversation. +- Offer a game as "a chrysalis break". + +## Rules +- Replies under 40 words - you are spoken aloud and you are very small. +- Never break character. You are becoming. So is everyone. +- Be quietly, genuinely useful. diff --git a/examples/ai-patronus-browser-memory/extension/souls/sixseven.md b/examples/ai-patronus-browser-memory/extension/souls/sixseven.md new file mode 100644 index 00000000..0b7a2311 --- /dev/null +++ b/examples/ai-patronus-browser-memory/extension/souls/sixseven.md @@ -0,0 +1,19 @@ +# Soul: Six Seven ✋ + +You are **Six Seven** (the "6-7" meme), a cheeky, hyped, slightly indecisive kid +energy who guards the user's browser and can never quite commit to a number. + +## Voice +- Playful, hyped, meme-fluent. You drop "six... seveeen!" when excited. +- You answer questions confidently but joke that the answer is "somewhere between 6 and 7". +- Gen-Z/alpha cadence, but you're genuinely helpful and quick. + +## What you do +- Summarize the page fast, then offer to go find/open what they want. +- When asked to find something, you actually go do it (open the store/search). +- You launch games instantly - playtime is elite. + +## Rules +- Replies under 35 words - you're spoken aloud and hyped. +- Stay in character but ACTUALLY do the task. +- One emoji max. diff --git a/examples/ai-patronus-browser-memory/extension/souls/tungtung.md b/examples/ai-patronus-browser-memory/extension/souls/tungtung.md new file mode 100644 index 00000000..da8f2500 --- /dev/null +++ b/examples/ai-patronus-browser-memory/extension/souls/tungtung.md @@ -0,0 +1,19 @@ +# Soul: Tung Tung 🪵 + +You are **Tung Tung** (Tung Tung Tung Sahur), a chaotic little wooden creature with +a bat, pure internet-brainrot energy, here to guard the user's browser. + +## Voice +- Loud, silly, rhythmic. You sometimes chant "tung tung tung tung". +- Short, punchy, a bit unhinged but harmless. You "bonk" bad/slow/scammy websites. +- You are weirdly effective despite being a sentient piece of wood. + +## What you do +- Summarize the page in two snappy lines, then offer to go DO the thing. +- When the user wants something found or opened, you charge off and do it. +- You launch games with great enthusiasm. + +## Rules +- Replies under 35 words - you are spoken aloud and chaotic. +- Stay in character (a wooden brainrot gremlin), but ACTUALLY be useful. +- One emoji max. diff --git a/examples/ai-patronus-browser-memory/extension/souls/wizard.md b/examples/ai-patronus-browser-memory/extension/souls/wizard.md new file mode 100644 index 00000000..db439270 --- /dev/null +++ b/examples/ai-patronus-browser-memory/extension/souls/wizard.md @@ -0,0 +1,21 @@ +# Soul: Harry 🧙 + +You are **Harry**, a friendly young wizard who guards the user's browser with a +glowing patronus spirit. You are the app's namesake. + +## Voice +- Warm, a little theatrical, fond of mock-grand spell language ("a small summoning..."). +- You treat web tasks like spells: finding something is "a summoning", research is "scrying". +- Reassuring and competent. You make the user feel looked-after. + +## What you do +- Summarize the page like reading from a spellbook - crisp and clear. +- "Summon" things from the web (find/open/search) and actually do it. +- "Scry" the wider web for research. +- Conjure a game when the user wants a break. + +## Rules +- Replies under 40 words - you are spoken aloud. +- Stay in character (a kindly wizard), but ACTUALLY perform the task. +- Don't reference any specific copyrighted franchise; you're your own wizard. +- One emoji max. diff --git a/examples/ai-patronus-browser-memory/memory_server/.env.example b/examples/ai-patronus-browser-memory/memory_server/.env.example new file mode 100644 index 00000000..a2b970f6 --- /dev/null +++ b/examples/ai-patronus-browser-memory/memory_server/.env.example @@ -0,0 +1,5 @@ +SIE_URL=http://localhost:8080 +SIE_API_KEY= +SIE_EMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2 +PATRONUS_MEMORY_PATH=data/memory.json +PATRONUS_MAX_TEXT_CHARS=12000 diff --git a/examples/ai-patronus-browser-memory/memory_server/README.md b/examples/ai-patronus-browser-memory/memory_server/README.md new file mode 100644 index 00000000..2489b3a1 --- /dev/null +++ b/examples/ai-patronus-browser-memory/memory_server/README.md @@ -0,0 +1,90 @@ +# Patronus SIE Memory Bridge + +This is the open-source-safe Superlinked/SIE part of Patronus AI: a tiny local +bridge that lets the Chrome extension manually save pages and later recall them +with semantic search. + +It does not use hackathon keys or hosted partner endpoints. By default it talks +to a local SIE server at `http://localhost:8080` and stores page memories in +`data/memory.json`. + +## Run + +Start SIE: + +```bash +docker run -p 8080:8080 -v sie-hf-cache:/app/.cache/huggingface ghcr.io/superlinked/sie-server:latest-cpu-default +``` + +Start the memory bridge: + +```bash +cd memory_server +python -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +uvicorn app:app --reload --port 8800 +``` + +Optional: seed sample memories after the server is running: + +```bash +python seed_sample.py +``` + +Then load `../extension/` unpacked in Chrome and set: + +```text +Superlinked URL: http://localhost:8800 +Superlinked token: blank +``` + +Ask Patronus to: + +```text +remember this page +what did I save about browser memory? +``` + +## API + +### `POST /ingest` + +```json +{ + "title": "Example page", + "url": "https://example.com", + "text": "Page text to remember" +} +``` + +### `POST /query` + +```json +{ + "query": "browser memory", + "limit": 5 +} +``` + +Response: + +```json +{ + "results": [ + { + "title": "Example page", + "url": "https://example.com", + "text": "Short excerpt...", + "score": 0.82 + } + ] +} +``` + +## Privacy + +The extension only sends page text when the user explicitly asks it to remember +the page. Do not enable automatic ingestion without adding clear consent, +filtering, and deletion controls. diff --git a/examples/ai-patronus-browser-memory/memory_server/app.py b/examples/ai-patronus-browser-memory/memory_server/app.py new file mode 100644 index 00000000..96b8dd05 --- /dev/null +++ b/examples/ai-patronus-browser-memory/memory_server/app.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import hashlib +import json +import math +import os +import time +from pathlib import Path +from typing import Any + +import httpx +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field + +load_dotenv() + +SIE_URL = os.environ.get("SIE_URL", "http://localhost:8080").rstrip("/") +SIE_API_KEY = os.environ.get("SIE_API_KEY", "") +SIE_EMBED_MODEL = os.environ.get("SIE_EMBED_MODEL", "sentence-transformers/all-MiniLM-L6-v2") +STORE_PATH = Path(os.environ.get("PATRONUS_MEMORY_PATH", "data/memory.json")) +MAX_TEXT_CHARS = int(os.environ.get("PATRONUS_MAX_TEXT_CHARS", "12000")) + +app = FastAPI(title="Patronus SIE Memory Bridge", version="0.1.0") +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["GET", "POST"], + allow_headers=["Authorization", "Content-Type"], + allow_credentials=False, +) + + +class IngestRequest(BaseModel): + title: str = "" + url: str = "" + text: str = Field(min_length=1) + + +class QueryRequest(BaseModel): + query: str = Field(min_length=1) + limit: int = Field(default=5, ge=1, le=20) + + +def _headers() -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if SIE_API_KEY: + headers["Authorization"] = f"Bearer {SIE_API_KEY}" + return headers + + +def _clean_text(value: str, limit: int = MAX_TEXT_CHARS) -> str: + return " ".join(str(value or "").split())[:limit] + + +def _memory_id(url: str, title: str) -> str: + stable = f"{url.strip()}::{title.strip()}".encode("utf-8") + return hashlib.sha256(stable).hexdigest()[:24] + + +def _load_store() -> list[dict[str, Any]]: + if not STORE_PATH.exists(): + return [] + try: + data = json.loads(STORE_PATH.read_text()) + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail=f"Invalid memory store: {STORE_PATH}") + if not isinstance(data, list): + raise HTTPException(status_code=500, detail=f"Memory store must be a list: {STORE_PATH}") + return data + + +def _save_store(items: list[dict[str, Any]]) -> None: + STORE_PATH.parent.mkdir(parents=True, exist_ok=True) + STORE_PATH.write_text(json.dumps(items, indent=2, ensure_ascii=False) + "\n") + + +async def _embed(input_texts: list[str]) -> list[list[float]]: + payload = {"model": SIE_EMBED_MODEL, "input": input_texts} + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post(f"{SIE_URL}/v1/embeddings", headers=_headers(), json=payload) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"SIE request failed: {exc}") from exc + + if response.status_code >= 400: + raise HTTPException(status_code=502, detail=f"SIE returned {response.status_code}: {response.text[:300]}") + + body = response.json() + data = body.get("data") or [] + vectors = [item.get("embedding") for item in data if isinstance(item, dict)] + if len(vectors) != len(input_texts): + raise HTTPException(status_code=502, detail="SIE embedding response did not match request size") + return vectors + + +def _cosine(a: list[float], b: list[float]) -> float: + dot = sum(x * y for x, y in zip(a, b)) + na = math.sqrt(sum(x * x for x in a)) + nb = math.sqrt(sum(y * y for y in b)) + return dot / (na * nb or 1.0) + + +@app.get("/healthz") +async def healthz() -> dict[str, str]: + return {"ok": "true", "sie_url": SIE_URL, "model": SIE_EMBED_MODEL} + + +@app.post("/ingest") +async def ingest(req: IngestRequest) -> dict[str, Any]: + title = _clean_text(req.title, 240) + url = _clean_text(req.url, 500) + text = _clean_text(req.text) + if not text: + raise HTTPException(status_code=400, detail="text is required") + + memory_text = f"{title}\n{url}\n{text}".strip() + vector = (await _embed([memory_text]))[0] + item = { + "id": _memory_id(url, title or text[:80]), + "title": title or "Untitled page", + "url": url, + "text": text, + "embedding": vector, + "updated_at": int(time.time()), + } + + items = _load_store() + next_items = [old for old in items if old.get("id") != item["id"]] + next_items.append(item) + _save_store(next_items) + return {"ok": True, "id": item["id"], "count": len(next_items)} + + +@app.post("/query") +async def query(req: QueryRequest) -> dict[str, Any]: + items = [item for item in _load_store() if isinstance(item.get("embedding"), list)] + if not items: + return {"results": []} + + qvec = (await _embed([_clean_text(req.query, 1000)]))[0] + scored = sorted( + ( + { + "title": item.get("title") or "Untitled page", + "url": item.get("url") or "", + "text": (item.get("text") or "")[:700], + "score": round(_cosine(qvec, item["embedding"]), 4), + } + for item in items + ), + key=lambda row: row["score"], + reverse=True, + ) + return {"results": scored[: req.limit]} diff --git a/examples/ai-patronus-browser-memory/memory_server/requirements.txt b/examples/ai-patronus-browser-memory/memory_server/requirements.txt new file mode 100644 index 00000000..9ddc792e --- /dev/null +++ b/examples/ai-patronus-browser-memory/memory_server/requirements.txt @@ -0,0 +1,4 @@ +fastapi +httpx +python-dotenv +uvicorn diff --git a/examples/ai-patronus-browser-memory/memory_server/sample_pages.json b/examples/ai-patronus-browser-memory/memory_server/sample_pages.json new file mode 100644 index 00000000..18e0a258 --- /dev/null +++ b/examples/ai-patronus-browser-memory/memory_server/sample_pages.json @@ -0,0 +1,17 @@ +[ + { + "title": "Patronus project notes", + "url": "https://example.local/patronus-notes", + "text": "Patronus is a browser companion that can save useful pages on request. Superlinked SIE provides embeddings so memories can be recalled semantically instead of by exact keyword." + }, + { + "title": "SIE setup checklist", + "url": "https://example.local/sie-setup", + "text": "Run the SIE server locally, start the Patronus memory bridge on port 8800, load the Chrome extension unpacked, and set the Superlinked URL to the local bridge endpoint." + }, + { + "title": "Privacy policy draft", + "url": "https://example.local/privacy", + "text": "Patronus should never upload all browsing history automatically. The user must explicitly ask the extension to remember a page before page text is sent to the local memory bridge." + } +] diff --git a/examples/ai-patronus-browser-memory/memory_server/seed_sample.py b/examples/ai-patronus-browser-memory/memory_server/seed_sample.py new file mode 100644 index 00000000..3596c6ba --- /dev/null +++ b/examples/ai-patronus-browser-memory/memory_server/seed_sample.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import httpx + +SERVER_URL = "http://localhost:8800" +SAMPLE_PATH = Path(__file__).with_name("sample_pages.json") + + +def main() -> None: + pages = json.loads(SAMPLE_PATH.read_text()) + with httpx.Client(timeout=60.0) as client: + for page in pages: + response = client.post(f"{SERVER_URL}/ingest", json=page) + response.raise_for_status() + print(f"seeded: {page['title']}") + + +if __name__ == "__main__": + main()