Written for a coding agent (or human) to execute in order. Do not skip gates. Each milestone ends with runnable commands and a checklist; when a gate fails, fix that milestone — don't start the next.
Strategy in one paragraph: the Python Docling bootstrap proved reading quality and froze the contract (M1, done); the project has now pivoted to a portable TypeScript + ONNX engine (engine-js/, granite-docling-258M under transformers.js — pure JS, no Python, no sidecar) as the primary trajectory. Build the TS engine core + Node CLI (M2), reach fixture parity with the Python oracle including a numeric-fidelity cross-check (M3), then package for humans as an Obsidian plugin (M4), then EPUB delivery (M5) and a browser extension (M6) — with on-device mobile as the unplanned end goal. Python is retained as a reference oracle, not deleted. Rationale: README.md and docs/perf-and-portability.md.
- Machine: MacBook Air M4, 32 GB (Apple Silicon).
- Python managed with uv (
uv sync,uv run pdf2md ...). No bare pip/venv. - Models: document every download (name, size, cache path) in
docs/models.md. Offline must be real after cache warm. out/is gitignored scratch. Fixture PDFs are gitignored;fixtures/MANIFEST.mdrecords sources + a fetch script.
paper.pdf # the source, e.g. 1706.03762v7.pdf
paper/ # package folder, named from the PDF filename — drops into a vault as-is
paper.md # YAML frontmatter properties (title, source, author, created,
# tags — Obsidian web-clipper style), then a conversion-warning
# banner *if any*, then headings, MD/HTML tables, $LaTeX$ math
# (MathJax-safe), figure refs
images/ # extracted figures, stable names
meta.json # source, title, engine id+version, page count, timings, warnings
paper.epub # M5
Named from the source PDF, not the extracted title. The title depends on what the
model read, so it varied by engine and by run and threw away the identifier the reader
had actually filed the paper under; and a generic document.md made every converted
note in a vault share one name, which Obsidian's quick-switcher renders unusable. The
title is not lost — it stays in the frontmatter and in meta.title; it just stops being
a path component. Collisions now follow filenames rather than titles: two papers with
the same title no longer collide, two files with the same stem do.
images/ stays inside the package folder. A flat paper.md + paper.images/ was
considered and rejected — the images/ prefix is emitted by doctags.ts, three layers
below anything that knows the output filename, so a flat layout would have meant
threading an image-dir option through the parser, the assembler, both entry points and
the worker message protocol, plus percent-encoding link paths for names with spaces.
Warnings reach the reader, not just meta.json. A sidecar JSON is the wrong
place to tell someone that the text they are about to read is incomplete — nobody
opens it first. So warnings surface in three places at once: a
> [!warning]+ banner between the frontmatter and the body, an inline
> [!warning] Page N may be incomplete marker at the end of each damaged page,
and a conversion_warnings: N frontmatter property so a vault can be queried for
papers worth re-checking. All three are omitted entirely when the conversion is
clean. The inline marker exists because the markdown is reflowed and has no page
boundaries — a banner saying "page 4" is honest but unactionable on its own.
meta.json.engine labels the path honestly: python-docling-bootstrap (oracle) vs onnx-portable (shipping engine). Both write the same folder; the TS engine adds model, timings_ms, and execution_providers fields rather than inventing a parallel layout. Both also record md_path, since the markdown filename is no longer a constant readers can join on.
Goal: pdf2md convert paper.pdf --out out/paper/ on the Mac produces a Markdown package that someone who read the PDF would rather read in Obsidian.
Engine: Python Docling with figure extraction and formula enrichment, labeled bootstrap. This was the fast path to the existential question — is the quality there? — and it answered yes. Its lasting output is the frozen artifact contract + the engine-independent fixture suite, which now serve as the parity spec for the TS engine. Kept as the modular fallback and numeric cross-check; not extended further (the perf items below are moot now that the VLM replaces the slow CPU-bound formula/table stages).
- uv project:
pyproject.toml,src/pdf2md/,pdf2mdentry point (convert,version). -
convertalways writes<stem>.md+images/+meta.jsoninto a package folder named from the source PDF; the markdown opens with Obsidian-style YAML properties. (Originally title-named; amended 2026-07-26 — see the contract note above.) - Figures: extracted to
images/, referenced inline with captions. - Tables: Markdown tables. (HTML fallback for row/col spans: pending — no fixture has hit it yet.)
- Math:
$$LaTeX$$via Docling formula enrichment (--no-formulasto disable) + MathJax-repair pass (brace balancing,& & (N)→\tag{N},alignedwrapping). - Warn loudly on image-only pages (no silent empty success on scans).
- Fixtures + expectations (see
fixtures/MANIFEST.md):attention,bert(two-column, CC-BY),vae(equation-dense),ioannidis(medical, CC-BY). Each hasfixtures/expectations/<id>.jsonpass/fail checks (olmOCR-bench style) with strings validated against the raw PDF text layer — ground truth independent of the engine. Scanned fixture deferred with OCR.fixtures/private/*.pdf(gitignored) auto-discovered for smoke checks. -
tests/: contract tests + per-fixture expectation tests (title, headings, figures, math-block count, required phrases, exact numeric table cells, forbidden artifacts) + private-PDF smoke. Session-scoped conversion cache;-m "not slow"for the fast subset. 10 passed / 1 skipped. - [~] Perf — superseded by the pivot. The Python engine's CPU-bound formula/table stages (vae: 73 min) were the trigger to move to the single-VLM TS engine; optimizing the bootstrap is no longer on the critical path. The perf research (Track 1) is preserved in docs/perf-and-portability.md as context for the pivot.
- [→] LaTeX render-rate metric + human Obsidian verdict: carried into M3 (parity), where they measure the TS engine against this oracle rather than the retired bootstrap.
uv sync
uv run pdf2md convert fixtures/attention.pdf --out out/attention
open out/attention/attention.md # or open the folder as an Obsidian vault
uv run pytest -q| Check | Pass? |
|---|---|
Contract files always written; engine + timings in meta.json |
|
| Two-column fixture: reading order correct, no header/footer junk | |
Headings → real # hierarchy (Obsidian outline = paper TOC) |
|
Figures present in images/ and embedded with captions |
|
| Tables faithful — zero corrupted numeric cells | |
| Math renders as LaTeX in Obsidian (rate recorded) | |
| Scanned fixture fails loudly (OCR may be deferred, silence may not) | |
| Human verdict: prefer the MD for ≥4/5 fixtures |
Stop if Gate 1 fails — tune pipeline/models; nothing downstream matters without this.
The VLM-vs-modular experiment is no longer run as a separate Python/MLX study. The decision is made: adopt the VLM (granite-docling-258M) as the portable engine, in TypeScript + ONNX. The quality question M1.5 was meant to answer is now answered empirically by M3 — the TS engine is scored against the same fixture suite, with a numeric-fidelity cross-check guarding the "VLMs can invent table numbers" risk. Rationale: docs/perf-and-portability.md §3.
Goal: pdf2md-js convert paper.pdf --out out/ produces the same contract-valid vault package as
the Python oracle, using pure JS — pdf.js + granite-docling-258M ONNX (transformers.js) + a JS
DocTags→Markdown parser. No Python, no sidecar.
Engine: engine-js/ (meta.json.engine = "onnx-portable").
- Scaffold
engine-js/TS package:package.json,tsconfig, Node CLIpdf2md-js. -
doctags.ts— DocTags→Markdown (headings, text,$$LaTeX$$, OTSL tables,<picture>+caption,<loc_*>bboxes; drops page header/footer; flags dropped OTSL spans). Offline unit-tested. -
mathjax.ts— port of the bootstrap's_fix_formula/_clean_math. Offline unit-tested. -
frontmatter.ts/meta.ts— same artifact contract; addsmodel,timings_ms,execution_providers. -
pdf.ts— pdf.js raster + text layer + figure crops (@napi-rs/canvas). -
vlm.ts— transformers.js load ofonnx-community/granite-docling-258M-ONNX(device-aware dtype: fp32 on CPU, q4f16 on WebGPU), page image → DocTags;max_new_tokensrunaway guard. - First end-to-end convert proven on
attention.pdfpage 1: clean DocTags → Markdown, title extracted, running-header dropped, 28.4/41.8 BLEU numbers preserved. Four integration bugs fixed (white canvas fill, processor arg order, device-aware dtype, cwd).timings_ms+execution_providersrecorded inmeta.json. - Measured CPU dtype (perf doc §4b): fp32 is the only correct dtype on the ORT CPU provider (q4f16/q8 both garble); ~4 min/page. Usable speed = WebGPU (M4/M6), not CPU quant.
- Full-document convert + fixture parity → M3 (fp32 is slow but correct; run in background).
cd engine-js
npm install
npm run typecheck
npm run test:offline # DocTags + MathJax, no download
npm run cli -- convert ../fixtures/attention.pdf --out out/ # downloads model on first run| Check | Pass? |
|---|---|
pdf2md-js convert writes <stem>.md + images/ + meta.json (engine: onnx-portable) |
✅ |
| Offline unit tests green (DocTags parser, MathJax repairs) | ✅ |
One fixture converts end-to-end; math as $$LaTeX$$, headings hierarchical |
✅ page 1 (figures/full-doc → M3) |
meta.json records model, per-stage timings, execution providers |
✅ |
Goal: the TS engine matches the Python oracle's quality on the shared fixture suite — this is where "is the VLM good enough?" is answered, in TS, against ground truth validated on the raw PDF text layer.
- Fixture parity achieved (2026-07-24). All four fixtures (attention/bert/vae/ioannidis)
converted through
engine-js(CPU fp32) and the Python oracle, scored against the sharedfixtures/expectations/*.json: both engines pass all 7 checks on all 4 fixtures. Numeric table fidelity holds (attention 28.4/41.8, bert 93.2/86.7); on equation-dense vae the VLM produced more renderable math blocks than Docling (35 vs 31). - Degenerate-generation guard (commit
3ead976): repetition + wall-clock StoppingCriteria; fired in production exactly once (vae p11), turning a would-be hang into a flagged, recoverable page. Replaces the "cross-check" as the immediate never-silently-wrong mechanism. - Quantization sweep on CPU — dead-end (perf doc §4b): q4f16/q8 garble on the ORT CPU provider; fp32 is the CPU floor. WebGPU quant sweep deferred to M4/M6.
- Two gaps surfaced, deferred to M4-adjacent hardening (both flagged, not silent):
figure over-detection (VLM emits ~2× Docling's
<picture>count on bert/ioannidis), and OTSL merged-cell handling (recurring "merged cells rendered blank" warning → HTML fallback). - Numeric-fidelity cross-check vs the pdf.js text layer, LaTeX render-rate metric, human Obsidian verdict, 2-stage-variant fallback — carried forward as refinements (parity already met without them).
Gate 3: PASSED — the portable TS engine matches the oracle's quality scores on all four fixtures, numeric cells intact, degenerate pages caught loudly. The VLM route is validated; the Python modular path stays only as an oracle/fallback.
Goal: the empty-quadrant product: right-click a PDF in Obsidian → note + figures + math land in the vault. No API keys, no server, nothing uploads.
-
WebGPU path validated (
engine-js/web/, perf doc §4b): pdf.js + transformers.js + our JS DocTags parser convert a page in Electron 42 / Chrome 148 (= the Obsidian desktop renderer) at ~41 s/page, ~6× the CPU fp32 speed, quality-equal. Config: transformers.js pinned 3.7.5 (4.2.0 garbles), dtype{embed fp16, vision fp32, decoder fp32},device: "webgpu". -
Portable engine refactor (commit
783dff5):core/{types,convert,guard}.tshold the platform-agnostic pipeline (assembleDocument, no I/O);browser/{pdf,vlm,engine}.tsare renderer adapters with transformers.js + pdf.js injected (host picks the WebGPU-working version).index.tsis now a thin Node adapter. The web harness runs the real core (engine.js, 17 kB) — no duplication. tsc clean, 20/20 tests, node smoke ✓. -
Obsidian plugin scaffold (commit
604a373,plugin/): file-menu "Convert to Markdown" + command →convertPdfBrowser()on WebGPU → vault package; settings (output folder, max pages); esbuild → CJSmain.js. Bundles clean (engine import resolves).npm install && npm run buildto producemain.js; needs a WebGPU Obsidian + first-run model download. -
ORT runs on WebGPU inside Obsidian — blocker cleared. Two independent Node-detection traps in Obsidian's Node-integrated renderer, both now fixed and verified live:
- ✅ Backend selection. transformers.js read
process.release.name === "node"and picked its cpu-only onnxruntime-node backend (Unsupported device: "webgpu"). Fixed by an esbuild banner that renamesprocess.release.namebefore any bundled module evaluates, so transformers capturesIS_NODE_ENV=false(c583fab). - ✅ Wasm glue loading (the real blocker). ORT only uses the emscripten glue already inlined
in its bundle when no
wasmPathsoverride is set (importWasmModule()inwasm-utils-import.ts). transformers.js unconditionally sets a jsdelivrwasmPathsprefix at import time, so ORT instead dynamic-imported the standaloneort-wasm-simd-threaded.jsep.mjsfrom the CDN — whose top-level await epilogue,var isNode = typeof globalThis.process?.versions?.node == 'string'; if (isNode) isPthread = (await import('worker_threads'))…, has noprocess.type !== "renderer"guard (unlike the check in the module body, which does). Unresolvable specifier → module never evaluates → "no available backend found." Unfixable at runtime (Electron makesprocess.versions.nodenon-writable) and invisible to esbuildonLoad(the file is fetched from a CDN, never bundled) — which is why the earlier build-time patch attempt never fired. Fix:plugin/ort-env.tsinlines a build-time-patched copy of the glue as text, serves it from a blob URL, and setswasmPaths = { mjs, wasm }. The esbuild plugin throws if the patched epilogue stops matching, so an ORT bump can't silently reintroduce it.
- Verified live: a 94-byte
AddONNX model runs on the webgpu EP in Obsidian 1.12.7 in ~1.5 s cold ([11,22,33,44]); flippingortSmokeback to the old config reproduces the exact original error on demand.
- ✅ Backend selection. transformers.js read
-
Headless Obsidian driving (
plugin/tools/obsidian-drive.mjs). Conversion is non-interactive, so it needs no human in the loop: the tool launches an isolated Obsidian (--user-data-dir+--remote-debugging-port, scratch vault at.obsidian-test/, runs alongside the real session), attaches over CDP, evaluates JS in the plugin's context and streams the renderer console back. Withwindow.__pdf2md(plugin/probe.ts:envReport/ortSmoke/convertPath) the whole build → install → reload → run → read-result loop is scripted. This is the debugging capability the previous session lacked — every finding above was produced without touching the UI. -
RESOLVED — "WebGPU fails after a few pages" was pdf.js + a hidden window, not WebGPU. Chromium never fires
requestAnimationFramewhile a document is hidden, and pdf.js schedules every continuation chunk of a page raster through it (_scheduleNext, display intent only —useRequestAnimationFrame: !intentPrint). So with Obsidian minimised, hidden or in the background,page.render()never resolves: no error, no timeout, no CPU, no GPU. The conversion parks forever on whatever page was in flight when the user switched away.-
Bisected in one hidden renderer (
plugin/tools/render-stall-probe.js, each call raced against its own timeout so a stall names itself):call result getDocumentok, 137 ms getPage(1)ok, 1 ms page.renderTIMED OUT after 120 s getImageDataok, 24 ms getTextContentok, 27 ms page.renderwith rAF →setTimeoutrendered in 192 ms Every other pdf.js worker round-trip is healthy; only the rAF-driven render stalls, and shimming the scheduler renders the identical page in 192 ms.
-
Fix:
withTimerScheduling()inengine-js/src/browser/pdf.tsroutesrequestAnimationFrame(andcancelAnimationFrame) through timers for the duration of each render. We rasterise to an offscreen canvas and read the pixels straight back, so frame timing buys nothing.RenderTask.onContinueis not an alternative: the continuation it hands back schedules through rAF too. No-op in Node, which has no rAF and takes pdf.js's microtask path — which is exactly why the CPU parity suite always passed while WebGPU timed out, and why this was not reproducible in Node by construction. transformers.js and the ORT glue reference rAF zero times, so pdf.js was the whole hang surface. -
This explains the whole history: the 2681 s parity timeout (
.obsidian-test/parity-webgpu.json) was headless-driven with the window hidden; the complete 15-page WebGPU conversion in.obsidian-test/vault/full/succeeded because that window happened to be visible; and "a 2-page run looks fine, a 15-page run does not" was the user watching the first pages and then switching away. -
There is no per-page degradation. 8 pages of
attention.pdfon WebGPU, token-capped at 128 so every page does identical work (__pdf2md.benchPages, the multi-page twin ofbenchPage— one model instance held across pages, whichbenchPagecannot see): 5.55 / 5.56 / 5.88 / 5.77 / 5.75 / 6.02 / 5.62 / 5.76 tok/s, RSS 3281→3302 MB. Flat within ±4%, RSS within 2%. The Node CPU control over the same 8 pages is flat too. The "cost per page climbs steeply" note incore/convert.tswas this stall misread — a page that never finishes reads as an infinitely expensive one — and has been corrected in place. -
Cost structure (from the new per-decode-step heartbeat,
BrowserVlmOptions.onStep): step 1 at ~15 s, steps 16→128 in ~5 s. Prefill ~15 s/page dominates; decode runs ~24 tok/s. A 128-token cap therefore understates throughput badly — the headline 5.8 tok/s is mostly amortised prefill. Prefill, not decode, is where WebGPU optimisation should go. -
Open, and separate from the hang: today's WebGPU throughput is ~5.6 tok/s, not the 12.37 tok/s recorded above — and at a 128-token cap that gap is entirely prefill (128 tok at 12.37 tok/s is 10.3 s, less than the ~15 s prefill measured here, so the earlier run must have prefilled far faster). Machine idle both times, same stack. Not visibility: a repeat with the window brought to the front measured 4.97-5.63 tok/s, though macOS re-hid the window mid-run (
Page.bringToFrontand AppleScript both failed to hold it visible), so visible-vs-hidden is strictly speaking still unmeasured. Suspect shader/pipeline cache state (DawnWebGPUCache) or a dtype difference in the vision encoder. Worth one bisect before trusting either number. -
Lesson (companion to the idle-machine one): an async pipeline needs a heartbeat, not just totals. A stalled
generate()and a slow one are the same silence from outside, and the wall-clock guard reported "slow page" for what was really "never scheduled".onStep+ phase logs turned a 45-minute mystery into a 137 ms/1 ms/timeout table.
-
-
RESOLVED — the throughput scare was measurement error. Everything in the struck-through block below was measured while an unrelated AI training run was saturating the machine (CPU + GPU contention and thermal throttling). Re-measured on 2026-07-25 with the machine settled (load < 2, Apple M4, 4P+6E), identical methodology on both sides (
engine-js/tools/bench.tsand__pdf2md.benchPage, both token-capped at 128 so neither can be cut short by a wall-clock guard):| backend | page 1 | page 2 | model load | |---------|--------|--------|------------| | Node CPU (onnxruntime-node, fp32) | 13.80 tok/s | 14.93 tok/s | 2.9 s | | **Obsidian WebGPU (fp32)** | **12.37 tok/s** | **12.57 tok/s** | 0.6-1.1 s |- WebGPU and native CPU are within ~15% of each other. The earlier "WebGPU is ~10x slower" finding was an artifact: WebGPU measured 1.12 tok/s under contention vs 12.4 tok/s idle — an 11x distortion. The CPU side was barely affected (the 126 s full page ≈ 1700 tokens at ~14 tok/s), which is exactly why the comparison looked so lopsided.
- Consequence: the onnxruntime-node pivot is unnecessary. No per-platform native binaries, no change to the pure-JS distribution story. The WebGPU path stays.
- End-to-end on an idle machine: 2 pages of
attention.pdfin 65 s (~32 s/page), 7207 chars, both pages reaching EOS with no truncation warning. That is consistent with the original M4-probe figure of ~41 s/page, which was right all along. Gate 4 conversion quality is met. - Lesson for future benchmarking: always confirm the machine is idle first, and prefer token-capped runs — a wall-clock-capped run silently reports "truncated page" for what is really "loaded machine", which is what sent this investigation down a blind alley.
- Tooling lesson: a completed 57-minute "hang" turned out to be the driver, not the plugin —
one
Runtime.evaluateheld open for the whole conversion, whose reply was lost when the socket dropped.obsidian-drive.mjsnow rejects in-flight commands on socket close, andrun/run-filekick the job into a page global and poll for it. Use those for anything minutes-long. - Still valid from that work (correctness results, not timing-dependent): any f16 dtype garbles
to
!!!on both ORT 1.22 and 1.26, so fp32 stays pinned; q8/q4 are numerically fine. The tok/s figures in that dtype table are contaminated and would need re-measuring to be trusted.
Superseded (contaminated measurements, kept for the audit trail)
- [~] OPEN: WebGPU throughput in Obsidian is ~0.72 tok/s — the real remaining blocker. Measured with
__pdf2md.benchPage(token-capped, so it isolates throughput from the wall-clock guard): 32 tokens in 44.3 s on page 1 ofattention.pdf, model load 4.4 s (cached), render 0.6 s. Output is correct (<page_header><loc_14>…arXiv:1706.03762v7 [cs) — so this is not the degenerate- generation problem and not a termination bug; the earlier "hangs" were simply a dense page needing thousands of tokens at ~1 tok/s (30–60 min/page).-
Contradicts the M4-probe harness measurement of ~41 s/page on Electron 42 by ~2 orders of magnitude. Either the harness figure isn't comparable or something differs in Obsidian's renderer.
-
Not a hardware limit: the adapter is real (
apple/metal-3, not a fallback) and advertisesshader-f16,subgroups, and 4 GBmaxStorageBufferBindingSize. So the fp32 decoder is a choice, not a constraint. -
Cost split (8-token vs 32-token run, ORT 1.26): prefill ~15 s one-time, decode ~0.41 s/token (~2.4 tok/s). Prompt is 1142 tokens at 1224x1584 — not an Idefics3 image-split blowup, so the prefix is not the problem. Pages are decode-dominated → ~10 min/page.
-
device:"wasm"control: <16 tokens in ~700 s, i.e. <0.02 tok/s. WebGPU is doing real work (~50x wasm), so wholesale CPU fallback is not the explanation. -
dtype matrix (page 1, 32-token cap, ORT 1.26). Any f16 path garbles to
!!!(repetition guard fires at 3 tokens) on both ORT 1.22 and 1.26 — so it is the model's fp16 numerics on WebGPU, not a runtime version. int8/int4 are numerically fine but no faster:dtype tok/s output fp32 1.12 ✅ correct fp16 0.16 ❌ !!!q4f16 0.19 ❌ !!!q8 0.30 ✅ correct q4 1.34 ✅ correct (needs fixture validation) -
So no dtype rescues it: best is ~1.3 tok/s, and a 258M decoder on an M-series GPU should be ~10-30x that. The gap is still unexplained.
-
- [!] DECISION POINT: native CPU beats WebGPU by ~10x, and it runs inside Obsidian. Two controls
on the same machine, same page, same core:
- Node CLI (onnxruntime-node, CPU, ~381% CPU): page 1 complete, EOS reached cleanly, 2937 chars of markdown, 126 s total (incl. model load + render).
- Obsidian WebGPU: 515 chars after 120 s and still generating; ~2.4 tok/s steady state.
- i.e. CPU produced ~5.7x more output in the same wall-clock and finished. This inverts the M4-probe entry below ("WebGPU validated … ~6x CPU, ~41 s/page"); that figure does not reproduce.
onnxruntime-nodeloads and runs in Obsidian's renderer. Verified live:require()of the package from the renderer returns a working module and runs the 94-byte Add model in 31 ms (vs ~800-1500 ms on WebGPU). N-API (napi-v6) is ABI-stable across Node and Electron, and Obsidian's renderer has Node integration — so the native backend is available to the plugin.- This makes the whole WebGPU apparatus optional: no
process.release.namebanner spoof, no glue patch, no CDN wasm — and ~10x the throughput. - Open cost: onnxruntime-node ships platform-specific native binaries (darwin-arm64/x64,
win32-x64, linux-x64; tens of MB each). Obsidian community plugins are normally pure JS, so this
affects distribution and catalog acceptability. Build change needed too: bundle transformers'
node entry and mark
onnxruntime-nodeexternal so it resolves at runtime. - Remaining WebGPU diagnostics (only worth doing if we stay on WebGPU): per-node EP assignment
via ORT verbose logging (behind the
VerifyEachNodeIsAssignedToAnEpwarning), and whether Obsidian's Electron launch flags constrain WebGPU.
Note: the onnxruntime-node-in-the-renderer result above is still true (it loads and runs — N-API
is ABI-stable across Node and Electron), it is just no longer motivated: WebGPU performs comparably
without the per-platform binaries. Keep it in the back pocket if a machine ever lacks WebGPU.
-
Adopt from
cavi-ai/claude-obsidian(reviewed 2026-07-24, transformers 4.2.0 + ORT 1.26):env.useWasmCache = true(4.x) caches the ORT sidecar binaries in the Cache API → fully offline after first run, dropping our runtime jsdelivr dependency for the 4 MB wasm; and their probe → warm-up inference → fall back to wasm loader pattern (we commit todevice:"webgpu"with no verification or fallback). Their Blob-URL worker is worth copying for UI responsiveness but is not a fix for Node-detection: Obsidian runs with--node-integration-in-worker, soprocessexists in workers too (they need theirforceWebEnvshim inside the worker) — this retires candidate (c) from the earlier blocker analysis. -
Consider transformers 4.x upgrade. ORT ≥ ~1.24–1.26 adds the missing renderer guard (
globalThis.process?.type != "renderer") upstream, which would makeplugin/ort-env.ts's glue patch unnecessary (verified: guard absent in 1.22/1.23, present in 1.26). Major bump touchingAutoProcessor/AutoModelForVision2Seq/generate/StoppingCriteria; gate it on re-running the 4-fixture parity suite that validated M3 on 3.7.5. -
Gate 3 reconfirmed on the current stack (transformers 4.2.0 / ORT 1.26), CPU: 4/4 fixtures pass 8/8 checks.
attention,bert,vae,ioannidisall clean. (The vitest run reported 3 failures — those were its 900 s per-test timeout firing while conversion was still running, not check failures; every fixture printed 8/8 once it finished. Per-test timeout needs raising.)- Pages that tripped the degenerate-generation guard and were still good enough to pass: attention p4/p6/p9 and bert p16 (repetition), vae p2 and ioannidis p6 (timeout). Merged-cell table warnings on attention, bert, ioannidis — the known OTSL gap.
-
Memory is the binding constraint, not CPU — and on WebGPU it plateaus. Machine-requirements data from the CPU run:
-
Peak RSS during a single 15-page conversion: ~9.3 GB (sampled with
pswhile running). Settled RSS after the same conversion: ~2.5 GB — so peak, not steady state, is what sizes the box. -
RSS accumulates across conversions in one process: 2528 → 5156 → 7313 MB over successive fixtures, despite
convertPdfcallingvlm.dispose()+pdf.destroy(). ORT's native allocations are not fully returned. -
On this 16 GB M4 the second run drove swap to 7.5 GB and load to 18.5, and a conversion that took 439 s on a healthy machine took >35 min. Thrashing, not compute.
-
Measured on WebGPU (2026-07-25), and it does not reproduce there —
tools/memory-probe.js, three consecutive 2-page conversions in one Obsidian session, renderer RSS after each:run 1 run 2 run 3 worker 266 → 882 MB → 1006 MB → 1019 MB main thread 267 → 2780 MB → 2938 MB → 2885 MB Both plateau after the first conversion rather than climbing linearly, so the "renderer grows until it dies" risk was carried over from the CPU measurement and is not a property of the shipping path. The runaway figure above stands, but as a fact about
onnxruntime-node. -
The worker still cuts the settled footprint ~3× (1.0 GB vs 2.9 GB), because terminating the thread returns what ORT's
dispose()does not. That is the reason it is single-use. -
Still to measure: peak during conversion on WebGPU at 15 pages (the CPU figure was 9.3 GB; a 15-page worker run was observed at ~3.1 GB renderer RSS mid-conversion, unsampled), and whether peak scales with page count.
-
-
Conversion progress UI + cancel. The old UI showed a Notice that ended on "Downloading model … 100%" and then said nothing for the rest of the run, because
assembleDocumentemitted nothing between start and finish — no UI could have shown progress. Now:ConvertProgressticks per page (render/generate/page-done), an abort signal checked between pages and between decode steps (createGuard), and aConversionModalwith progress bar, page counter, live token count, elapsed, ETA and s/page.- Only the Cancel button cancels. Obsidian closed a modal by itself 32 s into a test run; under "any close = cancel" that would have silently discarded the work. Dismissing detaches instead.
- Detach/reattach: progress lives in a
ConversionStateboth views read, so the dialog can be closed and reopened with its clock intact (verified: reopened at 465 tokens / 29 s, continuing). The fallback is a status bar item, not a Notice — Obsidian Notices dismiss themselves on click, which ate the "click to reopen" affordance and left no way back. Command palette entry too. - The bar interpolates within a page (
plugin/tools/bar-probe.jssamples the<progress>element itself). Two earlier shapes both failed in the same way — looking stuck: updating only at page boundaries meant two steps on a 2-page document, and a linear ramp with a hard cap froze for 51 s when a page ran 103 s against the 45 s guess. Now it runs linearly to 80% of the page's share by the expected time, then asymptotically (still moving at 3× the estimate), with a high-water gate so it can never retreat. Result: 41 distinct values in 41 samples, monotonic, no freeze, vs 43-of-60 with a 51 s stall before. Estimate is the running per-page average; elapsed time, not tokens, because tokens sit at 0 through the ~15 s prefill. - Verified live (
plugin/tools/modal-probe.js, 2 pages ofbert.pdf): dialog ticks mid-page (211→392 tokens between samples), Escape detaches without cancelling, another note opens in 114 ms while converting, status-bar click restores the dialog, and Cancel settles in 503 ms mid-page. - Main-thread cost, measured: event-loop lag during conversion was 877 ms in one run and 4.5 ms in another. Both are real: prefill is one long block, decode is many short ones, so responsiveness swings within a page. Obsidian stays usable but can feel sluggish — the fix is the worker migration below, not the dialog.
-
Inference moved into a worker.
plugin/worker.ts(own esbuild bundle →dist/worker.js, started from a blob URL byworker-host.ts) runs the sameengine-jscore off the renderer's main thread. The worker is single-use: terminated after every conversion, which is the only reliable way to reclaim what ORT'sdispose()leaves behind. AConvert in a background threadsetting turns it off — that is the control arm, not a fallback.-
A/B on the same build, 2 pages of
attention.pdf,tools/worker-probe.jssampling event-loop lag on the main thread (a 50 ms interval whose observed period is the lag — totals cannot tell a blocked UI from a busy one, which is why the earlier "877 ms in one run, 4.5 ms in another" reading was ambiguous):worker main thread main-thread lag, p95 1.1 ms 17 ms main-thread lag, max 1.8-72 ms 533 ms stalls > 100 ms 0 8 throughput 16.6 tok/s 17.4 tok/s ~5% of throughput for a UI that never stutters, plus the memory result above. (Two worker runs, before and after the font fix below, gave 1.8 ms and 72 ms for the max — a single blip in one of them. p95 and the count of >100 ms stalls were stable across both, so those are the figures to trust; a lone max is one sample.)
-
It changes how pages are rasterized, and that nearly shipped a silent quality regression. A worker has no
document, sobrowser/pdf.tsnow supplies an OffscreenCanvas factory, a no-op filter factory, anddisableFontFace— pdf.js's own Node configuration, i.e. the one the fixture suite validated. ButdisableFontFacemakes pdf.js rasterize the standard 14 fonts from their outlines, so it needsstandardFontDataUrl; without it pdf.js does not fail, it warns once per glyph and rasterizes the page with holes in the text (25 dropped characters on page 1 ofattention.pdf). Downstream that is indistinguishable from a sparse page: the first worker runs quietly returned 7172 chars against the main thread's 7207.loadPdfBrowsernow throws rather than render without it (working rule 5). -
Supplying those URLs then tripped a second trap: pdf.js computes
useWorkerFetchfromisValidFetchUrl(cMapUrl, document.baseURI)— a baredocumentreference that throws ReferenceError off-main-thread, and only once both URLs are set, so it hides behind the very fix it accompanies. Set explicitly. -
Two corrections to assumptions made here.
requestAnimationFramedoes exist on Obsidian's worker global and does fire, so the pdf.js rAF shim stays on in workers rather than being redundant there. And pdf.js reports "Setting up fake worker" inside the worker — it cannot spawn its nested parsing worker — so parsing shares the conversion thread; still off the main thread, just not parallel with inference. Neither is a correctness problem; both were guesses that measurement reversed. -
Probe note worth keeping: pdf.js transfers the input buffer to its worker, so a
Uint8Arrayhanded to two consumers reaches the second detached and empty — and pdf.js answers that by hanging, not throwing. Two probe "timeouts" were this. -
Quality re-verified on the worker path, not assumed:
attentionend-to-end throughtools/fixture-parity.mjs(15 pp, real Obsidian, judged by the same shared checks as the CPU suite) — 8/8, includingrequired_table_cells, and the warning set is identical to the recorded CPU run (repetition guard on p4/p6/p9 + the known merged-cell warning). 31091 chars, 7 figures. Rasters still differ from the main thread by ~5-7% of pixels at mean 3 luma levels (tools/raster-diff-probe.js) — antialiasing from outline vs platform-font rendering, not missing content. The other three fixtures have not been re-run. -
Cancel settles in 74 ms mid-page (was ~500 ms on the main thread — the message lands immediately when the receiving thread isn't the blocked one).
-
-
Model download on first enable with disclosure + checksums (Smart Connections precedent); persist weights via IndexedDB/OPFS (Cache API unreliable in the harness pane).
-
Warnings surface to the reader, and the plugin writes
meta.json. The plugin had never written it (git log -Sconfirms: markdown + images only since it was scaffolded), so the contract was asymmetric and its warnings lived nowhere but the console and a 6-second Notice. Now:meta.jsonon the plugin path too (addingrun_modeand the per-page cost series), plus the banner / inline marker / frontmatter count described in the contract above. Verified live — a deliberately tight 20 s per-page budget produced 4 real warnings, all three surfaces rendered,meta.json1164 B. Re-scored afterwards: WebGPU parity still 8/8 onattention, Python contract 6 passed. The merged-cell warning now names the page (table on page 8 …) instead of being unlocatable. -
Vault craft: YAML frontmatter (title/authors/DOI/citekey), relative image links, optional provenance links to PDF pages (
[[paper.pdf#page=N]]). -
Fix engine gaps the plugin inherits: figure over-detection, OTSL merged-cell spans (M3 carry-over).
-
Engine alternative sketched, not run:
facebook/nougat-small(247M, cc-by-4.0, Swin + 4-layer mBART) as a cheaper VLM than granite-docling. Loads under the frameworks we already have. The hypothesis is decode shape, not size — 4 decoder layers vs 30, cross-attention instead of Idefics3 image tokens in the decoder context — estimated 2-4× on decode; there is no size win (~995 MB fp32). Structural cost is real: no bboxes at all, so no figure crops and no page provenance, and tables come back as LaTeXtabular. Phased plan with kill criteria (stop below 2×) and the parser seam it needs inassembleDocument: docs/spike-nougat.md. Results land as perf-doc §4d. -
Named, licensed and packaged for the community directory — the plugin is
Reflow. Distribution facts that changed the build: Obsidian installs onlymain.js,manifest.json,styles.cssfrom a GitHub release, sodist/worker.jswould never have reached a normal user — every directory install would have silently fallen back to main-thread conversion. The worker is now bundled tobuild/worker.jsand inlined intomain.jsas text; pdf.js's parsing worker is inlined the same way, so no executable code is fetched at runtime (measured what remains: HF weights, ORT's 24 MB wasm, pdf.js fonts/CMaps — all disclosed in plugin/README.md).main.jsis 4.7 MB as a result. Also: MIT LICENSE (matches Docling),styles.cssinstead of inline element styles, the debug shim resolved to a stub in release builds (nowindowglobal, no second ORT copy),Vault.processfor note writes, andversions.json+ a tag-checked release workflow.eslint-plugin-obsidianmd— what the directory's automated review runs against every published version since May 2026 — is error-clean. Re-verified live after the changes:attentionandbertpage 1 convert on both the worker and renderer paths, titles intact. -
Repository conforms to the submission checklist. Renamed to
reflow;manifest.jsonandversions.jsonmoved to the repository root (the directory reads the manifest at HEAD of the default branch) with the build staging the root copy intodist/so no second manifest can drift; rootREADME.mdnow leads with what the plugin is, how to install it and how to use it, rather than the project thesis;LICENSEat the root. The move cost one piece of tooling:eslint-plugin-obsidianmdreadsmanifest.jsonfrom its own cwd, so linting fromplugin/can no longer see it (and silently assumed a mobile plugin, which made Electron'sprocessan undefined global).plugin/check-manifest.mjsasserts the submission rules directly and gates the release workflow. -
Submitted; 0.1.0 failed review; 0.1.1 answers it. Two blocking Errors, both the same root cause: inline
eslint-disablecomments for anobsidianmd/*rule are rejected outright — the console logging they suppressed was only a Warning. Everything else in a very long report was a Warning or Recommendation. Fixed: directives gone (dev logs moved onto the probe, where the driver can assert on them instead of a human reading a console);authorUrl→ the org profile; artifact attestations on the release assets. The finding worth remembering: the review reads the bundle, not the intent.main.jscarriedrequire("fs")/path/osand the plugin was reported to users as able to "read and write any file on the system" — from 92 KB of onnxruntime emscripten glue inlined as a text string and, since ORT fixed the guard upstream ~1.24, never used at all. Now decided at build time and inlined only when a patch is genuinely needed. AlsoisEvalSupported: falsefor pdf.js, so a document's Type-4 shading functions are interpreted rather than compiled. -
0.1.1 review: Completed. Zero Errors. Both attestations verified (
main.js,styles.css), "Direct Filesystem Access" gone from Behavior, dependencies clean, health scored Excellent. What remains is all Warning/Recommendation and mostly known: theany/unsafe family across the untyped third-party surfaces;globalThisandwindow.setTimeouton worker code wherewindowdoes not exist; the console bridge we kept on purpose; and Dynamic Code Execution, which did not clear — exactly as predicted, since pdf.js's ownnew Function("")capability probe stays in the bundle even thoughisEvalSupported: falsemeans we never compile anything from a document. Removing it would mean patching upstream pdf.js; not worth it. -
getSettingDefinitions()for 0.2.0. Settings do not appear in Obsidian's settings search on 1.13+. Backward compatible (older versions ignore the method), but it is a rewrite of the settings tab. Trivial companions to fold in: two unused locals (mathjax.ts:19,probe.ts:256) and an unnecessary regex escape (doctags.ts:321). -
Windows and Linux remain untested. The code no longer assumes WebGPU (probe → candidates → CPU fallback, all unit-tested against fake navigators), but nothing has run on either OS.
Gate 4: fresh Obsidian install → plugin → converted paper in vault in under 2 minutes, offline after model cache.
Goal: one command from the validated Markdown package to a book on each reader, honest about each path.
-
--epub: MD + images → EPUB (TOC from headings,img { max-width:100% }, math → MathML with image fallback for e-ink). - Acceptance instrument: Kindle Previewer 3 + Apple Books.
- Delivery helpers:
--send books(open -a Books, iCloud syncs);--send kindle(Send to Kindle — document the Amazon cloud hop); Boox/Kobo via USB/BooxDrop (fully local). - Compare against Amazon Convert + Calibre on ≥2 real PDFs; note results.
Gate 5: reflow + figures + navigable TOC verified in Kindle Previewer and Books; clearly better than Amazon Convert; delivery matrix documented.
Goal: the portable engine in the browser — proof that the JS core travels.
- MV3 extension: engine in an offscreen document / side panel (service workers can't run
ORT/WebGPU); bundle ORT
.wasm, setwasmPaths;unlimitedStorage+ OPFS/IndexedDB for the ~190 MB q4f16 weights. - Degrade to a pdf.js text-only fast path on non-WebGPU machines (VLM-on-WASM is too slow).
Gate 6: convert a PDF client-side on a WebGPU machine; weights cached across sessions.
Boox Palma / Android on-device conversion and iOS share-sheet → convert → read. The nearer iOS path is a native app (share-sheet handoff to the main app — the ~120 MB share-extension memory cap blocks in-extension inference — plus MLX-Swift); the Obsidian mobile plugin waits on WKWebView WebGPU (iOS 26+). Unlocked only by Gates 2–4; gets its own plan (performance projection first) when the time comes. Nothing before that gates on mobile.
- uv for all Python; never bare pip. Keep
uv.lockcommitted. - Preserve the artifact contract; extend
meta.json, don't fork layouts. - Record timings (
wall_ms,pages) inmeta.jsonon every convert. - Document model downloads (size, URL, cache path) in
docs/models.md. - Never silently wrong: warn on dropped figures, image-only pages, failed formula parses.
- Marker stays experimental (license); Docling-family is the default.
- Append to the Progress log when a gate passes or fails.
| Date | Milestone | Gate | Notes |
|---|---|---|---|
| 2026-07-21 | — | — | Plan rewritten: Markdown-first (Obsidian quality → e-reader delivery → plugin → ONNX port → mobile later) |
| 2026-07-21 | M1 | — | Started: uv scaffold + Docling bootstrap |
| 2026-07-21 | M1 | — | First fixture converts end-to-end (attention.pdf: 15 pp, 6 figures, MD tables, LaTeX math; 177 s warm). Contract tests pass. Remaining for Gate 1: 4 more fixtures, numeric-fidelity + LaTeX-rate checks, human Obsidian verdicts |
| 2026-07-22 | M1 | — | Vault-ready output (title folder, frontmatter, MathJax-safe LaTeX). Fixture suite: attention/bert/vae/ioannidis with ground-truth expectation tests (olmOCR-bench style) — 10 passed. Titles, figures, tables, reading order all verified. ⚠ Perf finding: formula enrichment is per-formula and dominates — vae (equation-dense, 14 pp) took 73 min vs bert 48 s; investigate accelerator (MPS) settings / batching before Gate 1 |
| 2026-07-23 | M1 | — | Perf + portability research written up in docs/perf-and-portability.md (root causes: TableFormer/CodeFormula forced to CPU upstream; fix plan Track 1; granite-docling VLM + all-JS ONNX path). PLAN updated: perf work item in M1, new M1.5 engine experiment, M3/M4 route decision. Converted packages staged in out/ for human Obsidian review |
| 2026-07-23 | pivot | — | Pivoted to TypeScript + ONNX as the primary trajectory. Scaffolded engine-js/ (granite-docling-258M ONNX via transformers.js + pdf.js + hand-written DocTags→Markdown parser). LiteRT.js evaluated and declined (perf doc §4a). |
| 2026-07-23 | M2 | Gate 2 ✅ | First end-to-end convert working (attention p1). Fixed 4 integration bugs (white canvas fill, processor arg order, device-aware dtype, cwd). Measured CPU dtype: fp32 only correct option (q4f16/q8 garble), ~4 min/page. |
| 2026-07-23 | M4-probe | — | WebGPU validated in Electron 42 (the Obsidian renderer env): ~41 s/page, ~6× CPU, quality-equal. Config from IBM's Space: transformers.js 3.7.5 + fp32 decoder (q4f16 garbles on WebGPU too). Docs corrected (perf §4b, models.md). |
| 2026-07-24 | M3 | Gate 3 ✅ | Fixture parity achieved. All 4 fixtures × both engines pass all 7 ground-truth checks; numeric cells intact; vae math 35 vs 31. Degenerate-generation guard added (3ead976), fired in production on vae p11. Gaps for later: figure over-detection, OTSL merged cells. → M4. |
| 2026-07-24 | M4 | — | Portable engine refactor (783dff5): core/ (assembleDocument, no I/O) + browser/ adapters (DI transformers/pdf.js); web harness runs the real core. Obsidian plugin scaffolded (604a373, plugin/) — file-menu convert → vault package on WebGPU; bundles clean. Remaining Gate 4: live in-Obsidian validation + IndexedDB weight cache. |
| 2026-07-24 | M4 | — | Plugin install workflow (ab55dd2: dist/ + install.mjs). Live-in-Obsidian debugging: fixed transformers backend selection (WebGPU now chosen, c583fab); blocked on onnxruntime-web threaded-wasm doing import('worker_threads') in the renderer (emscripten Node mis-detection). Build-time glue patch attempted, didn't fire — WIP. Diagnosis + candidate fixes logged in M4. |
| 2026-07-25 | M4 | — | "WebGPU fails after a few pages" solved — it was never WebGPU. pdf.js schedules page-raster continuations through requestAnimationFrame, which Chromium never fires in a hidden document, so page.render() parked forever whenever Obsidian was minimised/backgrounded (0% CPU, no error). Bisected in a hidden renderer: getDocument 137 ms, getPage 1 ms, page.render timed out at 120 s, same page renders in 192 ms with rAF shimmed. Fixed by withTimerScheduling() (browser/pdf.ts); no-op in Node, which is why the CPU suite always passed and why this was unreproducible there by construction. Also disproved the "cost per page climbs steeply on WebGPU" note: token-capped, 8 pages are flat within ±4% (RSS within 2%), Node CPU likewise. Prefill (~15 s/page) dominates; decode ~24 tok/s. |
| 2026-07-25 | — | — | Nougat spike sketched (docs/spike-nougat.md, perf doc §4c): facebook/nougat-small as a cheaper VLM. Proposal only — nothing measured yet beyond published configs and the HF export inventory. Phase gates and kill criteria written down first, so a negative result is publishable the way §4a's LiteRT verdict was. |
| 2026-07-27 | M4 | — | Conversion problems now reach the reader. meta.json recorded warnings, but nobody opens a sidecar JSON before reading a paper, so a lossy conversion produced a note that looked perfectly clean. Added a > [!warning]+ banner between frontmatter and body, an inline > [!warning] Page N may be incomplete marker at the end of each damaged page, and a conversion_warnings: N frontmatter property — all omitted when the conversion is clean. The inline marker is the load-bearing one: the markdown is reflowed and has no page boundaries, so a banner saying "page 4" cannot be acted on. Also closed a long-standing gap — the plugin had never written meta.json at all, leaving its warnings in the console and a 6 s Notice. Merged-cell warnings now name their page. 5 new offline tests (25 total); re-scored after the markdown changed: WebGPU parity 8/8, Python contract 6 passed. |
| 2026-07-26 | — | — | Artifact contract amended: packages are named from the source PDF, not the extracted title. 1706.03762v7.pdf → 1706.03762v7/1706.03762v7.md + images/ + meta.json. The title varied by engine and by run and discarded the identifier the reader filed the paper under, and a generic document.md made every converted note in a vault share one name. Title still lives in the frontmatter and meta.title. images/ deliberately stays inside the package folder: a flat <stem>.images/ would have needed the hardcoded images/ prefix in doctags.ts threaded through the parser, assembler, both entry points and the worker protocol, plus link percent-encoding — so this shape changes the three writers only and leaves the pipeline untouched (offline tests pass unedited). meta gains md_path so readers stop joining on a filename that is no longer constant; the title sanitizers are deleted. All three engines. |
| 2026-07-25 | M4 | — | Inference moved into a worker (plugin/worker.ts, own bundle, blob-URL start, single-use so ORT's leftovers die with the thread). Main-thread lag during conversion drops from p95 17 ms / 8 stalls >100 ms to p95 1.1 ms / zero, for ~5% of throughput; settled renderer RSS across three conversions ends at 1.0 GB instead of 2.9 GB; cancel settles in 74 ms instead of ~500 ms. Also disproved the accumulation blocker: on WebGPU memory plateaus after the first conversion in both modes — the 2528→5156→7313 MB runaway was the Node/CPU path. Nearly shipped a silent regression on the way: rendering without a document needs disableFontFace, which makes pdf.js rasterize the standard 14 fonts itself and drop glyphs one by one without standardFontDataUrl — a page with holes in the text, no error, only slightly short output (7172 vs 7207 chars). loadPdfBrowser now throws instead. attention re-verified end-to-end on the worker path: 8/8 checks, numeric table cells intact, same guard-firing pages as the CPU oracle. |
| 2026-07-25 | M4 | — | Progress UI + cancel. assembleDocument emitted nothing between start and finish, so the plugin's Notice froze on "Downloading model … 100%" for the whole run — no UI could have shown progress. Added ConvertProgress ticks, an abort signal honoured between pages and between decode steps, and a progress dialog (page, live token count, elapsed, ETA, cancel) that detaches to the status bar so the vault stays readable. Cancel settles in ~500 ms mid-page. Bar interpolates within a page on a running per-page average with an asymptotic tail — a linear-with-cap version froze for 51 s when a page overran its estimate. Verified live: 41 distinct bar values in 41 samples, monotonic, no freeze. |
| 2026-07-27 | M4 | — | Named Reflow and packaged for the community directory. The load-bearing discovery: Obsidian's installer downloads only main.js, manifest.json, styles.css from a release, so dist/worker.js — read back from the plugin folder at runtime — would have existed on developer machines and nowhere else, silently demoting every directory install to main-thread conversion. Worker now bundles to build/worker.js and is inlined into main.js as text; pdf.js's parsing worker likewise, so nothing executable is fetched at runtime (what remains, measured: HF weights, ORT's 24 MB wasm, pdf.js fonts/CMaps — all disclosed). Plus: MIT LICENSE, styles.css instead of inline styles, debug shim stubbed out of release builds (no window global, no second ORT copy), Vault.process, versions.json, tag-checked release workflow, and eslint-plugin-obsidianmd error-clean. Re-verified live on both paths after the rewrite (attention + bert p1, titles intact, 30-35 tok/s). Left open deliberately: the public-repo split with manifest.json at the root, then submission via community.obsidian.md. |
| 2026-07-27 | M4 | — | Windows/Linux support + a CPU fallback, and the fallback's ceiling found. The engine asked for device: "webgpu" unconditionally — correct on one machine, a raw Unsupported device: webgpu from inside transformers.js everywhere else. Now browser/device.ts probes first (adapter request, no download), the VLM walks an ordered candidate list and keeps the first that loads, and the resolved backend drives both the dtype (shader-f16 absent ⇒ fp32 embedding, the older-Intel-iGPU case) and the wasm thread count (1 for WebGPU, up to 4 for CPU). The progress dialog carries a red --text-error warning on the CPU path; executionProviders became a getter so meta.json records the backend ORT confirmed rather than the one we asked for. WebNN is deliberately not in the automatic order: navigator.ml is absent unless Obsidian is launched with --enable-features=WebMachineLearningNeuralNetwork (spike 4333ef2), which a plugin cannot set, so it would cost every user a failed probe to benefit nobody — opt-in via the settings dropdown instead. The finding that changes what the fallback promises: CPU is only ~1.7× slower than WebGPU here (bert p1: 60 s vs 34 s, 20.4 tok/s) but has a hard ceiling — attention p1 dies in 5 s with std::bad_alloc, in the worker and the renderer, on fresh windows. Not page size: the rasters are the same (1191×1684 vs 1224×1584). It is the Idefics3 processor's aspect-ratio tiling — bert 13 tiles / 878 prompt tokens, attention 17 / 1142 — and 17 tiles of fp32 activations plus ~1 GB of fp32 weights exceed wasm's 4 GB address space. The plugin says exactly that instead of surfacing std::bad_alloc. Untried: a lower render scale on the CPU path to cut tiles, priced against the fixture suite first. 15 new offline tests (40 total) against fake navigators, since none of the interesting hardware exists here. |
| 2026-07-28 | M4 | — | 0.1.0 failed review; 0.1.1 answers it. Two blocking Errors, one cause: inline eslint-disable comments for obsidianmd/* rules are rejected outright — the console logging they suppressed was only a Warning. Directives removed and the logging with them (the two dev-only console.logs became backendSpoof() / lastWorkerEnv() on the probe, so the driver can assert on them); the worker's warn/error bridge is relaxed in eslint.config.mjs where it is visible instead. The finding worth keeping: the review reads the bundle, not the intent. main.js carried require("fs")/path/os and the plugin was described to users as able to "read and write any file on the system" — all of it inside the onnxruntime emscripten glue, inlined as a text string and, since ORT fixed the renderer guard upstream ~1.24, never used at all. 92 KB of dead text bought a filesystem warning on a public scorecard. analyzeOrtGlue() now decides at build time and inlines only when a patch is needed (the loud failure on an unrecognised epilogue stays, so a future ORT bump still can't reintroduce the bug quietly); the only require() left in the bundle is require("obsidian"), and main.js drops to 4582566 B. Encoding the string to hide it would also have "worked" — and is obfuscation, which the developer policies prohibit. Also: pdf.js isEvalSupported: false on both paths so a document's Type-4 shading functions are interpreted, not compiled — priced first against the ground-truth suite (attention 8/8, 376 s, identical to baseline); authorUrl → org profile; release assets now carry SLSA provenance attestations (gh attestation verify ✓ at refs/tags/0.1.1). Released 0.1.1 artifacts re-verified end-to-end through the real command path in a clean vault. |
| 2026-07-28 | — | — | Bookkeeping, recorded because the history is misleading. Commit 6d802a8, whose message is entirely about the release pre-release flag, also contains ~1300 lines of the EPUB spike that was uncommitted in the tree at the time — engine-js/tools/md2epub.mjs, the formula bbox/LaTeX work in doctags.ts and its two new tests, meta.ts, plugin/tools/mathjax-probe.js, and a plugin/main.ts change. Swept in by a git add -A that nobody inspected. Left as-is deliberately: rewriting that commit would change the SHA of every descendant including 030ae89, which is what the 0.1.1 tag, the build-provenance attestation signed over refs/tags/0.1.1, and the pending directory review all point at. A misleading commit message is the cheaper of the two problems. |