From 4c130784705d53ce196a003125abbc4cb461a51d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:24:21 +0530 Subject: [PATCH] chore: sync uncommitted local work (2026-10-11) --- docs/IntentLock-task-list-plan.md | 525 +++++++ legacy-extension-source/background.js | 749 +++++++++ legacy-extension-source/content.js | 108 ++ legacy-extension-source/diagnostics.html | 42 + legacy-extension-source/diagnostics.js | 105 ++ legacy-extension-source/distraction-sites.js | 13 + legacy-extension-source/drift-cache.js | 61 + legacy-extension-source/drift.js | 108 ++ legacy-extension-source/error-log.js | 145 ++ legacy-extension-source/history.html | 37 + legacy-extension-source/history.js | 135 ++ legacy-extension-source/icon.svg | 5 + legacy-extension-source/icon128.png | Bin 0 -> 791 bytes legacy-extension-source/icon16.png | Bin 0 -> 123 bytes legacy-extension-source/icon48.png | Bin 0 -> 274 bytes .../intervention-overlay.js | 198 +++ legacy-extension-source/intervention.html | 33 + legacy-extension-source/intervention.js | 77 + legacy-extension-source/llm-backoff.js | 39 + legacy-extension-source/llm.js | 149 ++ legacy-extension-source/manifest.json | 56 + legacy-extension-source/newtab.css | 1396 +++++++++++++++++ legacy-extension-source/newtab.html | 35 + legacy-extension-source/newtab.js | 1043 ++++++++++++ legacy-extension-source/options.html | 140 ++ legacy-extension-source/options.js | 493 ++++++ legacy-extension-source/page-tracker.js | 161 ++ legacy-extension-source/popup.html | 19 + legacy-extension-source/popup.js | 127 ++ legacy-extension-source/providers.js | 436 +++++ 30 files changed, 6435 insertions(+) create mode 100644 docs/IntentLock-task-list-plan.md create mode 100644 legacy-extension-source/background.js create mode 100644 legacy-extension-source/content.js create mode 100644 legacy-extension-source/diagnostics.html create mode 100644 legacy-extension-source/diagnostics.js create mode 100644 legacy-extension-source/distraction-sites.js create mode 100644 legacy-extension-source/drift-cache.js create mode 100644 legacy-extension-source/drift.js create mode 100644 legacy-extension-source/error-log.js create mode 100644 legacy-extension-source/history.html create mode 100644 legacy-extension-source/history.js create mode 100644 legacy-extension-source/icon.svg create mode 100644 legacy-extension-source/icon128.png create mode 100644 legacy-extension-source/icon16.png create mode 100644 legacy-extension-source/icon48.png create mode 100644 legacy-extension-source/intervention-overlay.js create mode 100644 legacy-extension-source/intervention.html create mode 100644 legacy-extension-source/intervention.js create mode 100644 legacy-extension-source/llm-backoff.js create mode 100644 legacy-extension-source/llm.js create mode 100644 legacy-extension-source/manifest.json create mode 100644 legacy-extension-source/newtab.css create mode 100644 legacy-extension-source/newtab.html create mode 100644 legacy-extension-source/newtab.js create mode 100644 legacy-extension-source/options.html create mode 100644 legacy-extension-source/options.js create mode 100644 legacy-extension-source/page-tracker.js create mode 100644 legacy-extension-source/popup.html create mode 100644 legacy-extension-source/popup.js create mode 100644 legacy-extension-source/providers.js diff --git a/docs/IntentLock-task-list-plan.md b/docs/IntentLock-task-list-plan.md new file mode 100644 index 0000000..a4b89e6 --- /dev/null +++ b/docs/IntentLock-task-list-plan.md @@ -0,0 +1,525 @@ +# IntentLock — Agent task list & implementation plan + +> **Give this entire file to one agent (or one session). Work only on this product until ship criteria are met.** + +| Field | Value | +|-------|--------| +| **Product** | IntentLock | +| **Priority** | P0 — primary showcase | +| **GitHub** | https://github.com/harshabala/IntentLock | +| **Local clone (preferred)** | `~/Documents/Projects/IntentLock` | +| **If missing, clone to** | `~/Documents/Projects/IntentLock` via `gh repo clone harshabala/IntentLock` | +| **Default branch** | `main` | +| **Work branch** | `feat/flow-metrics-2026-07` (create from latest default if needed) | +| **Stack** | Chrome MV3 extension, vanilla JS ES modules, node:test | +| **Task IDs** | IL-1, IL-2, IL-3, IL-4, IL-5 | +| **Fable plan status** | Complete Fable plan (100% included below) | +| **Work already done** | IL-1 foundation landed + PR #2; IL-2–5 remaining | + +## Agent instructions (start here) + +1. **Read this whole file** before editing. +2. **cd** to the local clone path above. If the path does not exist, clone the GitHub repo there. +3. `git fetch origin && git checkout -B feat/flow-metrics-2026-07 origin/main` (or continue existing feature branch if present). +4. Implement tasks in the **recommended commit sequence** at the bottom of the plan section. +5. After each commit: run the **verify commands** listed below. +6. Create/update **`docs/TASKS.md`** with every task ID and status. +7. Complete the **README dual-section rewrite**. +8. Run design + code + security self-review on the diff; fix critical/high issues. +9. Push branch and open a PR against `main`. Do **not** force-push. Do **not** merge unless the user asked for auto-merge. +10. Do **not** touch other portfolio repos in this session. + +## What “done” means for this product + +All task IDs listed above are implemented **or** explicitly documented as infeasible with a narrow alternative. Metrics are local-first, activation is a named constant, scoreboard is user-visible with method + privacy copy, README is dual-section, `docs/TASKS.md` is accurate, tests/build green, PR open. + + +## Global constraints (apply to ALL work) + +Source: `~/Desktop/portfolio-implementation-plan-2026-07-10.md` and `~/Desktop/product-flow-metrics-task-list.md`. + +1. **Local-first metrics** — counters/estimates on device or extension storage only. Nothing leaves unless user explicitly exports or opts in. **No cloud analytics in v1.** +2. **One activation event** — named constant in code (e.g. `ACTIVATION_EVENT = '...'`). +3. **User-visible scoreboard** on the natural success screen (ParseKit token-savings pattern), with **method + caveat microcopy**. Honest estimates; never fake precision. +4. **Privacy line on every stats surface:** e.g. *"Stored only on this device. Never uploaded."* (wording may adapt slightly per product). +5. **Zero-AI default** where relevant — core loop works without API keys; AI is optional upgrade. +6. **README rewrite required** in every repo (see below). +7. **`docs/TASKS.md`** committed checklist; update checkboxes as you finish task IDs. +8. **Ship path:** branch `feat/flow-metrics-2026-07` → push → open PR → (if user requested auto-merge and reviews pass) squash-merge to default branch and delete feature branch. + +### README overhaul (mandatory for every product) + +Rewrite `README.md` with **two top sections**: + +#### Start here (novice) +Plain everyday words, zero jargon: +- What it is +- What problems it solves +- Step-by-step install and first use +- Edge cases and how to still use it when you hit them + +#### For technical users +- Mermaid architecture diagram (GitHub-native render) +- How it works under the hood +- Metrics / activation definition +- Dev install, test commands, storage/privacy notes + +### Ship checklist per repo + +- [ ] All task IDs for this product implemented or honestly deferred with reason in PR +- [ ] `docs/TASKS.md` updated (checkboxes match reality) +- [ ] README novice + technical sections done +- [ ] Build/tests pass (commands in this file) +- [ ] Design pass (hierarchy, motion, empty states, reduced-motion) +- [ ] Code pass (edge cases, no regressions) +- [ ] Security pass (secrets, XSS, storage, permissions, no accidental egress) +- [ ] Branch pushed; PR opened with body mapping commits → task IDs → metrics +- [ ] Merge only if user wants auto-merge **or** explicitly asks + +### Related docs (read if needed) + +| File | Why | +|------|-----| +| `~/Desktop/product-flow-metrics-task-list.md` | Original product task specs + metrics definitions | +| `~/Desktop/pm-portfolio-analysis-lenny.md` | Why these metrics matter (senior PM framing) | +| `~/Desktop/portfolio-implementation-plan-2026-07-10.md` | Multi-repo orchestration notes | +| `~/Desktop/fable-portfolio-handoff/STATUS.md` | What Fable completed (mostly plans only) | + + +## Work already completed (do not redo) + +PR open: https://github.com/harshabala/IntentLock/pull/2 · branch `feat/flow-metrics-2026-07` + +| Item | Status | +|------|--------| +| `session-metrics.js` + tests | Done | +| page-tracker `dwellDeltaMs` | Done | +| `isUrlAligned` + relatedHostnames in evaluatePolicyDrift | Partial (backend marks path started; overlay UI not done) | +| background metrics + createHistoryEntry + REPORT_VIEWED | Done | +| Session report UI + popup end → report | Done | +| `docs/TASKS.md` skeleton | Done | +| IL-2 session form presets | **TODO** | +| IL-3 mark-related overlay UI | **TODO** | +| IL-4 weekly popup glance | **TODO** | +| IL-5 heuristics-first onboarding | **TODO** | +| history.js on-intent % | **TODO** (check if missing) | +| docs/storage-schema.md update | **TODO** | +| README dual rewrite | **TODO** | +| Version bump / CHANGELOG | **TODO** | + +**Local path:** `~/Documents/Projects/IntentLock` +**Verify:** `node --test tests/*.mjs` (was 125 pass after IL-1 foundation) + +**Agent resume focus:** Finish IL-2 → IL-5, history.js, storage-schema, README, then push commits onto the existing PR branch. + + +## Original product task spec (source of truth for “why”) + +# P0 — IntentLock + +## Product in one line + +Chrome extension: declare browsing intent; block/score drift when tabs stop matching. + +## Current gap + +Strong mechanism; weak **session report**. Behavior products need a scoreboard or users can’t feel progress (and you can’t tell a metrics story). + +--- + +### Task IL-1 — End-of-session report as the default close path + +| Field | Detail | +|-------|--------| +| **Flow change** | When user ends session (or timer ends): full-screen or new-tab **Session report** before returning to empty new tab. Not only “session cleared.” | +| **Content** | Intent name · duration · **time on-intent %** · interventions · overrides · top domains (aligned vs drift) · one line: “You stayed on track X of Y minutes.” | +| **How it helps** | Makes outcome visceral. Activation isn’t only “locked in” — it’s “saw my first score.” Retention: people return to beat last session. | +| **Metric: Activation** | `activated = completed ≥1 session with duration ≥10 min AND report_viewed = true` | +| **Metric: Core health** | `on_intent_ratio = aligned_active_ms / session_active_ms` | +| **Metric: Intervention quality** | `override_rate = overrides / interventions` (high override = too aggressive or wrong policy) | +| **Metric: Retention proxy** | Sessions started per week (local history already likely exists) | +| **Privacy** | Store domain **hostnames** + intent labels + timestamps in extension storage only. No page titles/full URLs/query strings in the report history if avoidable (prefer eTLD+1). No sync to cloud unless user exports. | +| **User understanding** | *“On-intent % is time on sites that matched your intent policy vs total active browsing in this session. Heuristics don’t need an API key.”* | +| **Acceptance** | Every ended session shows report; history list of last N sessions with on-intent %. | + +### Task IL-2 — Start session: intent + “what success looks like” in one step + +| Field | Detail | +|-------|--------| +| **Flow change** | Session form: intent text + preset (Job search / Deep work / Coding) + strictness. Add one line under strictness: *“We’ll show your on-intent % when you finish.”* Sets expectation for the metric. | +| **How it helps** | Users know the product is scored, not only punitive. | +| **Acceptance** | Copy present on form; no extra step. | + +### Task IL-3 — Intervention overlay: teach, don’t only block + +| Field | Detail | +|-------|--------| +| **Flow change** | Overlay shows short reason: *“This looks like entertainment during Job search (heuristic).”* After override: optional one-tap *“This is related to my work”* → domain cooldown + local label (improves future scores). | +| **How it helps** | Lowers rage-quit; improves policy; shows product judgment. | +| **Metric** | `false_positive_marks` per domain (local) | +| **Privacy** | Label is local boolean on domain, not free-text to a server. | +| **Acceptance** | Override path always available; mark-related is optional. | + +### Task IL-4 — Weekly glance in popup (no account) + +| Field | Detail | +|-------|--------| +| **Flow change** | Popup: this week’s sessions, average on-intent %, best day. Button: “Export week as text” (for journaling / portfolio screenshots). | +| **How it helps** | Retention surface without opening full history. | +| **Privacy** | Export is user-initiated file/clipboard only. | +| **Acceptance** | Works offline; empty state if no sessions. | + +### Task IL-5 — Zero-AI path explicit in first-run + +| Field | Detail | +|-------|--------| +| **Flow change** | Onboarding step 2: default **Heuristics only** selected; LLM as “optional upgrade.” Copy: metrics work either way. | +| **How it helps** | Avoids “AI garnish” positioning; activation not blocked on API keys. | +| **Acceptance** | User can finish onboarding without any key. | + +**IntentLock north star (for you):** weekly sessions among activated users. +**User-facing north star:** average on-intent % (7-day) + sessions this week. + +--- + +## Full Fable implementation plan (100% — implement against this) + +# IntentLock — Implementation Plan: Flow Metrics (IL-1 … IL-5) + +**Repo:** `/private/tmp/claude-501/-Users-harshabalakrishnan-Documents-Projects-IntentLock--claude-worktrees-nosync-sad-stonebraker-513401/43b1ff6a-912a-4fa7-82d6-aa16bd3c1ddd/scratchpad/portfolio/IntentLock` +**Branch:** `feat/flow-metrics-2026-07` (checked out) · **Default branch:** `main` +**Stack:** Chrome MV3, vanilla JS ES modules, no build step, `node:test` suite (114 tests currently passing) +**Test command (verified):** `node --test tests/*.mjs` + +--- + +## 0. Architecture facts the plan builds on + +| Fact | Consequence for this plan | +|------|--------------------------| +| `background.js` owns session lifecycle: `handleSessionStart`, `logEvent`, `evaluateDrift`, `triggerIntervention`, `endActiveSession` → `createHistoryEntry` → `sessionHistory` (capped 100) | All metric accumulation and history-entry enrichment happens here | +| `session.events` is **capped at 50** (`logEvent` shifts) | On-intent % CANNOT be reconstructed from events at session end. Metrics must accumulate **incrementally** into a persisted `session.metrics` object | +| `page-tracker.js` (content script side) reports `PAGE_DWELL` with **cumulative** `dwellMs` per page every 30 s, on visibility-hidden, and beforeunload; resets on SPA nav | To sum active ms safely across service-worker restarts, page-tracker should also report a **delta** (`dwellDeltaMs`) since its last report — a pure, already-tested module, easiest correct place for this | +| `heuristic-policy.js` has private `isKeywordAligned` / `isCategoryAligned`; `evaluatePolicyDrift` computes `isAligned` internally | Export one small helper `isUrlAligned(intent, url, policy, relatedHostnames)` so background can classify each dwell delta as aligned/drift with the exact same logic the policy engine uses | +| Interventions are not recorded anywhere today (only `OVERRIDE` events exist) | Add `session.metrics.interventionCount` incremented in `triggerIntervention` so `override_rate = overrides / interventions` is computable | +| Session report surfaces already exist half-built: `newtab.js showSummary()` (natural close path from new tab), `popup.js` End-session button, `OVERLAY_END_SESSION` in background | IL-1 = upgrade `showSummary` into the full report and route ALL end paths to it | +| History entries currently store full `overrides[].url` | Tighten to hostname-only per IL-1 privacy rule (history.js already renders hostname anyway) | +| Onboarding is `newtab.js showOnboardingWizard()` steps 1–3; step 2 is the LLM key screen with a SKIP button | IL-5 = restructure step 2 so heuristics-only is the selected default, key entry is the opt-in branch | +| No package.json, no linter, no bundler | Only verification gates: `node --test tests/*.mjs` and `node --check ` per changed file | + +--- + +## 1. Cross-cutting foundation (build first) + +### 1.1 New module: `session-metrics.js` (create) + +Pure functions + constants, mirroring the repo idiom of small ES modules with a matching `tests/*.test.mjs`. Contents: + +```js +// The ONE activation event for IntentLock (spec IL-1): +// completed ≥1 session with duration ≥10 min AND its report was viewed. +export const ACTIVATION_EVENT = 'session_report_viewed_after_10_min_session'; +export const ACTIVATION_MIN_SESSION_MS = 10 * 60 * 1000; + +export function qualifiesForActivation(historyEntry) { /* duration ≥ 10 min && reportViewed */ } +export function isActivated(activationState) { /* Boolean(activationState?.activatedAt) */ } + +export function createSessionMetrics() { + return { activeMs: 0, alignedActiveMs: 0, interventionCount: 0, overrideCount: 0, domains: {} }; +} +// domains: { [hostname]: { activeMs, alignedMs } } — capped at 50 hostnames (drop smallest) + +export function applyDwellDelta(metrics, { hostname, deltaMs, aligned }) { /* mutate-free, returns new */ } +export function computeOnIntentRatio(metrics) { /* alignedActiveMs / activeMs, null if activeMs === 0 */ } +export function topDomains(metrics, n = 5) { /* [{hostname, activeMs, aligned}] sorted desc */ } + +export function summarizeWeek(sessionHistory, now = Date.now()) { + // sessions in last 7 days → { sessionCount, avgOnIntentRatio (null-safe), bestDay: 'Tue'|null, totalActiveMs } +} +export function formatWeekExport(summary, now = Date.now()) { /* plain-text block, see IL-4 copy */ } + +// Shared microcopy — single source so every surface stays consistent (spec: privacy line on EVERY stats surface) +export const PRIVACY_COPY = 'Stored only on this device. Never uploaded.'; +export const ON_INTENT_METHOD_COPY = + 'On-intent % is time on sites that matched your intent policy vs total active browsing in this session. ' + + 'It is an estimate from active-tab time — heuristics don’t need an API key.'; +``` + +### 1.2 `heuristic-policy.js` (modify — additive only) + +- Export new helper (placed next to `isKeywordAligned`/`isCategoryAligned`): + ```js + export function isUrlAligned(intent, url, policy, relatedHostnames = []) { + // keywordAligned || categoryAligned || hostname (or parent domain) ∈ relatedHostnames + } + ``` +- Extend `evaluatePolicyDrift` signature with optional `relatedHostnames = []` and fold it into the internal `isAligned` computation (used by IL-3 "mark related"). Default `[]` keeps all 48 existing tests green. +- Improve the `blocked_category` reason label (IL-3 "teach, don't only block"): when site category and intent category are both known, return a composed `reasonLabel`: + `This looks like ${siteCat.label} during ${intentCat.label} (heuristic).` + Fall back to the existing static `REASON_LABELS.blocked_category` otherwise. + +### 1.3 New storage keys (`chrome.storage.local`) — document in `docs/storage-schema.md` + +| Key | Shape | Written by | Task | +|-----|-------|-----------|------| +| `activeSession.metrics` | `{ activeMs, alignedActiveMs, interventionCount, overrideCount, domains: {hostname: {activeMs, alignedMs}} }` | background on every dwell delta / intervention / override | IL-1 | +| `sessionHistory[]` new fields | `activeMs, alignedActiveMs, onIntentRatio (0–1 or null), interventionCount, overrideCount, topDomains: [{hostname, activeMs, aligned}], reportViewed: boolean` — and `overrides[].hostname` **replaces** `overrides[].url` | `createHistoryEntry` + `REPORT_VIEWED` handler | IL-1 | +| `activationState` | `{ activatedAt: number\|null, sessionId: string\|null }` | background on `REPORT_VIEWED` when `qualifiesForActivation` | IL-1 | +| `relatedDomainMarks` | `{ [hostname]: { count: number, lastMarkedAt: number } }` (the spec's `false_positive_marks` per domain; boolean label, local only, capped 200) | background on `OVERLAY_OVERRIDE` with `markRelated: true` | IL-3 | + +No new keys for IL-4 — the weekly glance is computed on the fly from `sessionHistory`. Nothing is ever sent anywhere; export is user-initiated clipboard/file only. + +### 1.4 New test file: `tests/session-metrics.test.mjs` (create) + +Cover: activation constant + `qualifiesForActivation` boundary (599 999 ms vs 600 000 ms; reportViewed false), ratio math incl. `activeMs === 0 → null`, domain cap, `summarizeWeek` (empty history → zeros/nulls; sessions with null ratios excluded from avg; bestDay), `formatWeekExport` snapshot. + +--- + +## 2. Task IL-1 — End-of-session report as the default close path + +### Files + +| File | Change | +|------|--------| +| `page-tracker.js` | Add `dwellDeltaMs` to every report: track `lastReportedActiveMs`, include `dwellDeltaMs = activeMs - lastReportedActiveMs` in `snapshot()`/`report()`, reset it in `resetForUrl` | +| `tests/page-tracker.test.mjs` | New assertions: consecutive reports carry deltas; delta resets after SPA nav | +| `background.js` | Metrics accumulation + history enrichment + report routing (details below) | +| `tests/background.test.mjs` | `createHistoryEntry` includes ratio/topDomains/hostname-only overrides | +| `newtab.js` | `showSummary` → full **Session report**; handle `?report=last` deep link; send `REPORT_VIEWED` | +| `popup.js` | End-session button routes to the report tab | +| `history.js` | Show on-intent % per session card | +| `docs/storage-schema.md` | New fields/keys from §1.3 | + +### `background.js` details + +1. **Init:** in `handleSessionStart`, `session.metrics = session.metrics || createSessionMetrics()` (defensive `ensureMetrics(session)` helper used everywhere metrics are touched, since `newtab.js` creates the session object). +2. **Accumulate:** in `handleContentEvent`, on `PAGE_DWELL`/`SPA_NAVIGATION` with `payload.dwellDeltaMs > 0`: + - `aligned = isUrlAligned(session.intent, payload.url, heuristicPolicy, Object.keys(relatedDomainMarks))` + - update `session.metrics` via `applyDwellDelta` and persist with the existing `logEvent` write (piggyback on the same `chrome.storage.local.set({activeSession})`). + - Respect `trackingEnabled === false`: no dwell events arrive, `activeMs` stays 0, report degrades honestly (see copy). +3. **Interventions:** in `triggerIntervention`, increment `session.metrics.interventionCount` (read-modify-write `activeSession`). +4. **Overrides:** in `handleOverlayOverride` and the `intervention.js` path (`OVERRIDE_INTERVENTION` → `handleOverride`), increment `session.metrics.overrideCount`. +5. **History entry:** extend `createHistoryEntry(session)`: + - `onIntentRatio: computeOnIntentRatio(session.metrics)`, `activeMs`, `alignedActiveMs`, `interventionCount`, `overrideCount`, `topDomains: topDomains(session.metrics, 5)`, `reportViewed: false` + - **Privacy tightening:** map `overrides[].url` → `overrides[].hostname` via existing `extractDomain()`; drop full URL/query strings from history (spec: hostnames only; page titles were already excluded). +6. **New message `REPORT_VIEWED`** (add to `handledMessages`): payload `{ sessionId }` → set `reportViewed: true` on the matching `sessionHistory` entry; if `qualifiesForActivation(entry)` and `activationState.activatedAt` is null, set `activationState = { activatedAt: Date.now(), sessionId }`. This is the **single activation event** (`ACTIVATION_EVENT` in `session-metrics.js`). +7. **Route every close path to the report:** + - `OVERLAY_END_SESSION`: after `endActiveSession`, `chrome.tabs.create({ url: chrome.runtime.getURL('newtab.html?report=last') })`. + - Keyboard shortcut `toggle-session` end path: same. + - popup and newtab paths handled in their own files (below). + +### `newtab.js` details + +- On `DOMContentLoaded`, before the existing `activeSession` check: if `new URLSearchParams(location.search).get('report') === 'last'` and no active session, load `sessionHistory`, render `showSummary` for the last entry. +- Rewrite `showSummary(container, session)` into the **Session report** (works from either a live ended session or a history entry — normalize both into one shape): + - `h1`: `Session report` + - Intent line (existing intent box). + - **Hero stat** (the ParseKit-style emotional payoff): big on-intent %, with the one-liner below: + - `You stayed on track ${alignedMin} of ${activeMin} minutes.` (rounded whole minutes) + - If `onIntentRatio === null` (no dwell data / tracking off): show `—` and `Not enough activity data to score this session.` (never fake a number). If tracking is disabled add: `Behavior tracking is off in Settings, so on-intent % can't be measured.` + - Stat rows (reuse `.stat-row` pattern): `Duration`, `Interventions`, `Overrides`, `Budget` (existing logic). + - **Top domains** section replacing "Top drift sites": last-5 by active time, each row `hostname · Xm` with an `aligned` / `drift` tag (reuse `.plan-section` + `.stat-row`). + - Reflections section (existing, unchanged). + - **Method + caveat microcopy** (small, under the hero stat): `ON_INTENT_METHOD_COPY` from `session-metrics.js`. + - **Privacy line** (footer of the report): `PRIVACY_COPY` → `Stored only on this device. Never uploaded.` + - Buttons: `Start new session` (existing) + `View in history` (existing). +- After rendering, send `chrome.runtime.sendMessage({ type: 'REPORT_VIEWED', sessionId })`. +- Delete the dead local `createHistoryEntry` duplicate in `newtab.js` (unused since ending moved to background) — small cleanup, keeps one source of truth. + +### `popup.js` details + +- End-session click: after `END_ACTIVE_SESSION` + `SESSION_CLEARED`, `chrome.tabs.create({ url: chrome.runtime.getURL('newtab.html?report=last') })` and `window.close()` — the report becomes the default close path from the popup too. + +### `history.js` details + +- In the meta row items, insert on-intent % when present: `` `${Math.round(session.onIntentRatio * 100)}% on-intent` `` (omit when `onIntentRatio == null` — old entries stay renderable). `overrides[].hostname` fallback: keep reading `o.url` for pre-existing entries (`o.hostname || hostname(o.url)`). + +### Acceptance verification + +- Load unpacked → run a ≥10 min session mixing an aligned site (e.g. github.com under `coding`) and a drift site → end via (a) new-tab Complete, (b) popup End session, (c) overlay End session → all three land on the Session report showing intent, duration, on-intent %, interventions, overrides, top domains, "You stayed on track X of Y minutes." +- `chrome.storage.local.get(['sessionHistory','activationState'])` in the service-worker console: entry has `onIntentRatio`, `reportViewed: true`, hostname-only overrides; `activationState.activatedAt` set. +- History page lists last sessions with on-intent %. +- `node --test tests/*.mjs` green. + +--- + +## 3. Task IL-2 — Start session: intent + "what success looks like" in one step + +### Files + +| File | Change | +|------|--------| +| `newtab.js` | Extend `showNewSessionForm` (one screen, no extra step) | +| `newtab.css` | Minor styles for the preset row + expectation line (follow existing `.input-group` / `.field-hint` idioms) | + +### Approach + +- Add to the existing form, between the intent textarea and the time budget: + - **Preset select** `#intent-preset` labeled `Preset` — options built from `INTENT_CATEGORIES` (all 12; the spec's Job search / Deep work / Coding are the first three; default = current `heuristicPolicy.intentCategoryId` or `classifyIntentCategory(intent)` suggestion on blur). + - **Strictness select** `#session-strictness` labeled `Strictness` — `relaxed / balanced / strict`, defaulted from stored `heuristicPolicy.strictness`. +- One line of copy directly under the strictness select (class `field-hint`): + - `We’ll show your on-intent % when you finish.` +- On submit (inside the existing `bindForm` handler, before session creation): if preset/strictness differ from stored policy, `buildDefaultPolicy(presetId, strictness)` (preserving `customBlockDomains`/`customAllowDomains` from the existing policy), save `heuristicPolicy`, send `CONFIG_UPDATED`. Session start flow otherwise unchanged — **no extra step**. + +### Acceptance verification + +- Open new tab → form shows intent + preset + strictness + the expectation copy; Lock in still one click. Changing preset updates `heuristicPolicy` (check storage). Screenshot for case study. + +--- + +## 4. Task IL-3 — Intervention overlay: teach, don't only block + +### Files + +| File | Change | +|------|--------| +| `heuristic-policy.js` | Composed teach-reason for `blocked_category` + `relatedHostnames` param (§1.2) | +| `tests/heuristic-policy.test.mjs` | New cases: composed reason label; `relatedHostnames` makes a blocked domain aligned (no intervene); default arg keeps old behavior | +| `intervention-overlay.js` | Add optional one-tap "mark related" control | +| `tests/intervention-overlay.test.mjs` | Overlay exposes the mark control; override callback receives `markRelated` | +| `content.js` | Pass `markRelated` through `OVERLAY_OVERRIDE` payload | +| `background.js` | Persist mark → `relatedDomainMarks`; feed `relatedHostnames` into `evaluatePolicyDrift` and `isUrlAligned`; load key in `loadConfig` | +| `intervention.html` / `intervention.js` | Same optional checkbox on the full-page fallback for parity | +| `docs/storage-schema.md` | `relatedDomainMarks` key | + +### Approach + +- **Reason copy (teach):** background already passes `policyDrift.reasonLabel` — with §1.2 the overlay shows e.g. `This looks like Social Media during Job Search (heuristic).` LLM-triggered interventions keep the existing generic line. +- **Overlay control:** in `createInterventionOverlay`, add a checkbox row above the actions (unchecked by default, purely optional): + - Label: `This is related to my work` (id `intentlock-mark-related`) + - `onOverride(reflection, { markRelated })` — override path always available and unchanged when unchecked. +- **content.js:** include `markRelated` in the `OVERLAY_OVERRIDE` payload. +- **background.js `handleOverlayOverride`:** when `payload.markRelated` and a hostname is extractable: + - `relatedDomainMarks[hostname] = { count: (prev.count||0)+1, lastMarkedAt: Date.now() }` (the spec's `false_positive_marks` metric, local only — a boolean-ish label on the domain, never free-text to any server). + - Existing 5-min cooldown still applies (already implemented in `handleOverride`), satisfying "domain cooldown". +- **Scoring improvement:** in `evaluateDrift`, pass `relatedHostnames: Object.keys(relatedDomainMarks)` to `evaluatePolicyDrift`, and to `isUrlAligned` for dwell classification (IL-1) — marked domains count as aligned in future sessions and their dwell floors (120 s rule) no longer fire. This is deliberately stronger than `customAllowDomains` (which only clears the category block, not the unaligned-dwell floor). +- `loadConfig` reads `relatedDomainMarks` into memory alongside `overrideCooldowns`. + +### Acceptance verification + +- Trigger a blocked-category intervention during a `job_search` session → overlay reason reads `This looks like Social Media during Job Search (heuristic).` +- Override without the checkbox: works exactly as before (reflection required, cooldown set). +- Override with checkbox: `relatedDomainMarks` written; start a new session with the same intent category → visiting that domain no longer intervenes. +- Tests: policy tests for `relatedHostnames`; overlay test for the control; all existing tests green. + +--- + +## 5. Task IL-4 — Weekly glance in popup (no account) + +### Files + +| File | Change | +|------|--------| +| `popup.js` | Weekly glance block + export button (both session-active and no-session states) | +| `popup.html` | none needed (popup.js builds DOM into `#content`) — keep as is | +| `newtab.css` | Small `.week-glance` styles reusing `.stat-row` / `.no-session` tokens | +| `session-metrics.js` | `summarizeWeek` + `formatWeekExport` (§1.1) — popup.js must load it; **note:** popup.html loads `popup.js` as a classic script, so either add `type="module"` to the ` + + \ No newline at end of file diff --git a/legacy-extension-source/diagnostics.js b/legacy-extension-source/diagnostics.js new file mode 100644 index 0000000..01b8a9b --- /dev/null +++ b/legacy-extension-source/diagnostics.js @@ -0,0 +1,105 @@ +import { + getErrorLog, + clearErrorLog, + formatErrorLogForExport, +} from './error-log.js'; + +document.addEventListener('DOMContentLoaded', () => { + const listEl = document.getElementById('error-log-list'); + const emptyEl = document.getElementById('empty-log'); + const copyBtn = document.getElementById('copy-log-btn'); + const clearBtn = document.getElementById('clear-log-btn'); + const copyStatus = document.getElementById('copy-status'); + let allEntries = []; + let activeFilter = 'all'; + + function showStatus(text) { + copyStatus.textContent = text; + copyStatus.classList.remove('hidden'); + copyStatus.style.display = 'block'; + clearTimeout(copyStatus._timer); + copyStatus._timer = setTimeout(() => { + copyStatus.style.display = 'none'; + }, 2500); + } + + function formatTime(timestamp) { + return new Date(timestamp).toLocaleString(); + } + + function renderEntries() { + const filtered = activeFilter === 'all' + ? allEntries + : allEntries.filter((entry) => entry.type === activeFilter); + + listEl.textContent = ''; + emptyEl.classList.toggle('hidden', filtered.length > 0); + + filtered.forEach((entry) => { + const card = document.createElement('article'); + card.className = 'diagnostics-entry'; + + const meta = document.createElement('div'); + meta.className = 'diagnostics-meta'; + meta.textContent = `${formatTime(entry.timestamp)} · ${entry.type} · ${entry.source || 'unknown'}`; + + const message = document.createElement('p'); + message.className = 'diagnostics-message'; + message.textContent = entry.message; + + card.append(meta, message); + + if (entry.details && Object.keys(entry.details).length > 0) { + const details = document.createElement('pre'); + details.className = 'diagnostics-details'; + details.textContent = JSON.stringify(entry.details, null, 2); + card.appendChild(details); + } + + listEl.appendChild(card); + }); + } + + async function refresh() { + allEntries = await getErrorLog(); + renderEntries(); + } + + document.querySelectorAll('.diagnostics-filters .filter-btn').forEach((btn) => { + btn.addEventListener('click', () => { + document.querySelectorAll('.diagnostics-filters .filter-btn').forEach((b) => b.classList.remove('active')); + btn.classList.add('active'); + activeFilter = btn.dataset.filter; + renderEntries(); + }); + }); + + copyBtn.addEventListener('click', async () => { + const filtered = activeFilter === 'all' + ? allEntries + : allEntries.filter((entry) => entry.type === activeFilter); + const text = formatErrorLogForExport(filtered); + try { + await navigator.clipboard.writeText(text); + showStatus('Log copied to clipboard.'); + } catch { + const blob = new Blob([text], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `intentlock-diagnostics-${Date.now()}.txt`; + a.click(); + URL.revokeObjectURL(url); + showStatus('Log downloaded as text file.'); + } + }); + + clearBtn.addEventListener('click', async () => { + await clearErrorLog(); + allEntries = []; + renderEntries(); + showStatus('Diagnostic log cleared.'); + }); + + refresh(); +}); \ No newline at end of file diff --git a/legacy-extension-source/distraction-sites.js b/legacy-extension-source/distraction-sites.js new file mode 100644 index 0000000..782ebc9 --- /dev/null +++ b/legacy-extension-source/distraction-sites.js @@ -0,0 +1,13 @@ +// distraction-sites.js — Default distraction domains for heuristic drift + +export const DEFAULT_DISTRACTION_SITES = [ + 'twitter.com', 'x.com', 'facebook.com', 'reddit.com', + 'instagram.com', 'youtube.com', 'netflix.com', 'tiktok.com', +]; + +export function getEffectiveDistractionSites(storedSites, fallback = DEFAULT_DISTRACTION_SITES) { + if (Array.isArray(storedSites) && storedSites.length > 0) { + return storedSites; + } + return fallback; +} \ No newline at end of file diff --git a/legacy-extension-source/drift-cache.js b/legacy-extension-source/drift-cache.js new file mode 100644 index 0000000..d866028 --- /dev/null +++ b/legacy-extension-source/drift-cache.js @@ -0,0 +1,61 @@ +// drift-cache.js — In-memory TTL cache for LLM drift check results + +export const DRIFT_CACHE_TTL_MS = 60_000; +export const DRIFT_CACHE_MAX_ENTRIES = 100; + +const cache = new Map(); + +export function buildDriftCacheKey(intent, url, history = []) { + const recent = (Array.isArray(history) ? history : []) + .slice(-5) + .map((event) => `${event.actionType || 'unknown'}:${event.url || 'n/a'}`) + .join('|'); + return `${String(intent || '').trim()}::${String(url || '').trim()}::${recent}`; +} + +function pruneExpired(now = Date.now()) { + for (const [key, entry] of cache.entries()) { + if (!entry || entry.expiresAt <= now) { + cache.delete(key); + } + } +} + +function evictOldestIfNeeded() { + if (cache.size < DRIFT_CACHE_MAX_ENTRIES) return; + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } +} + +export function getCachedDrift(key, now = Date.now()) { + pruneExpired(now); + const entry = cache.get(key); + if (!entry || entry.expiresAt <= now) { + if (entry) cache.delete(key); + return null; + } + return { ...entry.result, cached: true }; +} + +export function setCachedDrift(key, result, now = Date.now()) { + pruneExpired(now); + evictOldestIfNeeded(); + cache.set(key, { + result: { + isAligned: Boolean(result?.isAligned), + confidence: typeof result?.confidence === 'number' ? result.confidence : 0, + }, + expiresAt: now + DRIFT_CACHE_TTL_MS, + }); +} + +export function clearDriftCache() { + cache.clear(); +} + +export function getDriftCacheSize() { + pruneExpired(); + return cache.size; +} \ No newline at end of file diff --git a/legacy-extension-source/drift.js b/legacy-extension-source/drift.js new file mode 100644 index 0000000..1aa6b76 --- /dev/null +++ b/legacy-extension-source/drift.js @@ -0,0 +1,108 @@ +export const DRIFT_CONFIDENCE_THRESHOLD = 0.7; +export const DWELL_DISTRACTION_MS = 60_000; +export const DWELL_UNALIGNED_MS = 120_000; + +const STOP_WORDS = new Set([ + 'about', 'after', 'again', 'also', 'from', 'have', 'into', 'latest', + 'learn', 'look', 'make', 'need', 'page', 'read', 'some', 'task', + 'that', 'this', 'with', 'work', 'write', 'your' +]); + +function words(value) { + return String(value || '') + .toLowerCase() + .match(/[a-z0-9]+/g) || []; +} + +function intentTerms(intent) { + return [...new Set(words(intent).filter(word => word.length > 3 && !STOP_WORDS.has(word)))]; +} + +function parseUrl(url) { + try { + const parsed = new URL(url); + return { + hostname: parsed.hostname.replace(/^www\./, '').toLowerCase(), + text: `${parsed.hostname} ${parsed.pathname} ${parsed.search}`.toLowerCase() + }; + } catch { + return null; + } +} + +function isAlignedWithIntent(url, terms) { + const parsed = parseUrl(url); + if (!parsed || terms.length === 0) return false; + return terms.some(term => parsed.text.includes(term)); +} + +function isConfiguredDistraction(hostname, distractionSites) { + return distractionSites.some(site => { + const normalized = String(site || '').replace(/^www\./, '').toLowerCase(); + return normalized && (hostname === normalized || hostname.endsWith(`.${normalized}`)); + }); +} + +function evaluateHeuristicDrift({ intent, url, events = [], distractionSites = [], now = Date.now() }) { + const parsed = parseUrl(url); + if (!parsed) { + return { shouldIntervene: false, score: 0, reason: 'invalid_url' }; + } + + const terms = intentTerms(intent); + const currentAligned = isAlignedWithIntent(url, terms); + + if (isConfiguredDistraction(parsed.hostname, distractionSites) && !currentAligned) { + return { shouldIntervene: true, score: 0.9, reason: 'known_distraction' }; + } + + if (terms.length === 0) { + return { shouldIntervene: false, score: 0, reason: 'empty_terms' }; + } + + const recentEvents = events.filter(event => now - event.timestamp <= 2 * 60 * 1000); + const unrelatedEvents = recentEvents.filter(event => event.url && !isAlignedWithIntent(event.url, terms)); + const tabSwitches = recentEvents.filter(event => event.actionType === 'TAB_SWITCH').length; + const sameDomainLoads = recentEvents.filter(event => { + const eventUrl = parseUrl(event.url); + return eventUrl && eventUrl.hostname === parsed.hostname; + }).length; + + const dwellForUrl = recentEvents + .filter((event) => event.actionType === 'PAGE_DWELL' && event.url === url) + .reduce((total, event) => total + (event.dwellMs || 0), 0); + + let score = currentAligned ? 0 : 0.25; + if (unrelatedEvents.length >= 3) score += 0.35; + if (tabSwitches >= 4) score += 0.25; + if (!currentAligned && sameDomainLoads >= 2) score += 0.2; + if ( + isConfiguredDistraction(parsed.hostname, distractionSites) && + !currentAligned && + dwellForUrl >= DWELL_DISTRACTION_MS + ) { + score += 0.15; + } + if (!currentAligned && dwellForUrl >= DWELL_UNALIGNED_MS) { + score = Math.max(score, DRIFT_CONFIDENCE_THRESHOLD); + } + + let reason = 'low_confidence'; + if (score >= DRIFT_CONFIDENCE_THRESHOLD) { + if (tabSwitches >= 4) { + reason = 'rapid_context_switching'; + } else if (dwellForUrl >= DWELL_UNALIGNED_MS) { + reason = 'extended_unrelated_dwell'; + } else { + reason = 'repeated_unrelated_activity'; + } + } + + return { + shouldIntervene: score >= DRIFT_CONFIDENCE_THRESHOLD, + score: Math.min(score, 1), + reason + }; +} + +export { evaluateHeuristicDrift, intentTerms }; diff --git a/legacy-extension-source/error-log.js b/legacy-extension-source/error-log.js new file mode 100644 index 0000000..69daeeb --- /dev/null +++ b/legacy-extension-source/error-log.js @@ -0,0 +1,145 @@ +// error-log.js — Local diagnostic log for user-visible errors + +export const ERROR_TYPES = { + API: 'api', + CONFIG: 'config', + UI: 'ui', + STORAGE: 'storage', + VALIDATION: 'validation', + RUNTIME: 'runtime', +}; + +export const MAX_LOG_ENTRIES = 200; + +export function classifyApiError(status, bodyText = '', providerId = 'unknown') { + const body = typeof bodyText === 'string' ? bodyText : ''; + const lower = body.toLowerCase(); + + let code = 'api_error'; + let message = `API request failed (${status || 'network error'}).`; + + if (status === 401 || lower.includes('invalid api key') || lower.includes('incorrect api key')) { + code = 'invalid_api_key'; + message = 'API key is invalid or unauthorized. Check your key in Settings.'; + } else if (status === 403) { + code = 'forbidden'; + message = 'API access forbidden. Your key may lack permission for this model or endpoint.'; + } else if (status === 429 || lower.includes('quota') || lower.includes('rate limit') || lower.includes('resource_exhausted')) { + code = 'quota_exceeded'; + message = 'API quota or rate limit exceeded. Usage may be full — try again later or check your provider billing.'; + } else if (status === 404 || lower.includes('not found')) { + code = 'not_found'; + message = 'API endpoint or model not found. Check model name and endpoint in Advanced settings.'; + } else if (status === 400) { + code = 'bad_request'; + message = 'API rejected the request. Configuration may be incorrect.'; + } else if (!status || status === 0) { + code = 'network_error'; + message = 'Could not reach the API. Check your network or local server (Ollama/LM Studio).'; + } + + let providerMessage = null; + try { + const parsed = JSON.parse(body); + providerMessage = parsed?.error?.message + || parsed?.error?.message + || parsed?.message + || parsed?.[0]?.error?.message + || null; + } catch { + if (body.length > 0 && body.length < 500) { + providerMessage = body.trim(); + } + } + + if (providerMessage) { + message = `${message} Provider says: ${providerMessage}`; + } + + return { + code, + message, + status: status || null, + providerId, + providerMessage, + }; +} + +export function formatErrorLogForExport(entries = []) { + const lines = [ + 'IntentLock Diagnostic Log', + `Exported: ${new Date().toISOString()}`, + `Entries: ${entries.length}`, + '', + ]; + + entries.forEach((entry, index) => { + lines.push(`--- Entry ${index + 1} ---`); + lines.push(`Time: ${new Date(entry.timestamp).toISOString()}`); + lines.push(`Type: ${entry.type}`); + lines.push(`Source: ${entry.source || 'unknown'}`); + lines.push(`Message: ${entry.message}`); + if (entry.details && Object.keys(entry.details).length > 0) { + lines.push(`Details: ${JSON.stringify(entry.details, null, 2)}`); + } + lines.push(''); + }); + + return lines.join('\n'); +} + +function sanitizeDetails(details) { + if (!details || typeof details !== 'object') return {}; + const copy = { ...details }; + if (copy.apiKey) copy.apiKey = '[redacted]'; + if (copy.key) copy.key = '[redacted]'; + return copy; +} + +export function logError({ type = ERROR_TYPES.RUNTIME, message, details = null, source = 'unknown' }) { + if (!message) return Promise.resolve(null); + + const entry = { + id: crypto.randomUUID(), + timestamp: Date.now(), + type, + message, + details: sanitizeDetails(details), + source, + }; + + console.error(`[IntentLock:${type}] ${message}`, entry.details || ''); + + if (typeof chrome === 'undefined' || !chrome.storage?.local) { + return Promise.resolve(entry); + } + + return new Promise((resolve) => { + chrome.storage.local.get(['errorLog'], (result) => { + const log = Array.isArray(result?.errorLog) ? result.errorLog : []; + log.unshift(entry); + if (log.length > MAX_LOG_ENTRIES) log.length = MAX_LOG_ENTRIES; + chrome.storage.local.set({ errorLog: log }, () => resolve(entry)); + }); + }); +} + +export function getErrorLog() { + if (typeof chrome === 'undefined' || !chrome.storage?.local) { + return Promise.resolve([]); + } + return new Promise((resolve) => { + chrome.storage.local.get(['errorLog'], (result) => { + resolve(Array.isArray(result?.errorLog) ? result.errorLog : []); + }); + }); +} + +export function clearErrorLog() { + if (typeof chrome === 'undefined' || !chrome.storage?.local) { + return Promise.resolve(); + } + return new Promise((resolve) => { + chrome.storage.local.set({ errorLog: [] }, () => resolve()); + }); +} \ No newline at end of file diff --git a/legacy-extension-source/history.html b/legacy-extension-source/history.html new file mode 100644 index 0000000..abfacfa --- /dev/null +++ b/legacy-extension-source/history.html @@ -0,0 +1,37 @@ + + + + + IntentLock — History + + + +
+
+

