docs: ADR + plan for a small video language model; ci: force MegaLinter auto-fix - #23
Conversation
Chrome's Prompt API (Gemini Nano) crashes 8 GB Chromebooks: its floor is >4 GB VRAM, or 16 GB RAM + 4 cores on CPU, plus 22 GB free disk, and the browser owns the model so the app cannot request anything smaller. Separately, the app advertises video support but extractVideoFrame() represents each video with a single still. Adds docs/adr/ (new convention) with ADR-0001 deciding to adopt SmolVLM2 256M/500M via the already-present @huggingface/transformers, in a worker, in three tiers. Its vision_config.hidden_size is 768 — the app's existing vector width — so pooling the vision tower that the captioner already runs yields the clustering vector from the same forward pass, dropping nomic-embed-text from the default path. VIDEO_LM_PLAN.md carries the six-milestone implementation plan, the model facts that drive the sizing, the risks, and the verification checklist. Documents only; no source changes, per the IMPROVEMENT_PLAN.md precedent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KY5o2ppXBdamroAWL3j8Qo
Matches what MegaLinter's MARKDOWN_MARKDOWN_TABLE_FORMATTER produces, so the branch does not drift from CI. Table padding only; no content change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KY5o2ppXBdamroAWL3j8Qo
Deploying media-clusterer with
|
| Latest commit: |
96a6ce3
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://050d81b0.media-clusterer.pages.dev |
| Branch Preview URL: | https://claude-video-language-model.media-clusterer.pages.dev |
APPLY_FIXES: all was already set in .mega-linter.yml, but the workflow never acted on the result — fixed files were only copied into the report artifact, so every run re-reported the same formatting drift. Adds APPLY_FIXES_EVENT: all / APPLY_FIXES_MODE: commit and a commit-back step. Checkout now targets the branch (not the PR merge commit) and persists credentials so the push can happen; contents: write is scoped to the job rather than the workflow. Fork PRs get a read-only token and have no head branch here, so FIX_BRANCH is empty for them: checkout falls back to its default ref and the commit step is skipped. They are still linted, just not fixed. MegaLinter itself is already on the latest release (v9.6.0); no bump. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KY5o2ppXBdamroAWL3j8Qo
CI status: MegaLinter red on the base branch too; Cloudflare build is not reproducible locallyMegaLinter — all four failures pre-date this PRI checked before touching anything. The MegaLinter run on
Same linters, same counts. This PR changes no source files, and the markdown linters — the only ones that look at what it actually adds — pass. gitleaks (1) — grype / osv-scanner / trivy (the same 2 vulns, triple-reported) — both transitive deps in
Both are fixable with Cloudflare Pages — builds clean locally
Since this PR adds only markdown, there is no plausible mechanism by which it breaks a Vite build. The failure detail is behind the Cloudflare dashboard, which I can't reach — if you open the log and paste the error I'll take it from there. My guess is a Pages-side config issue (missing Pushed on this branch
MegaLinter is already on the latest release, v9.6.0 — the pinned SHA One expectation to set: forcing auto-fix will not turn this check green. All four remaining failures are security findings that no formatter can rewrite — they need the key rotated and the two deps bumped. Generated by Claude Code |
Cloudflare Pages: two build paths, and only the dashboard-configured one is brokenDiagnosis for the This project has two independent build paths pointed at the same Pages project:
Path 1 succeeded on every commit of this branch:
So on the same commits, the same It also builds clean locally at Why I'm not fixing it hereThe failure is in the Pages project settings, not in any file in this repo. The usual cause for a project created via
Cloudflare's check posts only a dashboard link and no error text — Status of this PREverything it is responsible for passes: Generated by Claude Code |
The commit-back step added in the previous commit never ran. MegaLinter exits non-zero whenever any linter reports an error — here the four security scanners always do — and a failed step skips every later step by default, so has_updated_sources was 1 but nothing was ever pushed. Guard with !cancelled() so fixes are committed regardless of lint outcome, while a cancelled run still skips the push. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KY5o2ppXBdamroAWL3j8Qo
The first auto-fix commit (6d52b56) correctly fixed AGENT.md's markdown, but also swept in megalinter-reports/ — MegaLinter's run output, including a duplicate copy of AGENT.md under updated_sources/. That directory is regenerated every run and already uploaded as a build artifact, so it has no business in git. Adds it to .gitignore, untracks the committed copies, and constrains the commit step with file_pattern so the reports can never be picked up again even if the ignore rule is missed. Also pins commit_author: the action defaults it to github.actor, which attributed the bot's commit to a human who did not write it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KY5o2ppXBdamroAWL3j8Qo
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 33131ff6a1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # The follow-up push re-runs this workflow once; that run finds nothing | ||
| # left to fix, so it converges rather than looping. |
There was a problem hiding this comment.
Re-run checks on the auto-fix commit
When a same-repository PR or main run applies fixes, this push uses the persisted repository GITHUB_TOKEN, but events generated by that token do not trigger another workflow run. The generated commit therefore receives no MegaLinter validation, contrary to this convergence assumption; on a PR it may also become the new head without required checks. Use a GitHub App/PAT that can trigger workflows, or explicitly validate the rewritten tree before pushing. See GitHub's GITHUB_TOKEN documentation.
Useful? React with 👍 / 👎.
| **Search degrades deliberately.** `enableTextSearch` (`src/types.ts:53`) already gates the text-model | ||
| download. Keep the flag, flip the default to off, and route default search through a lexical index over | ||
| captions. Users who want cosine semantic search turn it on and get exactly today's behaviour. |
There was a problem hiding this comment.
Compare semantic queries in a compatible vector space
For any smolvlm2-* tier, state.vectors will contain pooled SigLIP vision features, while today's embedText() produces Nomic text vectors and searchImages() directly compares those vectors in src/app.ts:623-654. As the ADR itself notes at lines 131-133, these spaces are not aligned, so enabling this option cannot restore today's behavior and will instead return meaningless rankings. The plan needs either Nomic search_document vectors for the captions or a text encoder aligned with the chosen vision vectors.
Useful? React with 👍 / 👎.
| Keep `extractVideoFrame()` as `extractVideoFrames(file, 1)[0]` so the thumbnail path (`:606`) is | ||
| unaffected. Keep the `pLimit(4)` wrapper (`:678`) at the *file* level, not the frame level. |
There was a problem hiding this comment.
Preserve a low-resolution thumbnail extraction path
For folders containing videos, the existing thumbnail path calls extractVideoFrame() at src/app.ts:606; making it a wrapper around extractVideoFrames(file, 1) means it returns the 512-pixel VLM frame specified at lines 271-274 rather than today's 224-pixel bitmap. That increases each cached video thumbnail's pixel memory by more than 5× and contradicts the separate display/VLM resolutions described above, undermining the low-memory target. Keep a distinct thumbnail-sized extraction or parameterize the requested resolution.
Useful? React with 👍 / 👎.
|
|
||
| #### M6 — Search and device gating | ||
|
|
||
| - Lexical caption index (BM25 or TF-IDF) in `src/compute.ts`, alongside `searchByCosine()` (`:266-291`). |
There was a problem hiding this comment.
Supply captions before enabling lexical search
On devices without WebGPU, this milestone withholds tiers B/C at line 304, leaving tier A, which explicitly produces no captions. A lexical index over captions is therefore empty on precisely the low-end fallback tier, so the default search and verification step 7 cannot return any results. The plan should provide a caption source for tier A, retain a compatible fallback search route, or explicitly disable text search for that tier.
Useful? React with 👍 / 👎.
Semantic search space (P1): searchImages() compares the nomic-text query vector against state.vectors directly (src/app.ts:653). That works today only because chrome-ai stores nomic-text vectors there. Under a smolvlm2 tier those are pooled SigLIP features, so loading the text model alone does not restore today's behaviour — the ranking would be noise. Plan and ADR now require a parallel state.searchVectors built from captions, and state that tier A cannot offer semantic text search at all. Thumbnail resolution (P2): extractVideoFrames() hard-coded 512 px while extractVideoFrame() was to become a wrapper around it, silently promoting every cached video thumbnail from 224 px — >5x pixel memory across a 2000-entry LRU, on the low-memory device this plan targets. Resolution is now a parameter, and VLM frames must not be written to state.thumbnails. Tier A search (P2): tier A produces no captions and is the WebGPU-less fallback, so a lexical caption index would be empty exactly where it is most needed. The index now falls back to filename/folder/EXIF, and the text-search toggle is disabled with a reason on that tier. Also corrects the workflow comment claiming the auto-fix push re-runs the workflow: GITHUB_TOKEN pushes do not trigger runs, so the fix commit is never linted. Rules out a loop, but the tradeoff is now documented. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KY5o2ppXBdamroAWL3j8Qo
|
All four Codex comments were valid. Fixed in P1 · semantic search vector space — correct, and the most important catch. P2 · thumbnail resolution — correct, and an internal contradiction in my own plan: I wrote a "two resolutions, two purposes" design decision and then an implementation step that collapsed them. P2 · empty lexical index on tier A — correct. Tier A produces no captions and P1 · re-run checks on the auto-fix commit — correct on the mechanism. I'd observed this independently (the run created for Docs-only change; Generated by Claude Code |
The merge of main in 246321a kept both header rows and both delimiter rows from the two branches instead of merging the data rows under one header, so #23's header and its row of dashes rendered as ordinary table rows. Drops the duplicates, leaving one header, one delimiter, and the 0001/0002 rows. Compact pipe style, which is what this repo's markdownlint MD060 enforces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGZ5syBsRr54E7Cce6HthJ
Why
Two problems converge on one decision.
The captioner does not fit the target device. Captioning via Chrome's Prompt API (Gemini Nano) crashes an 8 GB Chromebook. Chrome's published floor is >4 GB VRAM, or 16 GB RAM + 4 cores on CPU, plus 22 GB free disk. The size is not negotiable — the browser owns the model, so the app cannot ask for a smaller quantisation. Its only lever today is to disable the feature (
src/app.ts:2696-2713).The app claims video support it does not have.
extractVideoFrame()(src/app.ts:504-555) seeks once tomin(1.0, duration/2)and returns one 224 px bitmap. That still is the entire representation of the video. The caption prompt is image-specific in its wording too (src/chromeAI.ts:52-56) — it asks about colour and lighting, never motion.What this adds
Two separate strands. No
src/changes — the source tree is untouched.1. The ADR and plan (the main deliverable)
docs/adr/README.md— establishes an ADR convention the repo lacked (numbering, immutability, status vocabulary, thesrc/file.ts:linecitation style already used inIMPROVEMENT_PLAN.md).docs/adr/0001-small-video-language-model.md— the decision: adopt SmolVLM2 (256M / 500M) via@huggingface/transformers, in a Web Worker, in three tiers.VIDEO_LM_PLAN.md— six-milestone implementation plan, model facts, risks, verification checklist.AGENT.mdgains adocs/adr/pointer, and its Tech Stack section is corrected — it claimed Nomic while the default issapiens2-fp16.IMPROVEMENT_PLAN.mdP1-1 cross-references milestone M5.Implementation lands in later PRs, per the
IMPROVEMENT_PLAN.mdprecedent.2. MegaLinter auto-fix (requested mid-PR)
.mega-linter.ymlalready setAPPLY_FIXES: all, but the workflow never acted on it — fixed files were only copied into the report artifact, so every run re-reported the same drift and nothing was ever fixed..github/workflows/mega-linter.ymlnow setsAPPLY_FIXES_EVENT: all/APPLY_FIXES_MODE: commitwith a commit-back step.Three details that took a round each to get right:
!cancelled()on the commit step. MegaLinter exits non-zero whenever any linter errors — here the security scanners always do — and a failed step skips every later step by default. Without this the fixes were never pushed.file_pattern: ':!megalinter-reports'plus a.gitignoreentry. The first auto-fix commit correctly fixedAGENT.md, but also swept in MegaLinter's own run output. Belt and braces so it cannot recur.commit_authorpinned. The action defaults it togithub.actor, attributing a machine-generated commit to a human.Fork PRs resolve
FIX_BRANCHto empty, so they are linted but never pushed to. MegaLinter is already on the latest release (v9.6.0) — no bump was needed.The decision, briefly
ModelVariantsmolvlm2-visionsmolvlm2-256msmolvlm2-500m@huggingface/transformersis already a dependency at^4.2.0and SmolVLM landed upstream in 3.4.0 — no dependency bump.The load-bearing insight: SmolVLM2's
vision_config.hidden_sizeis 768, exactly the app's existing vector width. When the captioner runs, the vision tower runs anyway — so pooling its output yields the caption and the clustering vector from a single forward pass. That is what dropsnomic-embed-text(134 MB) from the default path. Today'schrome-aichain needs two models to produce one vector.Multi-frame video is affordable:
pixel_shuffle_factor: 4reduces each frame's 1024 patches to 64 visual tokens, so 4 frames cost ~256 tokens of prefill against a 576-hidden decoder — not the 4096 a naive reading suggests.For scale, tier A at 55 MB is the cheapest embedder the app has ever had (vs 116 MB
sapiens2-int8, 380 MBnomic).Trade-offs stated in the ADR
enableTextSearch(which already gates the text-model download) flips to off by default with lexical caption search; turning it on restores today's behaviour exactly.sapiens2-fp16before anything depends on it.chrome-aiis supplemented, not replaced — it stays selectable and unchanged.One assumption flagged for day-one confirmation
vision_encoder.onnxat fp32 is 374 MB ≈ 93.6 M params ≈ the bare SigLIP-base tower, implying the export stops before the pixel-shuffle connector and emits 768-d embeddings. That is inferred from file size, not read off the graph. The plan makes confirmingsession.outputNames+ dims an explicit M2 day-one step, with avisionDimtier field as the fallback if it turns out to be 576-d.Verification
npm run type-checkclean;npm test76/76 passingactionlint✅ 0 andzizmor✅ 0 on the changed workflow — including the deliberatepersist-credentials: true, which job-scoped permissions and anignore[artipacked]make explicit rather than accidentalsrc/file.ts:linecitation in both documents was checked to resolve to the code it claimsThe red checks are pre-existing
Both failed identically on
mainatf63dd37, the commit this branch is based on.MegaLinter — gitleaks (1), grype (2), osv-scanner (2), trivy (1). The leaked
UNSPLASH_ACCESS_KEYatsrc/app.ts:56dates from April and needs rotating, not deleting;adm-zipandsharpneed version bumps. Both warrant their own PR.Cloudflare Pages — resolved. Automatic deployments have now been disabled on the Pages project, so the git-integration build no longer runs. The failed check remains frozen on
33131ffbecause check runs are immutable once posted; later commits simply won't have the check.The cause is worth recording, because the dashboard was actively misleading. From the build log:
The Pages dashboard did have
Build command: npm run buildconfigured. Butwrangler.tomlcontainspages_build_output_dir, and per Cloudflare's docs that file then becomes the source of truth — "you can not edit the same fields in the dashboard once you are using this file." Sincewrangler.tomlhas no build-command key for Pages, the dashboard value was inert: displayed as correct, ignored by the builder. So the build was skipped, and validation then failed ondist/, which is.gitignored and therefore never in the clone.Not a broken build — a build that never ran. Throughout,
deploy.ymlbuilt, tested and deployed every commit on this branch successfully viawrangler pages deploydirect upload, which is unaffected and now the only publishing path — withtype-checkandnpm testgating it, which the git build never did.