Skip to content

MASHA-extension rebuild - #30

Merged
clemperorpenguin merged 7 commits into
mainfrom
masha
Sep 8, 2026
Merged

clemperorpenguin merged 7 commits into
mainfrom
masha

Conversation

@clemperorpenguin

Copy link
Copy Markdown
Owner

No description provided.

clemperorpenguin and others added 7 commits September 8, 2026 04:36
M0 — Bridge skeleton:
- Created packages/xian-bridge with FastAPI-based local bridge service
- Routes: /health, /ocr, /ocr/render, /documents, /cache, /glossary
- CORS-constrained to extension origins, loopback-only binding
- Added xian-bridge to uv workspace with pyproject.toml
- Added vitest harness for browser-free core/ tests

M1 — Bilingual pages (core/ layer):
- core/dom/tree.ts — serialisable NodeSummary tree
- core/dom/article.ts — Readability-based article root finder
- core/segment.ts — toSegments, shouldTranslate, splitLongBlock
- core/lang.ts — looksLikeTargetLanguage with script-block histogram
- core/pipeline.ts — translatePage with marker-validated batching
- core/cache.ts — LRU cache with epoch-based invalidation

M1 — Content script entrypoints:
- entrypoints/content/walk.ts — DOM -> NodeSummary walker
- entrypoints/content/inject.ts — shadow-rooted <masha-tr> injection
- entrypoints/content/observer.ts — MutationObserver for dynamic pages

Platform bridge:
- Extended PlatformBridge with getPageTree, translatePage, undoPageTranslation
- Added model field to MashaConfig
- Updated wxt.config.ts permissions (activeTab, scripting, webNavigation)

Tests:
- 5 test suites with 29 passing tests
- article.test.ts, segment.test.ts, pipeline.test.ts, cache.test.ts, lang.test.ts
- All browser-free, pure algorithmic tests via vitest
The M1 core went in unwired, and four bugs in it would each have produced
nothing at all on the first day it was hooked up:

- toSegments emitted a segment for every node, and the walker's node table
  holds each element's whole subtree text — so a paragraph was sent once per
  level of nesting, the first segment being the entire page body. Only the
  innermost block is a segment now.
- The batch prompt never mentioned the markers the batch protocol keys on. A
  model obeying "output ONLY the translation" answers without them, and every
  segment fails to parse. buildBatchTranslationMessages states the protocol.
- Node ids were positional, so anything inserted earlier in the document
  renumbered the rest, and markSource had no callers at all, so data-masha-id
  was never written — injection and the observer's dedupe both looked it up
  and always missed. Identity now lives on the element in data-masha-node.
- linkDensity divided subtree link text by own text, so a <nav> of links had
  density 0 and scored as prose. Both are measured over the subtree now.

Two fixes reach a little past the bug to where it actually bites. The comma
term was fed the text *length* ("500"), always zero — but fixing that alone
changes nothing while findArticleRoot scores containers and propagates child
scores into them, handing the page root the top score every time; scoring is
now restricted to innermost blocks, as the module's docstring already said.
And splitLongBlock returned chunks several times maxChars and had no caller;
it is fixed and wired into toSegments, with injectTranslation reassembling
#index chunks in order into one <masha-tr>.

Also in the pipeline: a mangled marker no longer discards the nine good
translations batched with it, the abort signal reaches fetch so a stopped run
stops painting, cleanResponse strips <think> tags on the page path as it does
on the selection path, the endpoint goes through normalizeLemonadeBaseUrl,
and the declared-but-ignored glossary reaches the prompt.

On the bridge: /health advertised OCR and documents while every one of those
routes returns 501, so MASHA would have shown features that cannot work — it
now reads the route modules' own readiness flags. The CORS allowlist held a
placeholder Chrome ID that would have rejected the real extension, and CORS
was the only guard despite the docstring's claim that no web page can reach
the service: a simple POST needs no preflight, and nothing checked Host. The
ID is configurable, Host is validated, and off-list origins are rejected
outright. A shared secret still needs a change on both sides of the wire; the
docstring now says so instead of implying otherwise.

None of this was caught because none of it ran: the bridge tests were never
collected, vitest had no test script, and tsc skipped test/. Worse, the
fixtures encoded the intended contract rather than the real one — containers
reporting no text, textLength scoped differently from linkTextLength — which
is exactly what hid the first and fourth bugs. test/helpers/tree.ts now
reproduces what the walker emits, and the suites build on it: 50 vitest tests
and 6 bridge tests, from 29 and 1.

The M1 layer remains unwired — no content entrypoint imports it yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtKEo9PFJMWbEo41C7WEQq
Code review of the branch turned up fifteen defects, most of them features
that ran to completion and produced no effect.

Extension:

* Hover queued a dwell timer per element the pointer crossed and never
  cleared the last one, so one mouse sweep fired dozens of identical
  translations — and discarded every reply for want of a response callback.
  Translations now render in a bubble under the block.
* The compose trigger stripped three trailing spaces from a field that held
  two: the third keystroke was preventDefault()ed before it landed.
* Audio subtitles routed the video's audio into an AudioContext connected
  only to a capture destination, muting playback for as long as they were on,
  and rendered the raw ASR transcript because nothing translated it. The tap
  is now cached per element (a second createMediaElementSource throws), keeps
  ctx.destination connected, configures ASR with the *source* language and
  the format actually sent, and translates each finalised utterance.
* Cue translations aborted a controller nobody was given, so a slow cue
  painted over the cue after it.
* Image overlays looked images up by `src` attribute while holding a resolved
  URL, and positioned the canvas against a parent that was not a containing
  block. Both fixed; quads are covered by their bounding box.