Session history

+

A local summary of completed sessions.

+
+ + + +
+
+ +
+
+ + + + +
+
+ +
+
+ + + + diff --git a/legacy-extension-source/history.js b/legacy-extension-source/history.js new file mode 100644 index 0000000..8ab7b68 --- /dev/null +++ b/legacy-extension-source/history.js @@ -0,0 +1,135 @@ +document.addEventListener('DOMContentLoaded', () => { + const historyList = document.getElementById('history-list'); + const emptyState = document.getElementById('empty-state'); + const searchInput = document.getElementById('history-search'); + + let allSessions = []; + let currentFilter = 'all'; + let searchQuery = ''; + + // Load sessions + chrome.storage.local.get(['sessionHistory'], (result) => { + allSessions = result.sessionHistory || []; + + if (allSessions.length === 0) { + emptyState.classList.remove('hidden'); + document.querySelector('.history-controls').classList.add('hidden'); + return; + } + + // Setup filter buttons + document.querySelectorAll('.filter-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + currentFilter = btn.dataset.filter; + renderSessions(); + }); + }); + + // Setup search + searchInput.addEventListener('input', (e) => { + searchQuery = e.target.value.toLowerCase().trim(); + renderSessions(); + }); + + renderSessions(); + }); + + function filterSessions(sessions) { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); + const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000); + + let filtered = sessions; + + // Apply time filter + switch (currentFilter) { + case 'today': + filtered = filtered.filter(s => new Date(s.startTime) >= today); + break; + case 'week': + filtered = filtered.filter(s => new Date(s.startTime) >= weekAgo); + break; + case 'month': + filtered = filtered.filter(s => new Date(s.startTime) >= monthAgo); + break; + } + + // Apply search filter + if (searchQuery) { + filtered = filtered.filter(s => s.intent.toLowerCase().includes(searchQuery)); + } + + return filtered; + } + + function renderSessions() { + historyList.textContent = ''; + + const filtered = filterSessions(allSessions); + + if (filtered.length === 0) { + emptyState.classList.remove('hidden'); + return; + } else { + emptyState.classList.add('hidden'); + } + + // Render sessions (newest first) + const reversed = [...filtered].reverse(); + reversed.forEach(session => { + const card = document.createElement('div'); + card.className = 'section history-card'; + + // Header row with intent + const headerRow = document.createElement('div'); + headerRow.className = 'history-header-row'; + + const intent = document.createElement('p'); + intent.className = 'history-intent'; + intent.textContent = session.intent; + + headerRow.appendChild(intent); + card.appendChild(headerRow); + + // Meta row + const meta = document.createElement('div'); + meta.className = 'history-meta'; + + const date = new Date(session.startTime); + const dateStr = date.toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric' + }); + const duration = Math.round((session.endTime - session.startTime) / 60000); + + const items = [ + dateStr, + `${duration} min`, + `${session.driftCount || 0} drift${(session.driftCount || 0) !== 1 ? 's' : ''}` + ]; + + if (session.timeBudget) { + const diff = duration - session.timeBudget; + items.push(diff <= 0 ? 'Under budget' : `${diff} min over`); + } + + items.forEach((item, i) => { + const span = document.createElement('span'); + span.textContent = item; + meta.appendChild(span); + if (i < items.length - 1) { + const sep = document.createElement('span'); + sep.className = 'history-sep'; + sep.textContent = '\u00B7'; + meta.appendChild(sep); + } + }); + + card.appendChild(meta); + + historyList.appendChild(card); + }); + } +}); diff --git a/legacy-extension-source/icon.svg b/legacy-extension-source/icon.svg new file mode 100644 index 0000000..22be111 --- /dev/null +++ b/legacy-extension-source/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/legacy-extension-source/icon128.png b/legacy-extension-source/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..558e6d43cf82f0d99b7a8ade462cd9517ff7c599 GIT binary patch literal 791 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV7lk&;uumf=j|lNNz#ryhyVQd zSD%{g_xk2bHqC{rE4HvJef(KtSAXW!0|qxn9toKP8V!ui%o3~y2?Y#`8Q6IE5=0I( z9AxZak}$An2xMSpV>jSB@bvy-Nf8FAfB)T$H5n}aC2vynNI2`@ata^Y^t4&^qiLase|Fieq&CCxT>rZE3?~th92vl(7 zL$~Vz#z)8E)fo8%Y)>%(&FoTq@qWFS!qPt?eW6TCrp`NN9M-Tr-}*KOW7MhKhiilu zti57(FM~1abk5UtVhw4sNBWsfrkg+HlX<--4KlgJ9gxxS-|4@$A!r;%@{7ZBD#UGrV zUeM3v@Ob_&ySqOl82#*6Im90v3vLKt%(G(+U|{tSYN+5^z`!*@f#Ii!10w=42q-+% zWYA#HJ=j?-^1$Kd@_9@e4Nz3T6d*Kbk2@ou$iHvF4GN5T-UW|=b_V>4_Ge^b-JyE$ zEKrMMqPl!xKa-8dn|_x==m7ueAJ-?wJ+yP94Gf0v9M?u>E2-YuyXQBZM( zdn7|U=a#L7Lv-ThcLy17am_A+`(0jKEacXb=z@mzUff!Fr(`W0iYtYG6gIfCs)^n_ zC9Cd`JTrICM<$c0w+=!{3e(wj3=3FHHiP(UxEMMeHiP8PE@7C!VbaV5On+R!gvWrE@DdK7 drc#4?#j;C0|*-o&GDNxJzaL)YO3iNdCr4Gn2hOI?)a`0hQ!!f-(G WyZH1o8}0&4WAJqKb6Mw<&;$VgOD8J; literal 0 HcmV?d00001 diff --git a/legacy-extension-source/icon48.png b/legacy-extension-source/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..405dc2bb08e4248b36fc1c6138dbce27add484e2 GIT binary patch literal 274 zcmV+t0qy>YP)ihtn+8W3He0nr6=1HP05tWpNBOKN~N zDFK$G0Z1ju2ww5q^HhMf3;}^F&&ERl$lC+Wx?gJj4MFo7Af7J=*d;wcleB=7G@t>~ zLQ7Hs%D+GZG61xQlX>m{31OM%4w%m)=eYwM<~6{K0L_Y747>>3(`ySLU(pP(76F&P z1MCXTrL^bV0h*g76~_XkTPBsq0<;M5PAot|5cc5#D+qB95AY+%JGcOcFT6klq6=Jo YFX7y?5}LwKiU0rr07*qoM6N<$f{4RtLI3~& literal 0 HcmV?d00001 diff --git a/legacy-extension-source/intervention-overlay.js b/legacy-extension-source/intervention-overlay.js new file mode 100644 index 0000000..f74d135 --- /dev/null +++ b/legacy-extension-source/intervention-overlay.js @@ -0,0 +1,198 @@ +// intervention-overlay.js — In-page shadow-DOM intervention UI + +export function buildOverlayStyles() { + return ` + :host { + all: initial; + position: fixed; + inset: 0; + z-index: 2147483647; + font-family: "SF Mono", "Fira Code", "JetBrains Mono", ui-monospace, monospace; + } + .backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.92); + } + .panel { + position: relative; + z-index: 1; + max-width: 480px; + margin: 10vh auto 0; + padding: 48px 32px; + color: #ffffff; + text-align: center; + } + h1 { + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.15em; + text-transform: uppercase; + margin: 0 0 16px; + } + .reason, .hint { + color: #888888; + font-size: 0.8rem; + line-height: 1.6; + margin: 0 0 16px; + } + .intent-box { + border: 1px solid #222222; + padding: 16px; + margin: 24px 0; + text-align: left; + } + .intent-label { + font-size: 0.65rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #9a9a9a; + margin: 0 0 8px; + } + .intent-text { + font-size: 0.85rem; + line-height: 1.5; + margin: 0; + color: #ffffff; + } + label { + display: block; + text-align: left; + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 8px; + color: #888888; + } + textarea { + width: 100%; + min-height: 96px; + background: #111111; + color: #ffffff; + border: 1px solid #222222; + border-radius: 2px; + padding: 12px; + font: inherit; + resize: vertical; + box-sizing: border-box; + margin-bottom: 16px; + } + textarea:focus { + outline: none; + border-color: #ffffff; + } + .actions { + display: flex; + gap: 12px; + } + button { + flex: 1; + min-height: 44px; + border-radius: 2px; + font: inherit; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + } + .dismiss-btn { + background: transparent; + color: #888888; + border: 1px solid #222222; + } + .override-btn { + background: #ffffff; + color: #000000; + border: 1px solid #ffffff; + } + `; +} + +export function createInterventionOverlay({ onOverride, onDismiss } = {}) { + let host = null; + let shadow = null; + let reflectionInput = null; + + function ensureHost() { + if (host) return; + host = document.createElement('div'); + host.id = 'intentlock-intervention-host'; + shadow = host.attachShadow({ mode: 'open' }); + + const style = document.createElement('style'); + style.textContent = buildOverlayStyles(); + shadow.appendChild(style); + + const backdrop = document.createElement('div'); + backdrop.className = 'backdrop'; + + const panel = document.createElement('div'); + panel.className = 'panel'; + panel.innerHTML = ` +

Drift detected

+

+
+

Session intent

+

+
+ + `; + + reflectionInput = document.createElement('textarea'); + reflectionInput.id = 'intentlock-reflection'; + reflectionInput.placeholder = 'I need a break, or this is actually relevant...'; + reflectionInput.setAttribute('autofocus', ''); + + const actions = document.createElement('div'); + actions.className = 'actions'; + + const dismissBtn = document.createElement('button'); + dismissBtn.type = 'button'; + dismissBtn.className = 'dismiss-btn'; + dismissBtn.textContent = 'Dismiss'; + dismissBtn.addEventListener('click', () => { + hide(); + if (typeof onDismiss === 'function') onDismiss(); + }); + + const overrideBtn = document.createElement('button'); + overrideBtn.type = 'button'; + overrideBtn.className = 'override-btn'; + overrideBtn.textContent = 'Override & continue'; + overrideBtn.addEventListener('click', () => { + const reflection = reflectionInput.value.trim(); + if (!reflection) { + reflectionInput.focus(); + return; + } + hide(); + if (typeof onOverride === 'function') onOverride(reflection); + }); + + actions.append(dismissBtn, overrideBtn); + panel.append(reflectionInput, actions); + shadow.append(backdrop, panel); + document.documentElement.appendChild(host); + } + + function show({ reason = 'You are deviating from your intent.', intent = '' } = {}) { + ensureHost(); + const reasonEl = shadow.querySelector('[data-role="reason"]'); + const intentEl = shadow.querySelector('[data-role="intent"]'); + if (reasonEl) reasonEl.textContent = reason; + if (intentEl) intentEl.textContent = intent || 'No active session intent.'; + reflectionInput.value = ''; + host.style.display = 'block'; + reflectionInput.focus(); + } + + function hide() { + if (host) host.style.display = 'none'; + } + + function isVisible() { + return Boolean(host && host.style.display !== 'none'); + } + + return { show, hide, isVisible }; +} \ No newline at end of file diff --git a/legacy-extension-source/intervention.html b/legacy-extension-source/intervention.html new file mode 100644 index 0000000..724dcca --- /dev/null +++ b/legacy-extension-source/intervention.html @@ -0,0 +1,33 @@ + + + + + IntentLock — Drift + + + +
+
+

Drift detected

+

You are deviating from your intent.

+
+ +
+

Session intent

+

Loading...

+
+ +
+
+ + +
+ + + +
+
+ + + + diff --git a/legacy-extension-source/intervention.js b/legacy-extension-source/intervention.js new file mode 100644 index 0000000..157660a --- /dev/null +++ b/legacy-extension-source/intervention.js @@ -0,0 +1,77 @@ +document.addEventListener('DOMContentLoaded', () => { + const reasonText = document.getElementById('reason-text'); + const currentIntent = document.getElementById('current-intent'); + const reflectionInput = document.getElementById('reflection-input'); + const returnBtn = document.getElementById('return-btn'); + const reflectionForm = document.getElementById('reflection-form'); + + chrome.storage.local.get(['activeSession', 'interventionState'], (result) => { + if (result.activeSession && result.activeSession.isActive) { + currentIntent.textContent = result.activeSession.intent; + } else { + currentIntent.textContent = 'No active session found.'; + } + + if (result.interventionState && result.interventionState.reason) { + reasonText.textContent = result.interventionState.reason; + } + }); + + returnBtn.addEventListener('click', () => { + chrome.storage.local.remove(['interventionState'], () => { + chrome.tabs.getCurrent((tab) => { + if (chrome.runtime.lastError || !tab) { + console.error("Could not get current tab:", chrome.runtime.lastError); + return; + } + chrome.tabs.remove(tab.id); + }); + }); + }); + + reflectionForm.addEventListener('submit', (e) => { + e.preventDefault(); + const reflection = reflectionInput.value.trim(); + if (!reflection) return; + + chrome.storage.local.get(['activeSession', 'interventionState'], (result) => { + if (result.activeSession) { + const originalUrl = result.interventionState ? result.interventionState.originalUrl : null; + result.activeSession.events = Array.isArray(result.activeSession.events) ? result.activeSession.events : []; + result.activeSession.events.push({ + timestamp: Date.now(), + actionType: 'OVERRIDE', + url: originalUrl, + reflection: reflection + }); + + chrome.storage.local.set({ activeSession: result.activeSession }, () => { + chrome.runtime.sendMessage({ type: 'OVERRIDE_INTERVENTION', sessionData: result.activeSession }); + + chrome.storage.local.remove(['interventionState'], () => { + if (originalUrl) { + window.location.href = originalUrl; + } else { + const container = document.querySelector('.lock-container'); + container.textContent = ''; + + const header = document.createElement('div'); + header.className = 'header'; + const h1 = document.createElement('h1'); + h1.textContent = 'Override accepted'; + const p = document.createElement('p'); + p.textContent = 'You may continue your session.'; + header.append(h1, p); + container.appendChild(header); + + const hint = document.createElement('p'); + hint.className = 'navigate-hint'; + hint.textContent = 'Navigate away from this page when ready.'; + container.appendChild(hint); + } + }); + }); + } + }); + }); +}); diff --git a/legacy-extension-source/llm-backoff.js b/legacy-extension-source/llm-backoff.js new file mode 100644 index 0000000..add5e7d --- /dev/null +++ b/legacy-extension-source/llm-backoff.js @@ -0,0 +1,39 @@ +// llm-backoff.js — Pause LLM calls after quota/rate-limit errors + +export const DEFAULT_QUOTA_BACKOFF_MS = 30 * 60 * 1000; + +let quotaBackoffUntil = 0; +let lastQuotaLogAt = 0; + +export function isLlmBackedOff(now = Date.now()) { + return now < quotaBackoffUntil; +} + +export function getQuotaBackoffUntil() { + return quotaBackoffUntil; +} + +export function clearLlmBackoff() { + quotaBackoffUntil = 0; + lastQuotaLogAt = 0; +} + +export function parseRetryAfterMs(bodyText = '', now = Date.now()) { + const match = String(bodyText).match(/retry in ([\d.]+)s/i); + if (match) { + return Math.ceil(parseFloat(match[1]) * 1000); + } + return DEFAULT_QUOTA_BACKOFF_MS; +} + +export function setQuotaBackoff({ retryAfterMs = DEFAULT_QUOTA_BACKOFF_MS, now = Date.now() } = {}) { + quotaBackoffUntil = Math.max(quotaBackoffUntil, now + retryAfterMs); +} + +export function shouldLogQuotaError(now = Date.now()) { + if (lastQuotaLogAt > 0 && now - lastQuotaLogAt < DEFAULT_QUOTA_BACKOFF_MS) { + return false; + } + lastQuotaLogAt = now; + return true; +} \ No newline at end of file diff --git a/legacy-extension-source/llm.js b/legacy-extension-source/llm.js new file mode 100644 index 0000000..10db28b --- /dev/null +++ b/legacy-extension-source/llm.js @@ -0,0 +1,149 @@ +// llm.js + +import { + chatCompletion, + cleanJsonString, + getLlmConfig, + isLlmConfigured, +} from './providers.js'; +import { logError, ERROR_TYPES } from './error-log.js'; +import { + buildDriftCacheKey, + getCachedDrift, + setCachedDrift, +} from './drift-cache.js'; + +/** + * Evaluate if a given URL + History matches the stated intent. + * @param {string} intent User's stated aim + * @param {string} url Current URL + * @param {Array} history Recent events + * @returns {Promise<{ isAligned: boolean, confidence: number }>} + */ +async function checkDriftLLM(intent, url, history) { + const config = await getLlmConfig(); + if (!isLlmConfigured(config)) { + return { isAligned: true, confidence: 1.0 }; + } + + const cacheKey = buildDriftCacheKey(intent, url, history); + const cached = getCachedDrift(cacheKey); + if (cached) { + return cached; + } + + const recentHistory = Array.isArray(history) ? history.slice(-5) : []; + const historySummary = recentHistory + .map((event) => `${event.actionType}: ${event.url || 'n/a'}`) + .join('; '); + + const prompt = ` + You are IntentLock, an AI that enforces behavioral constraints. + User's explicitly declared intent for this browsing session: "${intent}" + User is currently on: ${url} + Recent browsing events: ${historySummary || 'none'} + + Rule: Is the user Aligned with their intent, or Drifting? + Respond ONLY in strict JSON format: {"aligned": boolean, "confidence": number} + Do not add any additional text. + `; + + try { + const result = await chatCompletion(prompt, { + jsonMode: true, + maxTokens: 50, + temperature: 0.1, + }); + + if (!result.ok) { + if (result.error?.code === 'quota_backoff' || result.error?.code === 'quota_exceeded') { + return { isAligned: true, confidence: 0, llmSkipped: result.error.code }; + } + return { isAligned: true, confidence: 0 }; + } + + const parsed = JSON.parse(cleanJsonString(result.text)); + + if (!parsed || typeof parsed.aligned !== 'boolean' || typeof parsed.confidence !== 'number') { + await logError({ + type: ERROR_TYPES.API, + message: 'LLM drift check returned an unexpected response shape.', + details: { providerId: config.providerId }, + source: 'checkDriftLLM', + }); + return { isAligned: true, confidence: 0 }; + } + + const driftResult = { + isAligned: parsed.aligned, + confidence: parsed.confidence, + }; + setCachedDrift(cacheKey, driftResult); + return driftResult; + } catch (error) { + await logError({ + type: ERROR_TYPES.API, + message: 'LLM drift check failed to parse response.', + details: { providerId: config.providerId, error: error.message }, + source: 'checkDriftLLM', + }); + return { isAligned: true, confidence: 0 }; + } +} + +/** + * Generate a 3-step plan based on the user's intent to set expectations. + * @param {string} intent User's stated aim + * @returns {Promise<{ steps: string[], error: object|null }>} + */ +async function generateIntentPlan(intent) { + const config = await getLlmConfig(); + if (!isLlmConfigured(config)) { + return { steps: [], error: null }; + } + + const prompt = ` + The user declared the following intent for their browsing session: "${intent}" + Create a very concise, practical 3-step checklist for them to accomplish this. + Respond ONLY in strict JSON format: {"steps": ["Step 1", "Step 2", "Step 3"]} + `; + + try { + const result = await chatCompletion(prompt, { + jsonMode: true, + maxTokens: 100, + temperature: 0.3, + }); + + if (!result.ok) { + return { steps: [], error: result.error }; + } + + const parsed = JSON.parse(cleanJsonString(result.text)); + let steps = []; + if (parsed && Array.isArray(parsed.steps)) { + steps = parsed.steps; + } else if (Array.isArray(parsed)) { + steps = parsed; + } else { + await logError({ + type: ERROR_TYPES.API, + message: 'Plan generation returned an unexpected response format.', + details: { providerId: config.providerId }, + source: 'generateIntentPlan', + }); + return { steps: [], error: { code: 'invalid_response', message: 'Plan generation returned an unexpected format.' } }; + } + return { steps: steps.filter((step) => typeof step === 'string').slice(0, 3), error: null }; + } catch (err) { + await logError({ + type: ERROR_TYPES.API, + message: 'Plan generation failed to parse response.', + details: { providerId: config.providerId, error: err.message }, + source: 'generateIntentPlan', + }); + return { steps: [], error: { code: 'parse_error', message: 'Plan generation failed to parse response.' } }; + } +} + +export { checkDriftLLM, generateIntentPlan, cleanJsonString }; \ No newline at end of file diff --git a/legacy-extension-source/manifest.json b/legacy-extension-source/manifest.json new file mode 100644 index 0000000..b3e98af --- /dev/null +++ b/legacy-extension-source/manifest.json @@ -0,0 +1,56 @@ +{ + "manifest_version": 3, + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self';" + }, + "name": "IntentLock", + "version": "1.4.1", + "description": "Enforces user-declared intent during browsing sessions.", + "icons": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + }, + "chrome_url_overrides": { + "newtab": "newtab.html" + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "permissions": [ + "tabs", + "storage", + "idle", + "tabGroups" + ], + "host_permissions": [ + "https://*/*", + "http://*/*" + ], + "content_scripts": [ + { + "matches": ["https://*/*", "http://*/*"], + "js": ["content.js"], + "type": "module", + "run_at": "document_idle" + } + ], + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, + "action": { + "default_title": "IntentLock Controls", + "default_popup": "popup.html" + }, + "commands": { + "toggle-session": { + "suggested_key": { + "default": "Ctrl+Shift+L", + "mac": "Command+Shift+L" + }, + "description": "Start new session or end current session" + } + } +} diff --git a/legacy-extension-source/newtab.css b/legacy-extension-source/newtab.css new file mode 100644 index 0000000..cbd5bb8 --- /dev/null +++ b/legacy-extension-source/newtab.css @@ -0,0 +1,1396 @@ +/* ========================================================================== + IntentLock Design System + Inspired by Visualize Value — stark, minimal, black & white. + System theme aware: dark by default, flips on prefers-color-scheme: light. + ========================================================================== */ + +:root { + --bg: #000000; + --bg-elevated: #0a0a0a; + --bg-surface: #111111; + --text-primary: #ffffff; + --text-secondary: #888888; + --text-muted: #9a9a9a; + --border: #222222; + --border-focus: #ffffff; + --accent: #ffffff; + --error: #ff5555; + --radius: 2px; + --font: "SF Mono", "Fira Code", "JetBrains Mono", ui-monospace, monospace; + --transition: 120ms cubic-bezier(0.2, 0, 0, 1); +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #ffffff; + --bg-elevated: #fafafa; + --bg-surface: #f0f0f0; + --text-primary: #000000; + --text-secondary: #666666; + --text-muted: #767676; + --border: #e0e0e0; + --border-focus: #000000; + --accent: #000000; + --error: #c41e3a; + } +} + +/* Manual theme overrides */ +:root.theme-dark { + --bg: #000000; + --bg-elevated: #0a0a0a; + --bg-surface: #111111; + --text-primary: #ffffff; + --text-secondary: #888888; + --text-muted: #9a9a9a; + --border: #222222; + --border-focus: #ffffff; + --accent: #ffffff; + --error: #ff5555; +} + +:root.theme-light { + --bg: #ffffff; + --bg-elevated: #fafafa; + --bg-surface: #f0f0f0; + --text-primary: #000000; + --text-secondary: #666666; + --text-muted: #767676; + --border: #e0e0e0; + --border-focus: #000000; + --accent: #000000; + --error: #c41e3a; +} + +/* Reset */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font); + background-color: var(--bg); + color: var(--text-primary); + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + height: auto; + margin: 0; + overflow-x: hidden; + overflow-y: auto; + padding: 24px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Container */ +.lock-container { + padding: 48px; + width: 100%; + max-width: 480px; + animation: fadeIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Header */ +.header { + text-align: center; + margin-bottom: 40px; +} + +.header h1 { + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--text-primary); + margin-bottom: 16px; +} + +.header p { + color: var(--text-secondary); + font-size: 0.8rem; + letter-spacing: 0.05em; + line-height: 1.6; +} + +/* Divider line — VV geometric element */ +.header::after { + content: ""; + display: block; + width: 32px; + height: 1px; + background: var(--text-muted); + margin: 24px auto 0; +} + +/* Form */ +.input-group { + margin-bottom: 24px; +} + +label { + display: block; + font-size: 0.65rem; + font-weight: 500; + color: var(--text-muted); + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.2em; +} + +select { + width: 100%; + background-color: transparent; + border: 1px solid var(--border); + color: var(--text-primary); + padding: 14px; + border-radius: var(--radius); + font-size: 0.85rem; + font-family: var(--font); + transition: border-color var(--transition); +} + +select:focus { + outline: none; + border-color: var(--border-focus); +} + +.field-hint { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 6px; + line-height: 1.4; +} + +.field-error { + font-size: 0.75rem; + color: var(--error); + margin-top: 6px; + line-height: 1.4; +} + +textarea[aria-invalid="true"], +input[aria-invalid="true"] { + border-color: var(--error); +} + +.provider-advanced { + margin-bottom: 8px; +} + +.disclosure { + margin-bottom: 8px; +} + +.disclosure-toggle { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 44px; + padding: 10px 14px; + background-color: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.7rem; + font-weight: 500; + font-family: var(--font); + text-transform: uppercase; + letter-spacing: 0.15em; + cursor: pointer; + transition: border-color var(--transition), color var(--transition); +} + +.disclosure-toggle:hover { + color: var(--text-primary); + border-color: var(--text-primary); + background-color: transparent; + opacity: 1; +} + +.disclosure-toggle::after { + content: ''; + width: 7px; + height: 7px; + border-right: 1px solid currentColor; + border-bottom: 1px solid currentColor; + transform: rotate(45deg); + transition: transform var(--transition); + flex-shrink: 0; + margin-left: 12px; +} + +.disclosure-toggle[aria-expanded="true"]::after { + transform: rotate(-135deg); +} + +#provider-model-fields.hidden { + display: none; +} + +textarea, +input[type="number"], +input[type="password"], +input[type="text"] { + width: 100%; + background-color: transparent; + border: 1px solid var(--border); + color: var(--text-primary); + padding: 14px; + border-radius: var(--radius); + font-size: 0.85rem; + font-family: var(--font); + transition: border-color var(--transition); + letter-spacing: 0.02em; + line-height: 1.6; +} + +textarea { + min-height: 100px; + resize: vertical; +} + +textarea:focus, +input[type="number"]:focus, +input[type="password"]:focus, +input[type="text"]:focus { + outline: none; + border-color: var(--border-focus); +} + +textarea::placeholder, +input::placeholder { + color: var(--text-muted); + font-family: var(--font); + letter-spacing: 0.02em; +} + +/* Buttons */ +button { + width: 100%; + background-color: var(--text-primary); + color: var(--bg); + border: 1px solid var(--text-primary); + padding: 14px; + border-radius: var(--radius); + font-size: 0.75rem; + font-weight: 500; + font-family: var(--font); + cursor: pointer; + transition: opacity var(--transition), transform var(--transition); + text-transform: uppercase; + letter-spacing: 0.2em; +} + +button:hover { + background-color: var(--bg); + color: var(--text-primary); + border-color: var(--text-primary); + opacity: 1; +} + +button:active { + opacity: 0.6; + transform: scale(0.97); +} + +button:focus-visible, +a:focus-visible, +textarea:focus-visible, +input:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 3px; +} + +button:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* Hidden utility */ +.hidden { + display: none; +} + +/* Status message */ +#status-message { + margin-top: 20px; + text-align: left; + font-size: 0.75rem; + color: var(--text-primary); + letter-spacing: 0.05em; + border-left: 2px solid var(--text-primary); + padding-left: 12px; +} + +/* ========================================================================== + Active session state + ========================================================================== */ + +.intent-active-label { + color: var(--text-secondary); + font-size: 0.65rem; + font-weight: 500; + letter-spacing: 0.3em; + text-transform: uppercase; +} + +.intent-display { + border: 1px solid var(--border); + padding: 20px; + border-radius: var(--radius); + margin-bottom: 24px; +} + +.intent-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.intent-text { + font-size: 0.85rem; + line-height: 1.8; + color: var(--text-primary); + text-align: center; + font-style: italic; + letter-spacing: 0.02em; + flex: 1; +} + +.edit-intent-btn { + background: transparent; + border: none; + color: var(--text-muted); + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.1em; + cursor: pointer; + min-height: 40px; + padding: 10px 12px; + width: auto; + flex-shrink: 0; +} + +.edit-intent-btn:hover { + color: var(--text-primary); + opacity: 1; +} + +.edit-intent-textarea { + width: 100%; + background-color: transparent; + border: 1px solid var(--border); + color: var(--text-primary); + padding: 14px; + border-radius: var(--radius); + font-size: 0.85rem; + font-family: var(--font); + resize: vertical; + min-height: 80px; + margin-bottom: 20px; +} + +.edit-intent-textarea:focus { + outline: none; + border-color: var(--border-focus); +} + +/* Plan section */ +.plan-section { + text-align: left; + border-top: 1px solid var(--border); + padding-top: 20px; + margin-bottom: 24px; +} + +.plan-heading { + font-size: 0.65rem; + color: var(--text-muted); + margin-bottom: 16px; + text-transform: uppercase; + letter-spacing: 0.2em; + font-weight: 500; +} + +.plan-list { + list-style-type: none; + padding: 0; +} + +.plan-step { + margin-bottom: 12px; + font-size: 0.8rem; + color: var(--text-secondary); + line-height: 1.6; + padding-left: 20px; + position: relative; +} + +.plan-step::before { + content: ""; + position: absolute; + left: 0; + top: 8px; + width: 6px; + height: 6px; + border: 1px solid var(--text-muted); + border-radius: 0; +} + +/* Navigate hint */ +.navigate-hint { + text-align: center; + color: var(--text-muted); + font-size: 0.7rem; + margin-bottom: 20px; + letter-spacing: 0.1em; +} + +/* Complete/secondary button */ +.complete-btn { + background-color: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); + font-size: 0.7rem; + padding: 12px; +} + +.complete-btn:hover { + background-color: var(--text-primary); + color: var(--bg); + border-color: var(--text-primary); + opacity: 1; +} + +/* Session actions container */ +.session-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Override accepted state */ +.override-accepted { + color: var(--text-primary) !important; +} + +/* ========================================================================== + Intervention page overrides + ========================================================================== */ + +body.intervention { + background-color: var(--bg); +} + +body.intervention::before { + content: ""; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(255, 255, 255, 0.015) 2px, + rgba(255, 255, 255, 0.015) 4px + ); + pointer-events: none; + z-index: 999; +} + +@media (prefers-color-scheme: light) { + body.intervention::before { + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.02) 2px, + rgba(0, 0, 0, 0.02) 4px + ); + } +} + +:root.theme-light body.intervention::before { + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.02) 2px, + rgba(0, 0, 0, 0.02) 4px + ); +} + +body.intervention .lock-container { + border-left: 2px solid var(--text-primary); + padding-left: 46px; +} + +body.intervention .header h1 { + text-align: left; + font-size: 0.85rem; + letter-spacing: 0.15em; +} + +body.intervention .header p { + text-align: left; +} + +body.intervention .header::after { + margin: 24px 0 0; +} + +.intervention-intent { + border: 1px solid var(--border); + padding: 16px; + border-radius: var(--radius); + margin-bottom: 24px; +} + +.intervention-intent-label { + font-size: 0.6rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.2em; + margin-bottom: 8px; +} + +.intervention-intent-text { + font-size: 0.85rem; + color: var(--text-primary); + line-height: 1.6; +} + +/* Primary action (close tab) */ +.primary-btn { + background-color: var(--text-primary); + color: var(--bg); + border: 1px solid var(--text-primary); +} + +/* Override button */ +.override-btn { + background-color: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); + margin-top: 8px; +} + +.override-btn:hover { + color: var(--text-primary); + border-color: var(--text-primary); + opacity: 1; +} + +/* ========================================================================== + Options page overrides + ========================================================================== */ + +body.options { + height: auto; + padding: 60px 24px; + display: flex; + justify-content: center; + align-items: flex-start; + overflow: auto; +} + +.options-container { + width: 100%; + max-width: 520px; +} + +.section { + border: 1px solid var(--border); + padding: 32px; + border-radius: var(--radius); + margin-bottom: 24px; +} + +.section h2 { + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--text-primary); + margin-bottom: 16px; +} + +.section p { + color: var(--text-secondary); + font-size: 0.75rem; + line-height: 1.6; + margin-bottom: 16px; +} + +.toggle-row { + display: flex; + align-items: center; + margin-bottom: 20px; +} + +.tracking-toggle-label { + display: inline-flex; + align-items: center; + gap: 12px; + margin: 0; + cursor: pointer; +} + +.tracking-toggle-hit { + display: flex; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 44px; + flex-shrink: 0; +} + +.tracking-toggle-hit input[type="checkbox"] { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--text-primary); +} + +.tracking-toggle-text { + font-size: 0.75rem; + text-transform: none; + letter-spacing: 0.02em; + color: var(--text-secondary); +} + +.danger-btn { + background-color: transparent; + color: var(--text-primary); + border: 1px solid var(--text-primary); +} + +.danger-btn:hover { + background-color: var(--text-primary); + color: var(--bg); +} + +.status-feedback { + margin-top: 12px; + font-size: 0.7rem; + color: var(--text-secondary); + letter-spacing: 0.05em; +} + +/* ========================================================================== + Popup overrides + ========================================================================== */ + +body.popup { + width: 300px; + height: auto; + min-height: 120px; + padding: 24px; + display: block; + overflow: auto; +} + +body.popup .lock-container { + padding: 0; + animation: none; +} + +body.popup .header { + margin-bottom: 16px; +} + +body.popup .header::after { + margin-top: 16px; +} + +.session-intent { + border: 1px solid var(--border); + padding: 12px; + border-radius: var(--radius); + margin-bottom: 12px; + font-size: 0.8rem; + line-height: 1.5; + color: var(--text-primary); + font-style: italic; +} + +.time-remaining { + color: var(--text-secondary); + font-size: 0.7rem; + margin-bottom: 12px; + text-align: center; + letter-spacing: 0.1em; + font-variant-numeric: tabular-nums; +} + +.time-exceeded { + color: var(--text-primary); + font-weight: 500; +} + +.no-session { + text-align: center; + color: var(--text-muted); + font-size: 0.75rem; + letter-spacing: 0.05em; + line-height: 1.8; +} + +/* ========================================================================== + Session timer + ========================================================================== */ + +.session-timer { + text-align: center; + margin-bottom: 24px; + padding: 16px 0; +} + +.timer-label { + display: block; + font-size: 0.6rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.3em; + margin-bottom: 8px; +} + +.timer-value { + display: block; + font-size: 2rem; + font-weight: 300; + color: var(--text-primary); + letter-spacing: 0.1em; + font-variant-numeric: tabular-nums; +} + +@keyframes borderPulse { + 0%, 100% { border-color: var(--text-primary); } + 50% { border-color: var(--text-muted); } +} + +.timer-exceeded { + border: 1px solid var(--text-primary); + padding: 16px; +} + +.timer-exceeded .timer-value { + opacity: 1; +} + +.timer-exceeded .timer-label { + color: var(--text-primary); + font-weight: 500; +} + +@media (prefers-reduced-motion: no-preference) { + .timer-exceeded { + animation: borderPulse 2s ease-in-out infinite; + } +} + +/* ========================================================================== + Session summary stats + ========================================================================== */ + +.summary-stats { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 24px; +} + +.stat-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; +} + +.stat-row + .stat-row { + border-top: 1px solid var(--border); +} + +.stat-label { + font-size: 0.75rem; + color: var(--text-secondary); + letter-spacing: 0.05em; +} + +.stat-value { + font-size: 0.75rem; + color: var(--text-primary); + font-weight: 500; + letter-spacing: 0.05em; + font-variant-numeric: tabular-nums; +} + +/* ========================================================================== + Reflection text + ========================================================================== */ + +.reflection-text { + font-size: 0.8rem; + color: var(--text-secondary); + font-style: italic; + line-height: 1.6; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} + +.reflection-text:last-child { + border-bottom: none; +} + +/* Keyboard shortcuts button */ +.shortcuts-btn { + position: fixed; + bottom: 20px; + right: 20px; + width: 40px; + height: 40px; + background-color: transparent; + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: 50%; + font-size: 0.75rem; + cursor: pointer; + transition: all var(--transition); + padding: 0; +} + +.shortcuts-btn:hover { + color: var(--text-primary); + border-color: var(--text-primary); + opacity: 1; +} + +/* ========================================================================== + History page + ========================================================================== */ + +.history-card { + cursor: default; + border-left: 3px solid transparent; +} + +.history-header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.history-intent { + font-size: 0.85rem; + color: var(--text-primary); + line-height: 1.6; + margin-bottom: 8px; + flex: 1; +} + +.history-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + font-size: 0.7rem; + color: var(--text-muted); + letter-spacing: 0.05em; +} + +.history-sep { + color: var(--text-muted); +} + +/* ========================================================================== + Popup footer + ========================================================================== */ + +.popup-footer { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.popup-link { + color: var(--text-muted); + font-size: 0.65rem; + text-decoration: none; + text-transform: uppercase; + letter-spacing: 0.15em; + transition: color var(--transition); +} + +.popup-link:hover { + color: var(--text-primary); +} + +/* API key notice */ +.api-notice { + border: 1px solid var(--border); + padding: 16px; + margin-bottom: 24px; + text-align: center; +} + +.api-notice p { + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 12px; + line-height: 1.6; +} + +.api-notice button { + width: auto; + padding: 8px 16px; +} + +/* ========================================================================== + Confirmation dialog + ========================================================================== */ + +.confirm-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + animation: fadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); + transition: opacity 200ms cubic-bezier(0.2, 0, 0, 1); +} + +.confirm-overlay.closing { + opacity: 0; +} + +.confirm-dialog { + background-color: var(--bg-elevated); + border: 1px solid var(--border); + padding: 32px; + max-width: 360px; + width: 90%; + animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes slideUp { + from { transform: translateY(20px); opacity: 0; filter: blur(4px); } + to { transform: translateY(0); opacity: 1; filter: blur(0px); } +} + +.confirm-dialog h3 { + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--text-primary); + margin-bottom: 12px; +} + +.confirm-dialog p { + font-size: 0.8rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 24px; +} + +.confirm-actions { + display: flex; + gap: 12px; +} + +.confirm-actions button { + flex: 1; +} + +/* ========================================================================== + Session stats overlay + ========================================================================== */ + +.session-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1px; + background-color: var(--border); + border: 1px solid var(--border); + margin-bottom: 24px; +} + +.stat-box { + background-color: var(--bg-elevated); + padding: 16px 12px; + text-align: center; +} + +.stat-box .stat-value { + font-size: 1.2rem; + font-weight: 300; + color: var(--text-primary); + letter-spacing: 0.05em; + margin-bottom: 4px; + font-variant-numeric: tabular-nums; +} + +.stat-box .stat-label { + font-size: 0.6rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.15em; +} + +/* ========================================================================== + Keyboard shortcuts modal + ========================================================================== */ + +.shortcuts-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + animation: fadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); + transition: opacity 200ms cubic-bezier(0.2, 0, 0, 1); +} + +.shortcuts-modal.closing { + opacity: 0; +} + +.shortcuts-content { + background-color: var(--bg-elevated); + border: 1px solid var(--border); + padding: 32px; + max-width: 400px; + width: 90%; + animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +.shortcuts-content h3 { + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--text-primary); + margin-bottom: 20px; +} + +.shortcut-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} + +.shortcut-row:last-child { + border-bottom: none; +} + +.shortcut-keys { + display: flex; + gap: 4px; +} + +.shortcut-key { + background-color: var(--bg-surface); + border: 1px solid var(--border); + padding: 4px 8px; + font-size: 0.7rem; + color: var(--text-primary); + border-radius: 2px; +} + +.shortcut-desc { + font-size: 0.75rem; + color: var(--text-secondary); +} + +/* ========================================================================== + Theme toggle + ========================================================================== */ + +.theme-toggle { + display: flex; + gap: 4px; + background-color: var(--bg-surface); + border: 1px solid var(--border); + padding: 4px; + border-radius: var(--radius); +} + +.theme-btn { + background-color: transparent; + color: var(--text-muted); + border: none; + padding: 8px 12px; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.1em; + cursor: pointer; + transition: all var(--transition); + min-height: 40px; + width: auto; +} + +.theme-btn:hover { + color: var(--text-secondary); + opacity: 1; +} + +.theme-btn.active { + background-color: var(--text-primary); + color: var(--bg); +} + +/* ========================================================================== + History filters + ========================================================================== */ + +.history-controls { + margin-bottom: 24px; +} + +.search-container { + margin-bottom: 16px; +} + +.search-input { + width: 100%; + background-color: transparent; + border: 1px solid var(--border); + color: var(--text-primary); + padding: 12px 14px; + border-radius: var(--radius); + font-size: 0.8rem; + font-family: var(--font); + transition: border-color var(--transition); + letter-spacing: 0.02em; +} + +.search-input:focus { + outline: none; + border-color: var(--border-focus); +} + +.search-input::placeholder { + color: var(--text-muted); +} + +.history-filters { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.filter-btn { + background-color: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + padding: 8px 16px; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.1em; + cursor: pointer; + transition: all var(--transition); + min-height: 40px; + width: auto; +} + +.filter-btn:hover { + border-color: var(--text-primary); + color: var(--text-primary); + opacity: 1; +} + +.filter-btn.active { + border-color: var(--text-primary); + color: var(--text-primary); +} + +/* ========================================================================== + Animation + ========================================================================== */ + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(6px); filter: blur(4px); } + to { opacity: 1; transform: translateY(0); filter: blur(0px); } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +@media (max-width: 520px) { + body { + align-items: flex-start; + padding: 20px; + } + + .lock-container { + padding: 24px 0; + } + + .header { + margin-bottom: 32px; + } + + .section { + padding: 24px; + } + + .confirm-actions, + .theme-toggle { + flex-direction: column; + } +} + +/* ========================================================================== + Onboarding Wizard + ========================================================================== */ + +.onboarding-header h1 { + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--text-primary); + margin-bottom: 16px; +} + +.onboarding-header p { + color: var(--text-secondary); + font-size: 0.8rem; + letter-spacing: 0.05em; + line-height: 1.6; +} + +.onboarding-input-group { + margin-top: 32px; + margin-bottom: 16px; +} + +.security-notice { + font-size: 0.7rem; + color: var(--text-muted); + line-height: 1.5; + margin-top: 16px; + margin-bottom: 24px; + letter-spacing: 0.02em; +} + +.onboarding-status { + font-size: 0.75rem; + color: var(--text-secondary); + line-height: 1.5; + margin-bottom: 16px; + text-align: center; +} + +.onboarding-status-error { + color: var(--error); +} + +.monitoring-hint { + font-size: 0.7rem; + color: var(--text-muted); + line-height: 1.5; + text-align: center; + margin-bottom: 24px; + letter-spacing: 0.02em; +} + +.onboarding-actions { + display: flex; + gap: 12px; + margin-top: 24px; +} + +.onboarding-actions button { + flex: 1; +} + +.onboarding-btn { + margin-top: 32px; +} + +.privacy-note { + margin-bottom: 16px; +} + +.diagnostics-actions { + display: flex; + gap: 12px; + margin-bottom: 16px; +} + +.diagnostics-actions button { + width: auto; + flex: 1; +} + +.diagnostics-filters { + margin-bottom: 20px; +} + +.diagnostics-entry { + border: 1px solid var(--border); + border-left: 2px solid var(--error); + padding: 16px; + margin-bottom: 12px; + border-radius: var(--radius); +} + +.diagnostics-meta { + font-size: 0.65rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 8px; +} + +.diagnostics-message { + font-size: 0.8rem; + line-height: 1.6; + color: var(--text-primary); + margin-bottom: 8px; +} + +.diagnostics-details { + font-size: 0.7rem; + color: var(--text-secondary); + background: var(--bg-surface); + border: 1px solid var(--border); + padding: 12px; + border-radius: var(--radius); + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + diff --git a/legacy-extension-source/newtab.html b/legacy-extension-source/newtab.html new file mode 100644 index 0000000..ce6b6b6 --- /dev/null +++ b/legacy-extension-source/newtab.html @@ -0,0 +1,35 @@ + + + + + + IntentLock + + + +
+
+

IntentLock

+

Declare what you intend to do.

+
+ +
+
+ + +
+ +
+ + +
+ + +
+ + +
+ + + + diff --git a/legacy-extension-source/newtab.js b/legacy-extension-source/newtab.js new file mode 100644 index 0000000..7d0f3ab --- /dev/null +++ b/legacy-extension-source/newtab.js @@ -0,0 +1,1043 @@ +import { generateIntentPlan } from './llm.js'; +import { + PROVIDER_LIST, + DEFAULT_PROVIDER_ID, + getProvider, + getDefaultProviderConfig, + getLlmConfig, + isLlmConfigured, + validateApiKey, +} from './providers.js'; +import { logError, ERROR_TYPES } from './error-log.js'; + +document.addEventListener('DOMContentLoaded', () => { + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + function setFieldError(field, message, hintId) { + let errorEl = field._errorEl; + if (!errorEl) { + errorEl = document.createElement('p'); + errorEl.className = 'field-error'; + errorEl.id = `${field.id}-error`; + errorEl.setAttribute('role', 'alert'); + field.parentNode.appendChild(errorEl); + field._errorEl = errorEl; + } + errorEl.textContent = message; + field.setAttribute('aria-invalid', 'true'); + const describedBy = [hintId, errorEl.id].filter(Boolean).join(' '); + field.setAttribute('aria-describedby', describedBy); + field.focus(); + } + + function clearFieldError(field, hintId) { + if (field._errorEl) { + field._errorEl.textContent = ''; + } + field.removeAttribute('aria-invalid'); + if (hintId) { + field.setAttribute('aria-describedby', hintId); + } else { + field.removeAttribute('aria-describedby'); + } + } + + function closeOverlay(overlay, callback) { + let done = false; + function finish() { + if (done) return; + done = true; + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + if (callback) callback(); + } + if (reducedMotion) { + finish(); + return; + } + overlay.classList.add('closing'); + overlay.addEventListener('transitionend', function handler(e) { + if (e.target !== overlay) return; + overlay.removeEventListener('transitionend', handler); + finish(); + }); + // Fallback in case transitionend doesn't fire + setTimeout(finish, 250); + } + + function setupModalDialog({ overlay, dialog, heading, trigger }) { + const previousFocus = trigger || document.activeElement; + const headingId = `modal-title-${crypto.randomUUID()}`; + heading.id = headingId; + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + dialog.setAttribute('aria-labelledby', headingId); + + const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + + function getFocusableElements() { + return Array.from(dialog.querySelectorAll(FOCUSABLE_SELECTOR)) + .filter((el) => el.offsetParent !== null || el === document.activeElement); + } + + function restoreFocus() { + if (previousFocus && typeof previousFocus.focus === 'function') { + previousFocus.focus(); + } + } + + function handleKeydown(e) { + if (e.key === 'Escape') { + e.preventDefault(); + closeModal(); + return; + } + if (e.key !== 'Tab') return; + + const focusable = getFocusableElements(); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + + function teardown() { + document.removeEventListener('keydown', handleKeydown); + } + + function closeModal(callback) { + teardown(); + closeOverlay(overlay, () => { + restoreFocus(); + if (callback) callback(); + }); + } + + document.addEventListener('keydown', handleKeydown); + + const focusable = getFocusableElements(); + if (focusable.length > 0) { + focusable[0].focus(); + } else { + dialog.setAttribute('tabindex', '-1'); + dialog.focus(); + } + + return { closeModal }; + } + + let timerInterval = null; + + chrome.storage.local.get(['activeSession', 'hasSeenOnboarding'], (result) => { + if (result.activeSession && result.activeSession.isActive) { + showActiveState(result.activeSession); + } else if (!result.hasSeenOnboarding) { + showOnboardingWizard(document.querySelector('.lock-container')); + } else { + showNewSessionForm(document.querySelector('.lock-container')); + } + }); + + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName === 'local' && changes.activeSession) { + const session = changes.activeSession.newValue; + if (session && session.isActive) { + showActiveState(session); + } else { + if (timerInterval) { + clearInterval(timerInterval); + timerInterval = null; + } + document.querySelectorAll('.confirm-overlay').forEach(el => el.remove()); + showNewSessionForm(document.querySelector('.lock-container')); + } + } + }); + + // ── Live session timer ────────────────────────────────────────────── + + + function formatTime(ms) { + const totalSeconds = Math.floor(ms / 1000); + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = totalSeconds % 60; + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + return `${m}:${String(s).padStart(2, '0')}`; + } + + function createTimer(session, parent) { + const timerEl = document.createElement('div'); + timerEl.className = 'session-timer'; + + const timeLabel = document.createElement('span'); + timeLabel.className = 'timer-label'; + + const timeValue = document.createElement('span'); + timeValue.className = 'timer-value'; + + timerEl.append(timeLabel, timeValue); + parent.appendChild(timerEl); + + function getElapsed() { + return Date.now() - session.startTime; + } + + function tick() { + const elapsed = getElapsed(); + if (session.timeBudget) { + const budgetMs = session.timeBudget * 60000; + const remaining = budgetMs - elapsed; + if (remaining > 0) { + timeLabel.textContent = 'Remaining'; + timeValue.textContent = formatTime(remaining); + timerEl.classList.remove('timer-exceeded'); + } else { + timeLabel.textContent = 'Exceeded by'; + timeValue.textContent = formatTime(Math.abs(remaining)); + timerEl.classList.add('timer-exceeded'); + } + } else { + timeLabel.textContent = 'Elapsed'; + timeValue.textContent = formatTime(elapsed); + } + } + + tick(); + timerInterval = setInterval(tick, 1000); + return timerEl; + } + + function createHistoryEntry(session) { + const events = Array.isArray(session.events) ? session.events : []; + return { + id: session.id, + intent: session.intent, + startTime: session.startTime, + endTime: session.endTime, + timeBudget: session.timeBudget, + driftCount: events.filter(e => e.actionType === 'OVERRIDE').length, + totalEvents: events.length + }; + } + + // ── Active session state ──────────────────────────────────────────── + + function showActiveState(session) { + if (timerInterval) clearInterval(timerInterval); + const container = document.querySelector('.lock-container'); + container.textContent = ''; + + // Header + const header = document.createElement('div'); + header.className = 'header'; + const h1 = document.createElement('h1'); + h1.textContent = 'Session locked'; + const label = document.createElement('p'); + label.className = 'intent-active-label'; + label.textContent = 'Intent active'; + header.append(h1, label); + container.appendChild(header); + + // Timer + createTimer(session, container); + + // Real-time stats + const statsDiv = document.createElement('div'); + statsDiv.className = 'session-stats'; + + const events = Array.isArray(session.events) ? session.events : []; + const pageLoads = events.filter(e => e.actionType === 'PAGE_LOAD').length; + const tabSwitches = events.filter(e => e.actionType === 'TAB_SWITCH').length; + const drifts = events.filter(e => e.actionType === 'OVERRIDE').length; + + const stats = [ + { value: String(pageLoads), label: 'Pages' }, + { value: String(tabSwitches), label: 'Switches' }, + { value: String(drifts), label: 'Drifts' } + ]; + + stats.forEach(stat => { + const box = document.createElement('div'); + box.className = 'stat-box'; + const value = document.createElement('div'); + value.className = 'stat-value'; + value.textContent = stat.value; + const label = document.createElement('div'); + label.className = 'stat-label'; + label.textContent = stat.label; + box.append(value, label); + statsDiv.appendChild(box); + }); + + container.appendChild(statsDiv); + + const monitoringHint = document.createElement('p'); + monitoringHint.className = 'monitoring-hint'; + monitoringHint.textContent = 'Monitoring tab switches, page loads, dwell time, and SPA navigation. Drift triggers an in-page overlay or full-page intervention.'; + container.appendChild(monitoringHint); + + // Intent display + const intentBox = document.createElement('div'); + intentBox.className = 'intent-display'; + const intentHeader = document.createElement('div'); + intentHeader.className = 'intent-header'; + const intentText = document.createElement('p'); + intentText.className = 'intent-text'; + intentText.textContent = session.intent; + const editBtn = document.createElement('button'); + editBtn.className = 'edit-intent-btn'; + editBtn.textContent = 'Edit'; + editBtn.addEventListener('click', (e) => { + showEditIntentDialog(session, intentText, e.currentTarget); + }); + intentHeader.append(intentText, editBtn); + intentBox.appendChild(intentHeader); + container.appendChild(intentBox); + + // Plan + const plan = session.plan || []; + if (plan.length > 0) { + const planSection = document.createElement('div'); + planSection.className = 'plan-section'; + const planHeading = document.createElement('h3'); + planHeading.className = 'plan-heading'; + planHeading.textContent = 'Plan'; + planSection.appendChild(planHeading); + + const planList = document.createElement('ul'); + planList.className = 'plan-list'; + plan.forEach((step) => { + const li = document.createElement('li'); + li.className = 'plan-step'; + li.textContent = step; + planList.appendChild(li); + }); + planSection.appendChild(planList); + container.appendChild(planSection); + } + + // Hint + const hint = document.createElement('p'); + hint.className = 'navigate-hint'; + hint.textContent = 'Use the address bar to navigate.'; + container.appendChild(hint); + + // Action buttons + const actions = document.createElement('div'); + actions.className = 'session-actions'; + + // Complete button + const btn = document.createElement('button'); + btn.className = 'complete-btn'; + btn.textContent = 'Complete session'; + btn.addEventListener('click', (e) => showConfirmEndDialog(container, session, e.currentTarget)); + actions.appendChild(btn); + + container.appendChild(actions); + } + + // ── Confirmation dialog ───────────────────────────────────────────── + + function showConfirmEndDialog(container, session, trigger) { + const overlay = document.createElement('div'); + overlay.className = 'confirm-overlay'; + + const dialog = document.createElement('div'); + dialog.className = 'confirm-dialog'; + + const h3 = document.createElement('h3'); + h3.textContent = 'End session?'; + + const p = document.createElement('p'); + const elapsed = Math.round((Date.now() - session.startTime) / 60000); + const events = Array.isArray(session.events) ? session.events : []; + const drifts = events.filter(e => e.actionType === 'OVERRIDE').length; + p.textContent = `You've been working for ${elapsed} minutes with ${drifts} drift${drifts !== 1 ? 's' : ''}. Are you sure you want to end this session?`; + + const actions = document.createElement('div'); + actions.className = 'confirm-actions'; + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'complete-btn'; + cancelBtn.textContent = 'Cancel'; + + const confirmBtn = document.createElement('button'); + confirmBtn.textContent = 'End session'; + + actions.append(cancelBtn, confirmBtn); + dialog.append(h3, p, actions); + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + const { closeModal } = setupModalDialog({ overlay, dialog, heading: h3, trigger }); + + cancelBtn.addEventListener('click', () => closeModal()); + confirmBtn.addEventListener('click', () => closeModal(() => endSession(container, session))); + } + + function showEditIntentDialog(session, intentTextElement, trigger) { + const overlay = document.createElement('div'); + overlay.className = 'confirm-overlay'; + + const dialog = document.createElement('div'); + dialog.className = 'confirm-dialog'; + + const h3 = document.createElement('h3'); + h3.textContent = 'Edit intent'; + + const textarea = document.createElement('textarea'); + textarea.className = 'edit-intent-textarea'; + textarea.value = session.intent; + textarea.rows = 3; + textarea.maxLength = 250; + + const actions = document.createElement('div'); + actions.className = 'confirm-actions'; + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'complete-btn'; + cancelBtn.textContent = 'Cancel'; + + const saveBtn = document.createElement('button'); + saveBtn.textContent = 'Save'; + + actions.append(cancelBtn, saveBtn); + dialog.append(h3, textarea, actions); + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + const { closeModal } = setupModalDialog({ overlay, dialog, heading: h3, trigger }); + + cancelBtn.addEventListener('click', () => closeModal()); + saveBtn.addEventListener('click', () => { + const newIntent = textarea.value.trim(); + if (newIntent && newIntent !== session.intent) { + chrome.storage.local.get(['activeSession'], (result) => { + const currentSession = result.activeSession; + if (currentSession) { + currentSession.intent = newIntent; + chrome.storage.local.set({ activeSession: currentSession }); + } + }); + intentTextElement.textContent = newIntent; + } + closeModal(); + }); + } + + // ── Session summary ───────────────────────────────────────────────── + + function endSession(container, session) { + if (timerInterval) clearInterval(timerInterval); + + chrome.runtime.sendMessage({ type: 'END_ACTIVE_SESSION' }, (response) => { + chrome.runtime.sendMessage({ type: 'SESSION_CLEARED' }, () => { + const endedSession = (response && response.session) ? response.session : session; + showSummary(container, endedSession); + }); + }); + } + + function showSummary(container, session) { + container.textContent = ''; + + const header = document.createElement('div'); + header.className = 'header'; + const h1 = document.createElement('h1'); + h1.textContent = 'Session complete'; + header.appendChild(h1); + container.appendChild(header); + + // Stats + const stats = document.createElement('div'); + stats.className = 'summary-stats'; + + const events = Array.isArray(session.events) ? session.events : []; + const duration = Math.round((session.endTime - session.startTime) / 60000); + const drifts = events.filter(e => e.actionType === 'OVERRIDE').length; + const pageLoads = events.filter(e => e.actionType === 'PAGE_LOAD').length; + + const statItems = [ + { label: 'Duration', value: `${duration} min` }, + { label: 'Pages visited', value: String(pageLoads) }, + { label: 'Drift overrides', value: String(drifts) }, + ]; + + if (session.timeBudget) { + const diff = duration - session.timeBudget; + statItems.push({ + label: 'Budget', + value: diff <= 0 ? `${Math.abs(diff)} min under` : `${diff} min over` + }); + } + + statItems.forEach(item => { + const row = document.createElement('div'); + row.className = 'stat-row'; + const label = document.createElement('span'); + label.className = 'stat-label'; + label.textContent = item.label; + const value = document.createElement('span'); + value.className = 'stat-value'; + value.textContent = item.value; + row.append(label, value); + stats.appendChild(row); + }); + + container.appendChild(stats); + + // Intent recall + const intentBox = document.createElement('div'); + intentBox.className = 'intent-display'; + const intentText = document.createElement('p'); + intentText.className = 'intent-text'; + intentText.textContent = session.intent; + intentBox.appendChild(intentText); + container.appendChild(intentBox); + + // Override reflections (if any) + const overrides = events.filter(e => e.actionType === 'OVERRIDE' && e.reflection); + if (overrides.length > 0) { + const reflSection = document.createElement('div'); + reflSection.className = 'plan-section'; + const reflTitle = document.createElement('h3'); + reflTitle.className = 'plan-heading'; + reflTitle.textContent = 'Reflections'; + reflSection.appendChild(reflTitle); + + overrides.forEach(o => { + const p = document.createElement('p'); + p.className = 'reflection-text'; + p.textContent = o.reflection; + reflSection.appendChild(p); + }); + container.appendChild(reflSection); + } + + const skipBtn = document.createElement('button'); + skipBtn.className = 'complete-btn'; + skipBtn.textContent = 'Start new session'; + skipBtn.addEventListener('click', () => showNewSessionForm(container)); + container.appendChild(skipBtn); + } + + // ── Onboarding wizard ──────────────────────────────────────────────── + + function showOnboardingWizard(container) { + function showStep1() { + container.textContent = ''; + + const header = document.createElement('div'); + header.className = 'header onboarding-header'; + + const h1 = document.createElement('h1'); + h1.textContent = 'WELCOME TO INTENTLOCK'; + + const desc = document.createElement('p'); + desc.textContent = 'IntentLock is a minimalist tool designed to keep you focused. Before you start browsing, you declare your intent. If you drift off-task, the extension intervenes to help you stay aligned.'; + + header.append(h1, desc); + container.appendChild(header); + + const nextBtn = document.createElement('button'); + nextBtn.type = 'button'; + nextBtn.className = 'primary-btn onboarding-btn'; + nextBtn.textContent = 'NEXT'; + nextBtn.addEventListener('click', showStep2); + container.appendChild(nextBtn); + } + + function showStep2() { + container.textContent = ''; + + const header = document.createElement('div'); + header.className = 'header onboarding-header'; + + const h1 = document.createElement('h1'); + h1.textContent = 'ENABLE AI-POWERED ALIGNMENT'; + + const desc = document.createElement('p'); + desc.textContent = 'Choose a provider for semantic drift detection and plan generation. Use Gemini from Google AI Studio, a local Ollama/LM Studio server, or skip to use heuristics only.'; + + header.append(h1, desc); + container.appendChild(header); + + const providerGroup = document.createElement('div'); + providerGroup.className = 'input-group onboarding-input-group'; + + const providerLabel = document.createElement('label'); + providerLabel.setAttribute('for', 'onboarding-provider'); + providerLabel.textContent = 'Provider'; + + const providerSelect = document.createElement('select'); + providerSelect.id = 'onboarding-provider'; + PROVIDER_LIST.forEach((provider) => { + const option = document.createElement('option'); + option.value = provider.id; + option.textContent = provider.label; + providerSelect.appendChild(option); + }); + providerGroup.append(providerLabel, providerSelect); + container.appendChild(providerGroup); + + const inputGroup = document.createElement('div'); + inputGroup.className = 'input-group onboarding-input-group'; + inputGroup.id = 'onboarding-key-group'; + + const label = document.createElement('label'); + label.setAttribute('for', 'onboarding-api-key'); + label.textContent = 'API Key'; + + const input = document.createElement('input'); + input.type = 'password'; + input.id = 'onboarding-api-key'; + input.placeholder = 'sk-...'; + input.autocomplete = 'new-password'; + + inputGroup.append(label, input); + container.appendChild(inputGroup); + + function updateOnboardingProviderUI() { + const provider = getProvider(providerSelect.value); + input.placeholder = provider.keyPlaceholder; + const needsKey = provider.requiresApiKey; + inputGroup.classList.toggle('hidden', !needsKey); + } + + providerSelect.addEventListener('change', updateOnboardingProviderUI); + updateOnboardingProviderUI(); + + const securityNotice = document.createElement('p'); + securityNotice.className = 'security-notice'; + securityNotice.textContent = 'For security, your key is kept in secure session memory and cleared when the browser is closed.'; + container.appendChild(securityNotice); + + const statusEl = document.createElement('p'); + statusEl.className = 'onboarding-status hidden'; + statusEl.setAttribute('role', 'alert'); + statusEl.setAttribute('aria-live', 'polite'); + container.appendChild(statusEl); + + function setOnboardingStatus(message, isError = false) { + if (!message) { + statusEl.textContent = ''; + statusEl.classList.add('hidden'); + statusEl.classList.remove('onboarding-status-error'); + return; + } + statusEl.textContent = message; + statusEl.classList.remove('hidden'); + statusEl.classList.toggle('onboarding-status-error', isError); + } + + function resetLockInButton() { + lockInBtn.disabled = false; + lockInBtn.textContent = 'LOCK IN'; + } + + const actionsRow = document.createElement('div'); + actionsRow.className = 'onboarding-actions'; + + const skipBtn = document.createElement('button'); + skipBtn.type = 'button'; + skipBtn.className = 'complete-btn onboarding-skip-btn'; + skipBtn.textContent = 'SKIP'; + skipBtn.addEventListener('click', finishOnboarding); + + const lockInBtn = document.createElement('button'); + lockInBtn.type = 'button'; + lockInBtn.className = 'primary-btn onboarding-lock-btn'; + lockInBtn.textContent = 'LOCK IN'; + lockInBtn.addEventListener('click', () => { + try { + const providerId = providerSelect.value || DEFAULT_PROVIDER_ID; + const provider = getProvider(providerId); + const apiKey = input.value.trim(); + const providerConfig = getDefaultProviderConfig(providerId); + + setOnboardingStatus(''); + clearFieldError(input); + + if (provider.requiresApiKey) { + const keyError = validateApiKey(providerId, apiKey); + if (keyError) { + setFieldError(input, keyError); + setOnboardingStatus(keyError, true); + logError({ + type: ERROR_TYPES.VALIDATION, + message: keyError, + details: { providerId, action: 'onboarding_lock_in' }, + source: 'onboarding', + }); + return; + } + } + + lockInBtn.disabled = true; + lockInBtn.textContent = 'Saving...'; + + const completeSetup = () => { + chrome.runtime.sendMessage({ type: 'CONFIG_UPDATED' }, () => { + if (chrome.runtime.lastError) { + logError({ + type: ERROR_TYPES.RUNTIME, + message: 'Provider saved but background sync failed.', + details: { error: chrome.runtime.lastError.message }, + source: 'onboarding', + }); + } + }); + finishOnboarding(); + }; + + const failSetup = (msg, errorType = ERROR_TYPES.STORAGE) => { + setFieldError(input, msg); + setOnboardingStatus(msg, true); + logError({ + type: errorType, + message: msg, + details: { providerId }, + source: 'onboarding', + }); + resetLockInButton(); + }; + + const saveProvider = () => { + chrome.storage.local.set({ llmProviderConfig: providerConfig }, () => { + if (chrome.runtime.lastError) { + failSetup(`Could not save provider settings. ${chrome.runtime.lastError.message}`); + return; + } + completeSetup(); + }); + }; + + if (apiKey) { + const storageArea = chrome.storage.session || chrome.storage.local; + storageArea.set({ llmApiKey: apiKey }, () => { + if (chrome.runtime.lastError) { + failSetup(`Could not save API key. ${chrome.runtime.lastError.message}`); + return; + } + saveProvider(); + }); + } else { + const clearStoredKey = (done) => { + if (chrome.storage.session) { + chrome.storage.session.remove(['llmApiKey'], () => { + chrome.storage.local.remove(['llmApiKey'], done); + }); + } else { + chrome.storage.local.remove(['llmApiKey'], done); + } + }; + clearStoredKey(saveProvider); + } + } catch (err) { + const msg = 'Something went wrong while saving. Try again or use SKIP.'; + setOnboardingStatus(msg, true); + logError({ + type: ERROR_TYPES.RUNTIME, + message: msg, + details: { error: err?.message || String(err) }, + source: 'onboarding', + }); + resetLockInButton(); + } + }); + + actionsRow.append(skipBtn, lockInBtn); + container.appendChild(actionsRow); + + input.focus(); + input.addEventListener('input', () => { + clearFieldError(input); + setOnboardingStatus(''); + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + lockInBtn.click(); + } + }); + } + + function finishOnboarding() { + chrome.storage.local.set({ hasSeenOnboarding: true }, () => { + if (chrome.runtime.lastError) console.error(chrome.runtime.lastError); + showNewSessionForm(container); + }); + } + + showStep1(); + } + + // ── New session form (post-session) ───────────────────────────────── + + function showNewSessionForm(container) { + if (timerInterval) clearInterval(timerInterval); + container.textContent = ''; + + const header = document.createElement('div'); + header.className = 'header'; + const h1 = document.createElement('h1'); + h1.textContent = 'IntentLock'; + const p = document.createElement('p'); + p.textContent = 'Declare what you intend to do.'; + header.append(h1, p); + container.appendChild(header); + + const form = document.createElement('form'); + form.id = 'intent-form'; + form.noValidate = true; + + const intentGroup = document.createElement('div'); + intentGroup.className = 'input-group'; + const intentLabel = document.createElement('label'); + intentLabel.setAttribute('for', 'intent-input'); + intentLabel.textContent = 'Intent'; + const intentInput = document.createElement('textarea'); + intentInput.id = 'intent-input'; + intentInput.placeholder = 'Research Python decorators for the new module...'; + intentInput.required = true; + intentInput.autofocus = true; + intentInput.maxLength = 250; + intentGroup.append(intentLabel, intentInput); + + const timeGroup = document.createElement('div'); + timeGroup.className = 'input-group'; + const timeLabel = document.createElement('label'); + timeLabel.setAttribute('for', 'time-budget'); + timeLabel.textContent = 'Time budget (minutes)'; + const timeInput = document.createElement('input'); + timeInput.type = 'text'; + timeInput.inputMode = 'numeric'; + timeInput.pattern = '[0-9]*'; + timeInput.id = 'time-budget'; + timeInput.autocomplete = 'off'; + timeInput.placeholder = '30'; + timeInput.addEventListener('input', () => clearFieldError(timeInput)); + timeGroup.append(timeLabel, timeInput); + + const btn = document.createElement('button'); + btn.type = 'submit'; + btn.id = 'start-btn'; + btn.textContent = 'Lock in'; + + form.append(intentGroup, timeGroup, btn); + container.appendChild(form); + + getLlmConfig().then((config) => { + if (!isLlmConfigured(config)) { + const apiNotice = document.createElement('div'); + apiNotice.className = 'api-notice'; + const noticeText = document.createElement('p'); + noticeText.textContent = 'Configure an LLM provider in Settings to enable AI-powered drift detection and plan generation.'; + const noticeBtn = document.createElement('button'); + noticeBtn.className = 'complete-btn'; + noticeBtn.textContent = 'Open settings'; + noticeBtn.addEventListener('click', () => { + chrome.runtime.openOptionsPage(); + }); + apiNotice.append(noticeText, noticeBtn); + if (form && form.parentNode === container) { + container.insertBefore(apiNotice, form); + } + } + }); + + const statusMsg = document.createElement('div'); + statusMsg.id = 'status-message'; + statusMsg.className = 'hidden'; + container.appendChild(statusMsg); + + // Keyboard shortcuts button + const shortcutsBtn = document.createElement('button'); + shortcutsBtn.className = 'shortcuts-btn'; + shortcutsBtn.type = 'button'; + shortcutsBtn.setAttribute('aria-label', 'Keyboard shortcuts'); + shortcutsBtn.textContent = '?'; + shortcutsBtn.addEventListener('click', (e) => showShortcutsModal(e.currentTarget)); + container.appendChild(shortcutsBtn); + + intentInput.addEventListener('input', () => { + clearFieldError(intentInput); + }); + + bindForm(); + } + + // ── Keyboard shortcuts modal ───────────────────────────────────────── + + function showShortcutsModal(trigger) { + const modal = document.createElement('div'); + modal.className = 'shortcuts-modal'; + + const content = document.createElement('div'); + content.className = 'shortcuts-content'; + + const h3 = document.createElement('h3'); + h3.textContent = 'Keyboard Shortcuts'; + content.appendChild(h3); + + const shortcuts = [ + { keys: ['Ctrl', 'Shift', 'L'], desc: 'Start/End session' }, + { keys: ['Tab'], desc: 'Navigate form fields' }, + { keys: ['Enter'], desc: 'Submit form' }, + { keys: ['Esc'], desc: 'Close modal' } + ]; + + shortcuts.forEach(shortcut => { + const row = document.createElement('div'); + row.className = 'shortcut-row'; + + const keys = document.createElement('div'); + keys.className = 'shortcut-keys'; + shortcut.keys.forEach(key => { + const kbd = document.createElement('span'); + kbd.className = 'shortcut-key'; + kbd.textContent = key; + keys.appendChild(kbd); + }); + + const desc = document.createElement('span'); + desc.className = 'shortcut-desc'; + desc.textContent = shortcut.desc; + + row.append(keys, desc); + content.appendChild(row); + }); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'complete-btn'; + closeBtn.textContent = 'Close'; + content.appendChild(closeBtn); + + modal.appendChild(content); + document.body.appendChild(modal); + + const { closeModal } = setupModalDialog({ overlay: modal, dialog: content, heading: h3, trigger }); + + closeBtn.addEventListener('click', () => closeModal()); + + modal.addEventListener('click', (e) => { + if (e.target === modal) closeModal(); + }); + } + + // ── Form binding ──────────────────────────────────────────────────── + + function bindForm() { + const form = document.getElementById('intent-form'); + if (!form) return; + + form.addEventListener('submit', (e) => { + e.preventDefault(); + const intentInput = document.getElementById('intent-input'); + const timeBudgetInput = document.getElementById('time-budget'); + const startBtn = document.getElementById('start-btn'); + const intent = intentInput.value.trim(); + const timeBudget = parseInt(timeBudgetInput.value, 10); + + if (!intent) { + setFieldError(intentInput, 'Please declare your intent.'); + return; + } + + clearFieldError(timeBudgetInput); + + if (!isNaN(timeBudget) && timeBudgetInput.value.trim() && (timeBudget < 1 || timeBudget > 480)) { + const budgetError = 'Time budget must be between 1 and 480 minutes.'; + setFieldError(timeBudgetInput, budgetError); + logError({ + type: ERROR_TYPES.VALIDATION, + message: budgetError, + details: { value: timeBudgetInput.value.trim() }, + source: 'session_start', + }); + return; + } + + startBtn.disabled = true; + startBtn.textContent = 'Generating plan...'; + + const sessionData = { + id: crypto.randomUUID(), + intent: intent, + startTime: Date.now(), + timeBudget: isNaN(timeBudget) ? null : timeBudget, + isActive: true, + events: [], + plan: [] + }; + + generateIntentPlan(intent).then(({ steps, error }) => { + sessionData.plan = steps; + + if (error) { + const notice = document.createElement('div'); + notice.className = 'api-notice'; + const noticeText = document.createElement('p'); + noticeText.textContent = `${error.message} Session will start without an AI plan. Check Diagnostics in Settings for details.`; + const diagBtn = document.createElement('button'); + diagBtn.className = 'complete-btn'; + diagBtn.textContent = 'Open diagnostics'; + diagBtn.addEventListener('click', () => { + chrome.tabs.create({ url: chrome.runtime.getURL('diagnostics.html') }); + }); + notice.append(noticeText, diagBtn); + const form = document.getElementById('intent-form'); + if (form && form.parentNode) { + form.parentNode.insertBefore(notice, form.nextSibling); + } + } + + chrome.storage.local.set({ activeSession: sessionData }, () => { + if (chrome.runtime.lastError) { + logError({ + type: ERROR_TYPES.STORAGE, + message: 'Could not start session.', + details: { error: chrome.runtime.lastError.message }, + source: 'session_start', + }); + setFieldError(intentInput, 'Could not start session. See Diagnostics in Settings.'); + startBtn.disabled = false; + startBtn.textContent = 'Lock in'; + return; + } + chrome.runtime.sendMessage({ type: 'SESSION_STARTED', session: sessionData }, () => { + if (chrome.runtime.lastError) { + logError({ + type: ERROR_TYPES.RUNTIME, + message: 'Session saved but background sync failed.', + details: { error: chrome.runtime.lastError.message }, + source: 'session_start', + }); + } + showActiveState(sessionData); + }); + }); + }).catch((err) => { + logError({ + type: ERROR_TYPES.RUNTIME, + message: 'Unexpected error while generating plan.', + details: { error: err.message }, + source: 'session_start', + }); + setFieldError(intentInput, 'Could not start session. See Diagnostics in Settings.'); + startBtn.disabled = false; + startBtn.textContent = 'Lock in'; + }); + }); + } +}); diff --git a/legacy-extension-source/options.html b/legacy-extension-source/options.html new file mode 100644 index 0000000..e8a5657 --- /dev/null +++ b/legacy-extension-source/options.html @@ -0,0 +1,140 @@ + + + + + IntentLock — Settings + + + +
+
+

Settings

+

Configure parameters and privacy controls.

+
+ +
+

LLM provider

+

Choose a provider for drift detection and plan generation. API keys are stored in secure session memory and cleared when the browser closes.

+

When AI is enabled, your declared intent, current page URL, and recent browsing events are sent directly to your chosen provider for drift checks and plan generation. Data is not sent through IntentLock servers.

+ +
+ + +
+ + + +
+ + +

Stored in transient session memory — cleared when the browser closes.

+
+ + + + + + + +
+ +
+

Distraction sites

+

Sites that trigger an intervention when visited during a session. One domain per line.

+ +
+ + +
+ + + +

youtube.com is included by default. If the list is empty, defaults are used automatically.

+
+ +
+

Test intervention

+

Start a session on the new tab, then click below to trigger a test intervention on your current tab. You can also visit youtube.com during an active session — that should intervene without AI.

+ + +
+ +
+

Privacy & data

+

Manage what the extension tracks on your local machine.

+ +
+ +
+ + + + +
+ +
+

Diagnostics

+

View recent errors — API failures, quota limits, misconfigured keys, validation issues, and actions that did not complete.

+ +
+ +
+

Appearance

+

Choose your preferred color scheme.

+ +
+ + + +
+ +
+
+ + + + \ No newline at end of file diff --git a/legacy-extension-source/options.js b/legacy-extension-source/options.js new file mode 100644 index 0000000..af0c24a --- /dev/null +++ b/legacy-extension-source/options.js @@ -0,0 +1,493 @@ +import { + PROVIDER_LIST, + DEFAULT_PROVIDER_ID, + getProvider, + getDefaultProviderConfig, + providerRequiresApiKey, + validateApiKey, + validateProviderConfig, +} from './providers.js'; +import { logError, ERROR_TYPES } from './error-log.js'; + +document.addEventListener('DOMContentLoaded', () => { + const providerSelect = document.getElementById('provider-select'); + const providerDescription = document.getElementById('provider-description'); + const customProviderFields = document.getElementById('custom-provider-fields'); + const customLabelInput = document.getElementById('custom-label'); + const apiStyleSelect = document.getElementById('api-style-select'); + const authTypeSelect = document.getElementById('auth-type-select'); + const providerAdvancedDisclosure = document.getElementById('provider-advanced-disclosure'); + const providerAdvancedToggle = document.getElementById('provider-advanced-toggle'); + const providerModelFields = document.getElementById('provider-model-fields'); + const modelInput = document.getElementById('model-input'); + const baseUrlGroup = document.getElementById('base-url-group'); + const baseUrlInput = document.getElementById('base-url-input'); + const baseUrlHint = document.getElementById('base-url-hint'); + const apiKeyGroup = document.getElementById('api-key-group'); + const apiKeyInput = document.getElementById('api-key'); + const apiKeyHint = document.getElementById('api-key-hint'); + const saveProviderBtn = document.getElementById('save-provider-btn'); + const providerStatus = document.getElementById('provider-status'); + + const distractionSitesInput = document.getElementById('distraction-sites'); + const saveSitesBtn = document.getElementById('save-sites-btn'); + const sitesStatus = document.getElementById('sites-status'); + + const trackingToggle = document.getElementById('tracking-toggle'); + const exportBtn = document.getElementById('export-btn'); + const deleteDataBtn = document.getElementById('delete-data-btn'); + const dataStatus = document.getElementById('data-status'); + const themeStatus = document.getElementById('theme-status'); + const openDiagnosticsBtn = document.getElementById('open-diagnostics-btn'); + const testInterventionBtn = document.getElementById('test-intervention-btn'); + const testInterventionStatus = document.getElementById('test-intervention-status'); + let deleteArmed = false; + let deleteArmTimer = null; + let hasSavedApiKey = false; + let providerAdvancedOpen = false; + + function isCloudProvider(providerId) { + const provider = getProvider(providerId); + return !provider.isLocal && providerId !== 'custom'; + } + + const DEFAULT_SITES = [ + 'twitter.com', 'x.com', 'facebook.com', 'reddit.com', + 'instagram.com', 'youtube.com', 'netflix.com', 'tiktok.com', + ]; + + PROVIDER_LIST.forEach((provider) => { + const option = document.createElement('option'); + option.value = provider.id; + option.textContent = provider.label; + providerSelect.appendChild(option); + }); + + function getFormConfig() { + const providerId = providerSelect.value || DEFAULT_PROVIDER_ID; + const provider = getProvider(providerId); + return { + providerId, + model: modelInput.value.trim() || provider.defaultModel, + baseUrl: baseUrlInput.value.trim() || provider.defaultBaseUrl, + customLabel: customLabelInput.value.trim(), + authType: providerId === 'custom' ? authTypeSelect.value : provider.authType, + apiStyle: providerId === 'custom' ? apiStyleSelect.value : provider.apiStyle, + }; + } + + function updateProviderUI(providerId = providerSelect.value) { + const provider = getProvider(providerId); + const cloudProvider = isCloudProvider(providerId); + providerDescription.textContent = provider.description; + customProviderFields.classList.toggle('hidden', providerId !== 'custom'); + + providerAdvancedDisclosure.classList.toggle('hidden', !cloudProvider); + if (cloudProvider) { + providerModelFields.classList.toggle('hidden', !providerAdvancedOpen); + providerAdvancedToggle.setAttribute('aria-expanded', String(providerAdvancedOpen)); + } else { + providerModelFields.classList.remove('hidden'); + providerAdvancedOpen = false; + providerAdvancedToggle.setAttribute('aria-expanded', 'false'); + } + + const showBaseUrl = providerId === 'custom' || provider.isLocal || cloudProvider; + baseUrlGroup.classList.toggle('hidden', !showBaseUrl); + + if (providerId !== 'custom') { + modelInput.placeholder = provider.defaultModel; + baseUrlInput.placeholder = provider.defaultBaseUrl; + } + + baseUrlHint.textContent = provider.isLocal + ? 'Make sure your local server is running before starting a session.' + : providerId === 'custom' + ? 'Full URL to your provider endpoint.' + : ''; + + const needsKey = providerRequiresApiKey(providerId, getFormConfig()); + apiKeyGroup.classList.toggle('hidden', !needsKey); + apiKeyInput.placeholder = hasSavedApiKey && needsKey + ? 'Key saved — enter new key to replace' + : provider.keyPlaceholder; + apiKeyHint.textContent = needsKey + ? `${provider.keyHint}. Stored in session memory and cleared when the browser closes.` + : provider.keyHint; + } + + function applyStoredConfig(stored = {}) { + const providerId = stored.providerId || DEFAULT_PROVIDER_ID; + const provider = getProvider(providerId); + providerSelect.value = providerId; + modelInput.value = stored.model || provider.defaultModel; + baseUrlInput.value = stored.baseUrl || provider.defaultBaseUrl; + customLabelInput.value = stored.customLabel || ''; + authTypeSelect.value = stored.authType || provider.authType; + apiStyleSelect.value = stored.apiStyle || provider.apiStyle; + updateProviderUI(providerId); + } + + providerSelect.addEventListener('change', () => { + const provider = getProvider(providerSelect.value); + modelInput.value = provider.defaultModel; + baseUrlInput.value = provider.defaultBaseUrl; + providerAdvancedOpen = false; + hasSavedApiKey = false; + apiKeyInput.value = ''; + clearFieldError(apiKeyInput, 'api-key-hint'); + + const finishProviderSwitch = () => { + updateProviderUI(providerSelect.value); + }; + + if (chrome.storage.session) { + chrome.storage.session.remove(['llmApiKey'], finishProviderSwitch); + } else { + finishProviderSwitch(); + } + }); + + providerAdvancedToggle.addEventListener('click', () => { + providerAdvancedOpen = !providerAdvancedOpen; + updateProviderUI(); + }); + + authTypeSelect.addEventListener('change', () => updateProviderUI()); + apiStyleSelect.addEventListener('change', () => updateProviderUI()); + + apiKeyInput.addEventListener('input', () => { + clearFieldError(apiKeyInput, 'api-key-hint'); + }); + + chrome.storage.local.get([ + 'llmProviderConfig', 'openaiApiKey', 'trackingEnabled', 'customDistractionSites', 'theme' + ], (localResult) => { + const processSettings = (sessionApiKey) => { + let migratedKey = sessionApiKey; + + if (localResult.openaiApiKey && chrome.storage.session) { + migratedKey = localResult.openaiApiKey; + chrome.storage.session.set({ llmApiKey: migratedKey }, () => { + chrome.storage.local.remove(['openaiApiKey'], () => { + showStatus(providerStatus, 'Legacy API key migrated to secure session storage.'); + }); + }); + } + + hasSavedApiKey = Boolean(migratedKey); + applyStoredConfig(localResult.llmProviderConfig || getDefaultProviderConfig()); + + if (!localResult.llmProviderConfig) { + chrome.storage.local.set({ + llmProviderConfig: getDefaultProviderConfig(DEFAULT_PROVIDER_ID), + }); + } + + if (localResult.trackingEnabled !== undefined) { + trackingToggle.checked = localResult.trackingEnabled; + } + + const sites = localResult.customDistractionSites || DEFAULT_SITES; + distractionSitesInput.value = sites.join('\n'); + + const theme = localResult.theme || 'auto'; + document.querySelectorAll('.theme-btn').forEach((btn) => { + btn.classList.toggle('active', btn.dataset.theme === theme); + }); + applyTheme(theme); + }; + + if (chrome.storage.session) { + chrome.storage.session.get(['llmApiKey', 'openaiApiKey'], (sessionResult) => { + processSettings(sessionResult.llmApiKey || sessionResult.openaiApiKey); + }); + } else { + processSettings(localResult.openaiApiKey); + } + }); + + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + function setFieldError(field, message, hintId) { + let errorEl = field._errorEl; + if (!errorEl) { + errorEl = document.createElement('p'); + errorEl.className = 'field-error'; + errorEl.id = `${field.id}-error`; + errorEl.setAttribute('role', 'alert'); + field.parentNode.appendChild(errorEl); + field._errorEl = errorEl; + } + errorEl.textContent = message; + field.setAttribute('aria-invalid', 'true'); + const describedBy = [hintId, errorEl.id].filter(Boolean).join(' '); + field.setAttribute('aria-describedby', describedBy); + field.focus(); + } + + function clearFieldError(field, hintId) { + if (field._errorEl) { + field._errorEl.textContent = ''; + } + field.removeAttribute('aria-invalid'); + if (hintId) { + field.setAttribute('aria-describedby', hintId); + } else { + field.removeAttribute('aria-describedby'); + } + } + + function showStatus(el, text) { + el.textContent = text; + el.classList.remove('hidden'); + el.style.display = 'block'; + clearTimeout(el._hideTimer); + if (reducedMotion) { + el.style.opacity = '1'; + el._hideTimer = setTimeout(() => { + el.style.display = 'none'; + }, 3000); + } else { + el.style.opacity = '0'; + el.style.transition = 'opacity 200ms cubic-bezier(0.2, 0, 0, 1)'; + el.offsetHeight; + el.style.opacity = '1'; + el._hideTimer = setTimeout(() => { + el.style.opacity = '0'; + setTimeout(() => { + el.style.display = 'none'; + }, 200); + }, 3000); + } + } + + openDiagnosticsBtn.addEventListener('click', () => { + chrome.tabs.create({ url: chrome.runtime.getURL('diagnostics.html') }); + }); + + saveProviderBtn.addEventListener('click', () => { + const config = getFormConfig(); + const configError = validateProviderConfig(config); + if (configError) { + showStatus(providerStatus, configError); + logError({ + type: ERROR_TYPES.VALIDATION, + message: configError, + details: { providerId: config.providerId, action: 'save_provider' }, + source: 'options', + }); + return; + } + + clearFieldError(apiKeyInput, 'api-key-hint'); + + const key = apiKeyInput.value.trim(); + const keyError = validateApiKey(config.providerId, key || (hasSavedApiKey ? 'saved' : ''), config); + if (keyError && !hasSavedApiKey) { + setFieldError(apiKeyInput, keyError, 'api-key-hint'); + logError({ + type: ERROR_TYPES.VALIDATION, + message: keyError, + details: { providerId: config.providerId, action: 'save_provider' }, + source: 'options', + }); + return; + } + if (key) { + const newKeyError = validateApiKey(config.providerId, key, config); + if (newKeyError) { + setFieldError(apiKeyInput, newKeyError, 'api-key-hint'); + logError({ + type: ERROR_TYPES.VALIDATION, + message: newKeyError, + details: { providerId: config.providerId, action: 'save_provider' }, + source: 'options', + }); + return; + } + } + + chrome.storage.local.set({ llmProviderConfig: config }, () => { + if (chrome.runtime.lastError) { + const msg = 'Could not save provider settings.'; + showStatus(providerStatus, `${msg} See Diagnostics below.`); + logError({ + type: ERROR_TYPES.STORAGE, + message: msg, + details: { error: chrome.runtime.lastError.message }, + source: 'options', + }); + return; + } + + const finish = () => { + hasSavedApiKey = hasSavedApiKey || Boolean(key); + apiKeyInput.value = ''; + updateProviderUI(config.providerId); + showStatus(providerStatus, `${getProvider(config.providerId).label} settings saved.`); + chrome.runtime.sendMessage({ type: 'CONFIG_UPDATED' }); + }; + + if (key) { + const storageArea = chrome.storage.session || chrome.storage.local; + storageArea.set({ llmApiKey: key }, () => { + if (chrome.runtime.lastError) { + const msg = 'Could not save API key to session storage.'; + setFieldError(apiKeyInput, `${msg} See Diagnostics below.`, 'api-key-hint'); + logError({ + type: ERROR_TYPES.STORAGE, + message: msg, + details: { error: chrome.runtime.lastError.message, providerId: config.providerId }, + source: 'options', + }); + return; + } + finish(); + }); + } else { + finish(); + } + }); + }); + + saveSitesBtn.addEventListener('click', () => { + const raw = distractionSitesInput.value.trim(); + const sites = raw.split('\n') + .map((s) => s.trim().toLowerCase()) + .filter((s) => s.length > 0 && s.includes('.')); + + chrome.storage.local.set({ customDistractionSites: sites }, () => { + showStatus(sitesStatus, `${sites.length} sites saved.`); + chrome.runtime.sendMessage({ type: 'CONFIG_UPDATED' }); + }); + }); + + trackingToggle.addEventListener('change', (e) => { + const enabled = e.target.checked; + chrome.storage.local.set({ trackingEnabled: enabled }, () => { + showStatus(dataStatus, enabled ? 'Tracking enabled.' : 'Tracking disabled.'); + chrome.runtime.sendMessage({ type: 'CONFIG_UPDATED' }); + }); + }); + + exportBtn.addEventListener('click', () => { + chrome.storage.local.get(['sessionHistory'], (result) => { + const data = { + exportedAt: new Date().toISOString(), + sessions: result.sessionHistory || [] + }; + + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `intentlock-export-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); + + showStatus(dataStatus, 'History exported.'); + }); + }); + + deleteDataBtn.addEventListener('click', () => { + if (!deleteArmed) { + deleteArmed = true; + deleteDataBtn.textContent = 'Confirm delete'; + showStatus(dataStatus, 'Click confirm delete to erase all local IntentLock data.'); + clearTimeout(deleteArmTimer); + deleteArmTimer = setTimeout(() => { + deleteArmed = false; + deleteDataBtn.textContent = 'Delete all data'; + }, 5000); + return; + } + + clearTimeout(deleteArmTimer); + deleteArmed = false; + deleteDataBtn.disabled = true; + deleteDataBtn.textContent = 'Deleting...'; + + chrome.storage.local.clear(() => { + const finishDelete = () => { + hasSavedApiKey = false; + applyStoredConfig(getDefaultProviderConfig()); + distractionSitesInput.value = DEFAULT_SITES.join('\n'); + apiKeyInput.value = ''; + trackingToggle.checked = true; + chrome.storage.local.set({ llmProviderConfig: getDefaultProviderConfig() }); + showStatus(dataStatus, 'All data deleted.'); + chrome.runtime.sendMessage({ type: 'SESSION_CLEARED' }); + document.querySelectorAll('.theme-btn').forEach((btn) => { + btn.classList.toggle('active', btn.dataset.theme === 'auto'); + }); + applyTheme('auto'); + deleteDataBtn.disabled = false; + deleteDataBtn.textContent = 'Delete all data'; + }; + + if (chrome.storage.session) { + chrome.storage.session.clear(finishDelete); + } else { + finishDelete(); + } + }); + }); + + function applyTheme(theme, animate) { + const root = document.documentElement; + + function setTheme() { + if (theme === 'auto') { + root.style.removeProperty('color-scheme'); + root.classList.remove('theme-dark', 'theme-light'); + } else if (theme === 'dark') { + root.style.colorScheme = 'dark'; + root.classList.remove('theme-light'); + root.classList.add('theme-dark'); + } else if (theme === 'light') { + root.style.colorScheme = 'light'; + root.classList.remove('theme-dark'); + root.classList.add('theme-light'); + } + } + + if (animate && !reducedMotion) { + document.body.style.transition = 'opacity 150ms cubic-bezier(0.2, 0, 0, 1)'; + document.body.style.opacity = '0.6'; + setTimeout(() => { + setTheme(); + document.body.style.opacity = '1'; + }, 150); + } else { + setTheme(); + } + } + + testInterventionBtn.addEventListener('click', () => { + chrome.runtime.sendMessage({ type: 'TEST_INTERVENTION' }, (response) => { + if (chrome.runtime.lastError) { + showStatus(testInterventionStatus, 'Could not reach extension background. Reload the extension.'); + return; + } + if (response?.ok) { + showStatus(testInterventionStatus, 'Intervention triggered on your current tab.'); + } else { + showStatus(testInterventionStatus, response?.error || 'Could not trigger intervention.'); + } + }); + }); + + document.querySelectorAll('.theme-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const theme = btn.dataset.theme; + document.querySelectorAll('.theme-btn').forEach((b) => b.classList.remove('active')); + btn.classList.add('active'); + chrome.storage.local.set({ theme }, () => { + applyTheme(theme, true); + showStatus(themeStatus, 'Theme updated.'); + chrome.runtime.sendMessage({ type: 'CONFIG_UPDATED' }); + }); + }); + }); +}); \ No newline at end of file diff --git a/legacy-extension-source/page-tracker.js b/legacy-extension-source/page-tracker.js new file mode 100644 index 0000000..7d53c64 --- /dev/null +++ b/legacy-extension-source/page-tracker.js @@ -0,0 +1,161 @@ +// page-tracker.js — Per-page dwell time and SPA navigation tracking + +export const DWELL_REPORT_INTERVAL_MS = 30_000; + +export function accumulateDwell({ activeMs = 0, lastTick, isVisible, now }) { + if (isVisible && lastTick != null) { + return { + activeMs: activeMs + Math.max(0, now - lastTick), + lastTick: now, + }; + } + return { activeMs, lastTick: now }; +} + +export function shouldReportSpaNavigation(fromUrl, toUrl) { + if (!fromUrl || !toUrl || fromUrl === toUrl) return false; + try { + const from = new URL(fromUrl); + const to = new URL(toUrl); + return from.origin === to.origin; + } catch { + return false; + } +} + +export function createPageTracker({ + onReport, + getLocation = () => globalThis.location?.href || '', + getTitle = () => globalThis.document?.title || '', + isVisible = () => !globalThis.document?.hidden, + now = () => Date.now(), + reportIntervalMs = DWELL_REPORT_INTERVAL_MS, +} = {}) { + if (typeof onReport !== 'function') { + throw new Error('createPageTracker requires onReport callback'); + } + + let activeMs = 0; + let lastTick = now(); + let currentUrl = getLocation(); + let intervalId = null; + let started = false; + const cleanups = []; + + function snapshot() { + const state = accumulateDwell({ + activeMs, + lastTick, + isVisible: isVisible(), + now: now(), + }); + activeMs = state.activeMs; + lastTick = state.lastTick; + return { + url: getLocation(), + pageTitle: getTitle(), + dwellMs: activeMs, + }; + } + + function report(actionType, extra = {}) { + const data = snapshot(); + onReport({ + actionType, + url: data.url, + pageTitle: data.pageTitle, + dwellMs: data.dwellMs, + ...extra, + }); + } + + function resetForUrl(nextUrl) { + activeMs = 0; + lastTick = now(); + currentUrl = nextUrl; + } + + function handleSpaNavigation(nextUrl) { + if (!shouldReportSpaNavigation(currentUrl, nextUrl)) return; + report('SPA_NAVIGATION', { previousUrl: currentUrl }); + resetForUrl(nextUrl); + } + + function patchHistoryMethod(methodName) { + const historyRef = globalThis.history; + if (!historyRef || typeof historyRef[methodName] !== 'function') return; + + const original = historyRef[methodName]; + historyRef[methodName] = function patchedHistoryMethod(...args) { + const result = original.apply(this, args); + handleSpaNavigation(getLocation()); + return result; + }; + cleanups.push(() => { + historyRef[methodName] = original; + }); + } + + function onVisibilityChange() { + const state = accumulateDwell({ + activeMs, + lastTick, + isVisible: isVisible(), + now: now(), + }); + activeMs = state.activeMs; + lastTick = state.lastTick; + if (!isVisible()) { + report('PAGE_DWELL'); + } + } + + function onBeforeUnload() { + report('PAGE_DWELL'); + } + + function onPopState() { + handleSpaNavigation(getLocation()); + } + + function start() { + if (started) return; + started = true; + currentUrl = getLocation(); + lastTick = now(); + + patchHistoryMethod('pushState'); + patchHistoryMethod('replaceState'); + + const doc = globalThis.document; + const win = globalThis.window; + if (doc?.addEventListener) { + doc.addEventListener('visibilitychange', onVisibilityChange); + cleanups.push(() => doc.removeEventListener('visibilitychange', onVisibilityChange)); + } + if (win?.addEventListener) { + win.addEventListener('beforeunload', onBeforeUnload); + win.addEventListener('popstate', onPopState); + cleanups.push(() => win.removeEventListener('beforeunload', onBeforeUnload)); + cleanups.push(() => win.removeEventListener('popstate', onPopState)); + } + + intervalId = globalThis.setInterval(() => report('PAGE_DWELL'), reportIntervalMs); + cleanups.push(() => { + if (intervalId) globalThis.clearInterval(intervalId); + intervalId = null; + }); + } + + function stop() { + if (!started) return; + report('PAGE_DWELL'); + started = false; + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + cleanup(); + } + } + + return { start, stop, report, snapshot }; +} \ No newline at end of file diff --git a/legacy-extension-source/popup.html b/legacy-extension-source/popup.html new file mode 100644 index 0000000..de4cb65 --- /dev/null +++ b/legacy-extension-source/popup.html @@ -0,0 +1,19 @@ + + + + + IntentLock + + + +
+
+

IntentLock

+
+
+

Loading...

+
+
+ + + diff --git a/legacy-extension-source/popup.js b/legacy-extension-source/popup.js new file mode 100644 index 0000000..4cf96bb --- /dev/null +++ b/legacy-extension-source/popup.js @@ -0,0 +1,127 @@ +document.addEventListener('DOMContentLoaded', () => { + const content = document.getElementById('content'); + + function createHistoryEntry(session) { + const events = Array.isArray(session.events) ? session.events : []; + return { + id: session.id, + intent: session.intent, + startTime: session.startTime, + endTime: session.endTime, + timeBudget: session.timeBudget, + driftCount: events.filter(e => e.actionType === 'OVERRIDE').length, + totalEvents: events.length + }; + } + + chrome.storage.local.get(['activeSession'], (result) => { + const session = result.activeSession; + + if (!session || !session.isActive) { + content.textContent = ''; + const p1 = document.createElement('p'); + p1.className = 'no-session'; + p1.textContent = 'No active session.'; + const p2 = document.createElement('p'); + p2.className = 'no-session'; + p2.textContent = 'Open a new tab to declare intent.'; + content.append(p1, p2); + addFooterLinks(content); + return; + } + + content.textContent = ''; + + // Session intent display + const intentBox = document.createElement('div'); + intentBox.className = 'session-intent'; + intentBox.textContent = session.intent; + content.appendChild(intentBox); + + // Time display + const timeEl = document.createElement('p'); + timeEl.className = 'time-remaining'; + content.appendChild(timeEl); + + function updateTime() { + const elapsed = Math.round((Date.now() - session.startTime) / 60000); + if (session.timeBudget) { + const remaining = session.timeBudget - elapsed; + if (remaining > 0) { + timeEl.textContent = `${remaining} min remaining`; + timeEl.classList.remove('time-exceeded'); + } else { + timeEl.textContent = `Budget exceeded by ${Math.abs(remaining)} min`; + timeEl.classList.add('time-exceeded'); + } + } else { + timeEl.textContent = `${elapsed} min elapsed`; + } + } + + updateTime(); + setInterval(updateTime, 10000); + + // End session button + const btn = document.createElement('button'); + btn.className = 'complete-btn'; + btn.textContent = 'End session'; + btn.addEventListener('click', () => { + chrome.runtime.sendMessage({ type: 'END_ACTIVE_SESSION' }, () => { + chrome.runtime.sendMessage({ type: 'SESSION_CLEARED' }); + content.textContent = ''; + const p = document.createElement('p'); + p.className = 'no-session'; + p.textContent = 'Session ended.'; + content.appendChild(p); + addFooterLinks(content); + }); + }); + content.appendChild(btn); + + addFooterLinks(content); + }); + + function addFooterLinks(parent) { + const footer = document.createElement('div'); + footer.className = 'popup-footer'; + + const histLink = document.createElement('a'); + histLink.href = '#'; + histLink.className = 'popup-link'; + histLink.textContent = 'History'; + histLink.addEventListener('click', (e) => { + e.preventDefault(); + chrome.tabs.create({ url: chrome.runtime.getURL('history.html') }); + }); + + const sep = document.createElement('span'); + sep.className = 'history-sep'; + sep.textContent = '\u00B7'; + + const diagLink = document.createElement('a'); + diagLink.href = '#'; + diagLink.className = 'popup-link'; + diagLink.textContent = 'Diagnostics'; + diagLink.addEventListener('click', (e) => { + e.preventDefault(); + chrome.tabs.create({ url: chrome.runtime.getURL('diagnostics.html') }); + }); + + const sep2 = document.createElement('span'); + sep2.className = 'history-sep'; + sep2.textContent = '\u00B7'; + + const settingsLink = document.createElement('a'); + settingsLink.href = '#'; + settingsLink.className = 'popup-link'; + settingsLink.textContent = 'Settings'; + settingsLink.addEventListener('click', (e) => { + e.preventDefault(); + chrome.runtime.openOptionsPage(); + }); + + footer.append(histLink, sep, diagLink, sep2, settingsLink); + parent.appendChild(footer); + } +}); diff --git a/legacy-extension-source/providers.js b/legacy-extension-source/providers.js new file mode 100644 index 0000000..2648244 --- /dev/null +++ b/legacy-extension-source/providers.js @@ -0,0 +1,436 @@ +// providers.js — Multi-provider LLM adapter for IntentLock + +import { classifyApiError, logError, ERROR_TYPES } from './error-log.js'; +import { + isLlmBackedOff, + parseRetryAfterMs, + setQuotaBackoff, + shouldLogQuotaError, +} from './llm-backoff.js'; + +export const DEFAULT_PROVIDER_ID = 'openai'; + +export const PROVIDERS = { + openai: { + id: 'openai', + label: 'OpenAI', + apiStyle: 'openai', + defaultModel: 'gpt-4o-mini', + defaultBaseUrl: 'https://api.openai.com/v1/chat/completions', + authType: 'bearer', + requiresApiKey: true, + keyHint: 'sk-...', + keyPlaceholder: 'sk-...', + description: 'GPT-4o-mini for fast drift checks and plan generation.', + }, + gemini: { + id: 'gemini', + label: 'Google Gemini', + apiStyle: 'gemini', + defaultModel: 'gemini-2.0-flash-lite', + defaultBaseUrl: 'https://generativelanguage.googleapis.com/v1beta/models', + authType: 'query', + requiresApiKey: true, + keyHint: 'AIza... (from Google AI Studio)', + keyPlaceholder: 'AIza...', + description: 'Free-tier friendly Gemini models via Google AI Studio.', + }, + grok: { + id: 'grok', + label: 'xAI Grok', + apiStyle: 'openai', + defaultModel: 'grok-2-latest', + defaultBaseUrl: 'https://api.x.ai/v1/chat/completions', + authType: 'bearer', + requiresApiKey: true, + keyHint: 'xai-...', + keyPlaceholder: 'xai-...', + description: 'Grok models via the xAI API.', + }, + ollama: { + id: 'ollama', + label: 'Ollama (local)', + apiStyle: 'ollama', + defaultModel: 'llama3.2', + defaultBaseUrl: 'http://localhost:11434/api/chat', + authType: 'none', + requiresApiKey: false, + keyHint: 'No key required', + keyPlaceholder: 'Not required', + description: 'Run models locally with Ollama on port 11434.', + isLocal: true, + }, + lmstudio: { + id: 'lmstudio', + label: 'LM Studio (local)', + apiStyle: 'openai', + defaultModel: 'local-model', + defaultBaseUrl: 'http://localhost:1234/v1/chat/completions', + authType: 'none', + requiresApiKey: false, + keyHint: 'No key required', + keyPlaceholder: 'Not required (optional)', + description: 'OpenAI-compatible local server from LM Studio on port 1234.', + isLocal: true, + }, + custom: { + id: 'custom', + label: 'Custom provider', + apiStyle: 'openai', + defaultModel: '', + defaultBaseUrl: '', + authType: 'bearer', + requiresApiKey: true, + keyHint: 'Provider API key', + keyPlaceholder: 'Your API key', + description: 'Any OpenAI-compatible, Gemini, or Ollama endpoint.', + }, +}; + +export const PROVIDER_LIST = Object.values(PROVIDERS); + +export function cleanJsonString(str) { + if (typeof str !== 'string') return ''; + let cleaned = str.trim(); + if (cleaned.startsWith('```')) { + cleaned = cleaned.replace(/^```[a-zA-Z]*\s*/, '').replace(/\s*```$/, ''); + } + return cleaned.trim(); +} + +export function getProvider(providerId) { + return PROVIDERS[providerId] || PROVIDERS[DEFAULT_PROVIDER_ID]; +} + +export function getDefaultProviderConfig(providerId = DEFAULT_PROVIDER_ID) { + const provider = getProvider(providerId); + return { + providerId: provider.id, + model: provider.defaultModel, + baseUrl: provider.defaultBaseUrl, + customLabel: '', + authType: provider.authType, + apiStyle: provider.apiStyle, + }; +} + +export function providerRequiresApiKey(providerId, config = {}) { + if (providerId === 'custom') { + return (config.authType || 'bearer') !== 'none'; + } + return getProvider(providerId).requiresApiKey; +} + +export function validateApiKey(providerId, key, config = {}) { + const trimmed = (key || '').trim(); + if (!trimmed) { + return providerRequiresApiKey(providerId, config) ? 'API key is required for this provider.' : null; + } + if (providerId === 'openai' && trimmed.startsWith('AIza')) { + return 'This looks like a Google Gemini key. Switch provider to Google Gemini above.'; + } + if (providerId === 'gemini' && trimmed.startsWith('sk-')) { + return 'This looks like an OpenAI key. Switch provider to OpenAI above.'; + } + if (providerId === 'openai' && !trimmed.startsWith('sk-')) { + return 'OpenAI keys typically start with "sk-".'; + } + if (providerId === 'grok' && !trimmed.startsWith('xai-') && trimmed.length < 20) { + return 'xAI keys typically start with "xai-".'; + } + if (providerId === 'gemini' && trimmed.length < 20) { + return 'Enter a valid Gemini API key from Google AI Studio.'; + } + return null; +} + +async function throwApiFailure(response, providerId) { + const bodyText = await response.text().catch(() => ''); + const apiError = classifyApiError(response.status, bodyText, providerId); + const err = new Error(apiError.message); + err.apiError = apiError; + err.bodyText = bodyText; + err.status = response.status; + throw err; +} + +export function validateProviderConfig(config) { + const providerId = config?.providerId || DEFAULT_PROVIDER_ID; + const provider = getProvider(providerId); + + if (providerId === 'custom') { + if (!config.customLabel?.trim()) return 'Enter a name for your custom provider.'; + if (!config.baseUrl?.trim()) return 'Enter the API endpoint URL.'; + if (!config.model?.trim()) return 'Enter the model name.'; + try { + new URL(config.baseUrl.trim()); + } catch { + return 'Enter a valid API endpoint URL.'; + } + } + + if ((provider.isLocal || providerId === 'custom') && config.baseUrl?.trim()) { + try { + new URL(config.baseUrl.trim()); + } catch { + return 'Enter a valid base URL.'; + } + } + + return null; +} + +export function isLlmConfigured(config) { + const providerId = config?.providerId || DEFAULT_PROVIDER_ID; + const provider = getProvider(providerId); + + if (providerId === 'custom') { + if (!config.baseUrl?.trim() || !config.model?.trim()) return false; + if (providerRequiresApiKey(providerId, config) && !config.apiKey) return false; + return true; + } + + if (provider.isLocal) return true; + return Boolean(config.apiKey); +} + +export async function getLlmConfig() { + const fallback = { + ...getDefaultProviderConfig(DEFAULT_PROVIDER_ID), + provider: getProvider(DEFAULT_PROVIDER_ID), + apiKey: null, + }; + + if (typeof chrome === 'undefined' || !chrome.storage) { + return fallback; + } + + return new Promise((resolve) => { + chrome.storage.local.get(['llmProviderConfig', 'openaiApiKey'], (localRes) => { + const stored = { ...getDefaultProviderConfig(), ...(localRes?.llmProviderConfig || {}) }; + const providerId = stored.providerId || DEFAULT_PROVIDER_ID; + const provider = getProvider(providerId); + + const finish = (apiKey) => { + resolve({ + providerId, + provider, + apiKey, + model: stored.model || provider.defaultModel, + baseUrl: stored.baseUrl || provider.defaultBaseUrl, + customLabel: stored.customLabel || '', + authType: stored.authType || provider.authType, + apiStyle: stored.apiStyle || provider.apiStyle, + }); + }; + + if (chrome.storage.session) { + chrome.storage.session.get(['llmApiKey', 'openaiApiKey'], (sessionRes) => { + const apiKey = sessionRes?.llmApiKey || sessionRes?.openaiApiKey || localRes?.openaiApiKey || null; + finish(apiKey); + }); + } else { + finish(localRes?.openaiApiKey || null); + } + }); + }); +} + +async function callOpenAiCompatible({ baseUrl, apiKey, model, prompt, jsonMode, maxTokens, temperature, authType, providerId }) { + const headers = { 'Content-Type': 'application/json' }; + let url = baseUrl; + + if (authType === 'bearer' && apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } else if (authType === 'header' && apiKey) { + headers['x-api-key'] = apiKey; + } else if (authType === 'query' && apiKey) { + const sep = url.includes('?') ? '&' : '?'; + url = `${url}${sep}key=${encodeURIComponent(apiKey)}`; + } + + const body = { + model, + messages: [{ role: 'user', content: prompt }], + temperature, + max_tokens: maxTokens, + }; + if (jsonMode) { + body.response_format = { type: 'json_object' }; + } + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + await throwApiFailure(response, providerId); + } + + const data = await response.json(); + return data.choices?.[0]?.message?.content ?? null; +} + +async function callGemini({ baseUrl, apiKey, model, prompt, jsonMode, maxTokens, temperature, providerId }) { + const root = baseUrl.replace(/\/$/, ''); + const url = `${root}/${model}:generateContent?key=${encodeURIComponent(apiKey)}`; + + const body = { + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { + temperature, + maxOutputTokens: maxTokens, + }, + }; + if (jsonMode) { + body.generationConfig.responseMimeType = 'application/json'; + } + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + await throwApiFailure(response, providerId); + } + + const data = await response.json(); + return data.candidates?.[0]?.content?.parts?.[0]?.text ?? null; +} + +async function callOllama({ baseUrl, model, prompt, jsonMode, maxTokens, temperature, providerId }) { + const body = { + model, + messages: [{ role: 'user', content: prompt }], + stream: false, + options: { temperature, num_predict: maxTokens }, + }; + if (jsonMode) { + body.format = 'json'; + } + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + await throwApiFailure(response, providerId); + } + + const data = await response.json(); + return data.message?.content ?? null; +} + +export async function chatCompletion(prompt, options = {}) { + const { jsonMode = true, maxTokens = 100, temperature = 0.1 } = options; + const config = await getLlmConfig(); + + if (!isLlmConfigured(config)) { + return { ok: false, error: { code: 'not_configured', message: 'LLM provider is not configured.', providerId: config.providerId } }; + } + + if (isLlmBackedOff()) { + return { + ok: false, + error: { + code: 'quota_backoff', + message: 'LLM calls paused after a quota error. Heuristic drift still works. Retry later or switch models in Settings.', + providerId: config.providerId, + }, + }; + } + + const apiStyle = config.providerId === 'custom' + ? (config.apiStyle || 'openai') + : config.provider.apiStyle; + + try { + let text = null; + switch (apiStyle) { + case 'openai': + text = await callOpenAiCompatible({ + baseUrl: config.baseUrl, + apiKey: config.apiKey, + model: config.model, + prompt, + jsonMode, + maxTokens, + temperature, + authType: config.authType, + providerId: config.providerId, + }); + break; + case 'gemini': + text = await callGemini({ + baseUrl: config.baseUrl, + apiKey: config.apiKey, + model: config.model, + prompt, + jsonMode, + maxTokens, + temperature, + providerId: config.providerId, + }); + break; + case 'ollama': + text = await callOllama({ + baseUrl: config.baseUrl, + model: config.model, + prompt, + jsonMode, + maxTokens, + temperature, + providerId: config.providerId, + }); + break; + default: + return { ok: false, error: { code: 'unsupported_provider', message: 'Unsupported API format.', providerId: config.providerId } }; + } + + if (!text) { + const emptyError = { code: 'empty_response', message: 'API returned an empty response.', providerId: config.providerId }; + await logError({ + type: ERROR_TYPES.API, + message: emptyError.message, + details: emptyError, + source: 'chatCompletion', + }); + return { ok: false, error: emptyError }; + } + + return { ok: true, text }; + } catch (error) { + const bodyText = error.bodyText || error.message || ''; + const apiError = error.apiError || classifyApiError(error.status || 0, bodyText, config.providerId); + + if (apiError.code === 'quota_exceeded') { + setQuotaBackoff({ retryAfterMs: parseRetryAfterMs(bodyText) }); + if (shouldLogQuotaError()) { + const modelHint = config.providerId === 'gemini' + ? ' Try model gemini-2.0-flash-lite in Advanced settings, or wait for quota reset.' + : ''; + await logError({ + type: ERROR_TYPES.API, + message: `${apiError.message}${modelHint}`, + details: { ...apiError, model: config.model }, + source: 'chatCompletion', + }); + } + } else { + await logError({ + type: ERROR_TYPES.API, + message: apiError.message, + details: apiError, + source: 'chatCompletion', + }); + } + + return { ok: false, error: apiError }; + } +} \ No newline at end of file