A Chrome/Brave MV3 side panel. Bookmarks are the source of truth; the AI only ever proposes; nothing is applied without approval; nothing leaves the browser without passing one privacy module.
flowchart TB
subgraph browser[Browser]
tabsApi[chrome.tabs]
bmApi[chrome.bookmarks]
storeApi[chrome.storage]
end
subgraph ui["ui/ — one folder per screen"]
home[home/: unsorted, tree, recent, recall]
chat[chat/]
privacyUi[privacy/: Before sending]
review[review/]
options[options/]
shared[dom, drag, filter, picker, tabActions]
end
subgraph app["app/ — shell"]
state[(state)]
bus[bus: refresh / proposal]
nav[nav: views, drawer, theme]
refresh[refresh: dirty flags + 2 s poll]
end
subgraph services["services/ — chrome + network"]
payload[payload: what leaves]
tabsSvc[tabs]
bookmarks[bookmarks]
storage[storage]
backup[backup]
llm[llm/: prompts, tools, anthropic, openai]
end
subgraph domain["domain/ — pure rules"]
privacy[privacy: redact, exclude, map back]
urls[urls]
end
ui --> app
ui --> services
app --> services
services --> domain
ui --> domain
services --> browser
llm -->|HTTPS, your key| provider[(AI provider)]
refresh -. renders via bus .-> home
Dependencies point inward: ui → app → services → domain. Screens never import each other; a screen that needs a re-render calls requestRefresh() and a new proposal is announced with emit("proposal"). scripts/check-layers.mjs fails the build on a violation or a cycle.
| Layer | Module | Owns |
|---|---|---|
| app | state.ts |
the one shared mutable object (chat history, proposal, filters, expansion, privacy approvals) |
| app | bus.ts |
refresh and proposal events; keeps renderers and actions decoupled |
| app | nav.ts |
which view is visible, chat drawer, collapsible panels, theme |
| app | refresh.ts |
chrome event listeners → dirty flags → 2 s poll → renderers; never renders mid-drag or with a picker open |
| domain | privacy.ts |
domain exclusion, private-host detection, URL/title redaction, sent→real URL mapping, payload fingerprint |
| domain | urls.ts |
URL normalization, sortability, domain of |
| services | payload.ts |
builds the CURRENT STATE block for a scope (unsorted / tabs / library) after the privacy rules, plus what was kept back and why |
| services | llm/ |
provider dispatch (index.ts), prompts, strict tool schemas + sanitizers, one file per provider |
| services | bookmarks.ts |
managed root, folder paths, apply/revert proposals, manual filing, moves, deletes, reconcile |
| services | backup.ts |
snapshots (3-hourly, 7 days), export/import |
| services | tabs.ts |
open tabs (incl. sleeping tabs), close safely, reopen, focus |
| ui | privacy/outgoing.ts |
the “Before sending” step |
| ui | chat/chat.ts |
one chat turn: gate → send → parse → announce proposal |
| ui | home/recall.ts |
Enter in search → find by description → filtered lists with reasons |
| ui | review/review.ts |
proposal tree with diff badges, questions, removals, apply/undo |
flowchart LR
T[open tabs<br/>title + URL] --> S
B[library bookmarks<br/>title + URL + folder] --> S
S{scope}
S -->|"unsorted / tabs (sorting, chat)"| F[folder summaries only<br/>path · count · note]
S -->|"library (Clean up, recall,<br/>or ticked in Before sending)"| R
F --> R
R{privacy rules}
R -->|excluded domain| K[kept back<br/>shown with reason]
R -->|private network| K
R -->|skipped this conversation| K
R -->|passes| X[redact]
X -->|"credentials always<br/>query string and fragment<br/>identifier path segments → ~<br/>local files → file name only<br/>tenant.saas.com → ~.saas.com<br/>emails → [email]<br/>9+ digit numbers → [number]"| M[sent→real URL map<br/>deterministic, unique]
M --> P[CURRENT STATE JSON<br/>+ folder names]
P --> V[Before sending<br/>groups · kept back · exact text]
V -->|Send| A[(provider)]
A -->|proposal with sent URLs| M2[map back to real URLs]
M2 --> RV[Review]
Scope is data minimization: sorting sends the open tabs and the shape of the library (every folder’s path, size and the note saved from the proposal that created it), never the bookmarks. The library itself goes only for Clean up, for recall, or when the user ticks Also send my library bookmarks on the Before-sending screen; the screen says which case applies (“Your 152 library bookmarks stay in the browser”). removedByUser is likewise trimmed to URLs that are in the set.
The preview is skipped only when the fingerprint of the outgoing set equals the last one the user approved in this session, so refining a proposal in chat does not re-ask; adding a tab, or adding the library, does.
sequenceDiagram
actor U as User
participant C as chat.ts
participant P as payload.ts
participant O as privacy/outgoing.ts
participant L as llm/index.ts
participant D as domain/privacy.ts
participant R as review.ts
U->>C: message (or Sort chip)
C->>P: buildOutgoing(scope) — chip decides: unsorted / tabs / library
P->>D: exclude / redact / map
P-->>C: {scope, text, tabs, bookmarks, keptBack, map, key}
C->>O: confirmOutgoing(outgoing)
alt key already approved this session, or previews off
O-->>C: same outgoing
else
O->>U: Before sending (groups, kept back, exact text, library toggle)
U->>O: Send / Cancel
O-->>C: final outgoing / null
end
C->>L: runChatTurn(history + text)
L-->>C: text + submit_proposal(tool input)
C->>D: mapProposalBack(proposal, map)
C-->>R: emit("proposal")
R->>U: Review: folders (with "Why: …"), questions, removals
U->>R: Apply
R->>R: snapshot, applyProposal writes bookmarks
R->>U: Home strip: Applied · n filed — Undo ⇄ Redo (stays until dismissed or replaced)
sequenceDiagram
actor U as User
participant S as home/recall.ts
participant P as payload.ts
participant O as privacy/outgoing.ts
participant L as llm/index.ts
participant H as home/tree.ts + unsorted.ts
U->>S: types a description, presses Enter
S->>P: buildOutgoing("library") — recall searches the library
S->>O: confirmOutgoing("This search")
O-->>S: true / false
S->>L: runFindTurn(query, library) — tool call forced
L-->>S: matches [{url, why}]
S->>S: findFilter = real URL → why
S->>H: re-render: only matches, "↳ why" under each
stateDiagram-v2
[*] --> Home
Home --> Setup: first run (no key) / ⚙ Options
Setup --> Home: Save
Home --> BeforeSending: send a message / Enter in search (new outgoing set)
BeforeSending --> Home: Send or Cancel
Home --> Review: proposal ready / Review →
Review --> Home: Apply / Dismiss / ←
chrome.tabs.* and chrome.bookmarks.* events only set dirty flags. A 2-second poll renders when something is dirty, unless the user is mid-drag, has a folder picker or rename input open, or a proposal is being applied. A 30-second heartbeat catches anything the events miss (window focus changes, tabs restored from sleep).
| Layer | Where | Runs |
|---|---|---|
Unit — the pure rules in domain/ |
tests/unit/*.test.ts (node:test) |
npm test, and inside npm run build |
Browser — every screen against a mock chrome.* |
tests/harness/*.test.js, headless via DevTools |
npm run test:ui |
| Evidence — one screenshot per step of the same flows | tests/harness/evidence.mjs |
npm run evidence (output is not committed) |
| Architecture — layers and cycles | scripts/check-layers.mjs |
inside npm run build |