* MV3 service workers have no FileReader, so cross-origin image fetches always
  resolved null; and sendMessage is JSON, so a document's ArrayBuffer arrived
  as {} and uploaded "[object Object]".
* Site profiles matched unanchored substrings: /x\.com/ claimed netflix.com,
  and the news profile claimed any URL containing "history".
* The comic reader ignored its own config argument.

Bridge:

* The glossary's `setdefault("", "")` made its fallback unreachable and put an
  empty term in every prompt; the wiki loader named a module that does not
  exist and was never awaited.
* Document uploads joined the client's filename onto a temp path, and
  pause/resume/cancel only wrote to a dict the job never read — a cancelled
  job finished and reported success.
* /ocr advertised itself through /health while returning a placeholder string
  for the LLM to translate. It now runs xian-vl's PP-OCRv5 reader, and says
  501 when the optional extra is absent.
* The cache tests wrote fixtures into MAGE's real session store, which the
  bridge would then serve to the extension as genuine hits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013W8fNdXDZ3Ux4V2FTaD2Kx
The branch built the logic for bilingual pages, hover, compose, site profiles,
subtitles, images, comics, documents and the glossary, and unit-tested all of
it — but the content script still wires up only selection translation, so none
of it is reachable from the browser. Marking those 📋 understated the work and
marking them ✅ would have been a lie, so there is now a 🧩 state for "built and
tested, not yet reachable from the UI", and a sentence saying what is left.

Also: document the bridge service that M5–M7 depend on (what it is, how to run
it, and that OCR needs the xian-vl[ocr] extra or answers 501), correct two
claims that read as present tense but are not — MAGE does not start the bridge
yet, and the extension does not probe /health — note that document jobs still
return mock translations, and bring the architecture tree up to date with the
subdirectories the milestones added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013W8fNdXDZ3Ux4V2FTaD2Kx
Everything the branch built was reachable only from its own unit tests: the
content script imported none of it and the popup had no controls. This makes
`entrypoints/content.ts` the composition root — it owns no behaviour, decides
which features are on from the settings, routes the messages that arrive from
the background, and re-applies the set when the settings change, so a toggle
takes effect in tabs that are already open. Each feature hands back a detach
function; the selection translator that used to live in content.ts moves to
content/selection.ts unchanged.

New: content/page.ts (walk → site profile or article scorer → segments →
background → inject, then keep translating what a feed adds) and
content/subtitles/index.ts (per-video sessions, keeping up with players that
mount their <video> late).

The background becomes the only code that touches the network, because it has
to be: an MV3 content script's fetch is judged by the page's CORS policy, not
the extension's, so images.ts calling Lemonade and the bridge directly could
never have worked on a real site. It also now consults the caches before the
model — memory first, then the store shared with MAGE and Luduan — feeds the
glossary and expertise into every prompt, drops cached rows when a glossary
edit changes the prompts, probes the bridge's /health so the popup can hide
what has no backend, and carries a second context-menu entry for images.

The audio subtitle path was written against a protocol Lemonade does not
speak: it opened `http://…/realtime` as a WebSocket (a SyntaxError), sent a
`configure` message and webm/opus blobs, and read a `transcript` field. The
real `WS /realtime` wants `ws://…/v1/realtime?model=`, `session.update`, and
base64 PCM16 at 16 kHz mono — the only format it accepts — and answers with
`conversation.item.input_audio_transcription.completed`. Rewritten to that,
with the resampling and encoding under test, and the source still connected to
the speakers.

The popup grows the controls all of this needs: translate/undo for the page,
a switch per reading feature, domain expertise, a document picker with job
progress, and a glossary editor — the last two shown only when the bridge says
it can serve them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013W8fNdXDZ3Ux4V2FTaD2Kx
The porting contract was written when MASHA was a selection translator and was
accurate then. M1–M7 happened; nobody revisited it. It claimed PlatformBridge
was six methods (it is 24), that core/ was ~150 lines of obvious logic (1,744),
and — the one that would actually mislead someone into starting — that a Python
plugin could import the canonical definitions from xian-vl instead of mirroring
core/. Only constants.ts, prompt.ts and translator.ts trace to Python, 337
lines of the 1,744. The article scorer, segmentation, the batch pipeline, site
profiles, script detection, cue merging and the compose state machine have no
Python counterpart anywhere in the monorepo, and a port has to bring all of
them across.

So: the interface is tiered, because selection translation alone is a working
translator and is the port worth doing first; the 1,400 lines with no Python
twin are listed with what each one decides; and the selection JS now points at
content/selection.ts, which is where it moved when content.ts became the
composition root.

Adds what was never there: the bridge (and that a Python plugin can import
xian.ocr directly rather than going over loopback HTTP as the extension must),
the three DOM contracts a port has to honour exactly or its own undo breaks,
and the reason site-profile regexes need re-expressing rather than
transliterating. The network note keeps mixed content and adds the rule this
branch learned the hard way — an MV3 content script's fetch is judged by the
page's CORS policy, not the extension's.

"Mirror" now reads as same shape, not same values: MASHA defaults to Auto
where MAGE defaults to Chinese, and a browser has constants a game has no use
for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013W8fNdXDZ3Ux4V2FTaD2Kx
@clemperorpenguin
clemperorpenguin merged commit a841480 into main Sep 8, 2026
3 of 5 checks passed
tmpdir = tempfile.mkdtemp(prefix="masha_doc_")
file_path = os.path.join(tmpdir, _safe_filename(file.filename, f"document.{format}"))
content = await file.read()
with open(file_path, "wb") as f:
@clemperorpenguin
clemperorpenguin deleted the masha branch September 8, 2026 21:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants