<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Arsh Goyal</title>
    <description>The latest articles on DEV Community by Arsh Goyal (@eternity0207).</description>
    <link>https://dev.to/eternity0207</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4157198%2Fe68a1d03-539b-43ad-8e6a-95bf4df7c524.jpg</url>
      <title>DEV Community: Arsh Goyal</title>
      <link>https://dev.to/eternity0207</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eternity0207"/>
    <language>en</language>
    <item>
      <title>Hoard: an AI that tidies the screenshots you were never going to tidy</title>
      <dc:creator>Arsh Goyal</dc:creator>
      <pubDate>Tue, 20 Oct 2026 09:19:40 +0000</pubDate>
      <link>https://dev.to/eternity0207/hoard-an-ai-that-tidies-the-screenshots-you-were-never-going-to-tidy-2jnc</link>
      <guid>https://dev.to/eternity0207/hoard-an-ai-that-tidies-the-screenshots-you-were-never-going-to-tidy-2jnc</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Screenshots are the laziest note-taking system ever invented, and I mean that as a compliment. Wi-Fi password at your mum's? Screenshot. Where you parked? Screenshot. Boarding pass, the address someone texted, the recipe, the error message you'll Google later: screenshot, screenshot, screenshot.&lt;/p&gt;

&lt;p&gt;The catch is the other end. Finding one again means scrolling through thousands of near-identical white rectangles, and the pile only grows, because nobody has ever sat down on a Sunday to organise their screenshots. Nobody ever will.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hoard&lt;/strong&gt; is for people like that, and it's built so they never have to.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Point it at your screenshots folder once.&lt;/strong&gt; It reads every screenshot slowly and steadily in a background tab, newest first. It picks up where it stopped if you close it, and catches new screenshots each time you come back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search by what you remember.&lt;/strong&gt; "wifi password", "where did I park", "that sunset photo". Hoard has read every word on every screenshot &lt;em&gt;and&lt;/em&gt; looked at the picture, so it finds things by their text, their meaning or their looks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let it sweep.&lt;/strong&gt; Duplicates, one-time codes that died five minutes after they arrived, boarding passes for flights you already took, and accidental screenshots of your home screen, gathered into one pile. One click moves them into a &lt;code&gt;Swept by Hoard&lt;/code&gt; folder. Nothing is ever deleted, and there's an undo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask it.&lt;/strong&gt; "when is rent due?" Gemma 3 reads the screenshots that match best and answers in one sentence, with the screenshot underneath so you can check it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of it runs &lt;strong&gt;inside the browser tab&lt;/strong&gt;, on open models. No account, no upload, no server. After the first visit it works with Wi-Fi off, which matters more than you'd think: the moment you most need your boarding pass is at the gate, with flight mode on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt; &lt;a href="https://hoard-905r.onrender.com/" rel="noopener noreferrer"&gt;hoard-905r.onrender.com&lt;/a&gt; in Chrome or Edge on a laptop.&lt;/p&gt;

&lt;p&gt;You don't need your own screenshots: click &lt;strong&gt;Try a sample pile&lt;/strong&gt; and Hoard draws 40 made-up screenshots of a fictional person's phone (chats, receipts, a boarding pass, OTP texts, a map, a sunset, duplicates, a blank screen) and indexes them in a minute or two (after a one-time ~250 MB model download). Then try:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;where did I park&lt;/code&gt; (the note says "Level 3, Bay C14", and never the word "park")&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sunset photo&lt;/code&gt; (a picture with no words in it)&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;Sweep&lt;/strong&gt; tab: 11 screenshots it thinks you can let go of, and why&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;when is rent due?&lt;/code&gt; then &lt;strong&gt;Ask&lt;/strong&gt; (downloads Gemma once, ~790 MB)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Eternity0207" rel="noopener noreferrer"&gt;
        Eternity0207
      &lt;/a&gt; / &lt;a href="https://github.com/Eternity0207/hoard" rel="noopener noreferrer"&gt;
        hoard
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Search and sweep your screenshot pile with open AI models running in your browser.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Hoard&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Search and sweep your screenshot pile with open AI models that run in your browser.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You take screenshots so you don't have to remember things: the Wi‑Fi password, where you parked, a boarding pass, an address someone texted you. Then you have 4,000 of them and you can't find any of it.&lt;/p&gt;
&lt;p&gt;Hoard reads the folder for you, slowly and steadily, in a background tab:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Search by memory.&lt;/strong&gt; "wifi password", "where did I park", "that sunset photo". Each screenshot is OCR'd and embedded twice (its text and its picture), and search mixes keywords, meaning and looks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sweep the junk.&lt;/strong&gt; Duplicates, one-time codes that died minutes after you got them, boarding passes for flights you already took, and accidental screenshots of your home screen. One click moves them into a &lt;code&gt;Swept by Hoard&lt;/code&gt; subfolder. Nothing is ever deleted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask.&lt;/strong&gt; "when is rent due?" Gemma 3 reads the best-matching screenshots and…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Eternity0207/hoard" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;Hoard is static files: Vite and vanilla JavaScript, no framework. The plumbing for running models offline in a browser (a Web Worker with &lt;strong&gt;Transformers.js&lt;/strong&gt; and &lt;strong&gt;ONNX Runtime Web&lt;/strong&gt;, an Origin Private File System model cache, a service worker, and the WASM runtimes served from the app's own origin) is reused from my Week 1 and Week 2 projects, so this week went into the parts that are actually about screenshots.&lt;/p&gt;

&lt;h3&gt;
  
  
  Four open models, one job each
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Licence&lt;/th&gt;
&lt;th&gt;Download&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read the words&lt;/td&gt;
&lt;td&gt;Tesseract 5 LSTM, via tesseract.js&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;5 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Understand the picture&lt;/td&gt;
&lt;td&gt;SigLIP base (int8)&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;210 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Understand the words&lt;/td&gt;
&lt;td&gt;gte-small (int8)&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;34 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answer questions (optional)&lt;/td&gt;
&lt;td&gt;Gemma 3 1B (4-bit, WebGPU)&lt;/td&gt;
&lt;td&gt;Gemma terms&lt;/td&gt;
&lt;td&gt;790 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For each screenshot, Hoard makes a thumbnail, a perceptual hash and a "how blank is this?" score (no AI needed for those), then runs OCR, embeds the picture with SigLIP and embeds the OCR text with gte-small. A few strict text rules decide its kind (a boarding pass says "boarding pass"), and SigLIP decides the rest by comparing the picture to short descriptions ("a screenshot of a chat conversation", "a street map"...). Everything goes into IndexedDB, one record at a time, which is what makes indexing resumable.&lt;/p&gt;

&lt;h3&gt;
  
  
  I measured before I tuned
&lt;/h3&gt;

&lt;p&gt;Before building the UI, I wrote the sample pile as a canvas drawing script, so the demo and the tests need no real data and no image files in the repo. Then I wrote &lt;strong&gt;30 search queries&lt;/strong&gt; to tune on and, before looking at a single result, &lt;strong&gt;20 more held-out queries&lt;/strong&gt; that I promised myself I'd only score at the end.&lt;/p&gt;

&lt;p&gt;Search mixes three signals, and the eval shows why you need all three:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Search by&lt;/th&gt;
&lt;th&gt;Tuning hit@1&lt;/th&gt;
&lt;th&gt;Held-out hit@1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Keywords (BM25 over the OCR text)&lt;/td&gt;
&lt;td&gt;70%&lt;/td&gt;
&lt;td&gt;75%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Meaning (gte-small over the OCR text)&lt;/td&gt;
&lt;td&gt;83%&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Picture (SigLIP)&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;All three&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;93%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each signal fails in its own way. Keywords alone matched &lt;code&gt;sunset photo&lt;/code&gt; to the home screen, because of the little "Photos" label under an app icon, and &lt;code&gt;where did I park&lt;/code&gt; to the dentist, because the clinic is on "Park &lt;em&gt;Road&lt;/em&gt;". Meaning gets "park" ≈ "Car, Level 3, Bay C14" but can't see a sunset. SigLIP can see the sunset, and it surprised me by reading large text too, but it only gets a 224-pixel squashed thumbnail, so small print is invisible to it.&lt;/p&gt;

&lt;p&gt;The trick that made mixing them work was to stop comparing raw scores. On the sample pile, gte-small's similarities sit in a narrow band around 0.76 (give or take 0.035), while SigLIP's hover around zero and a great match is 0.15, so adding them up lets one model shout over the other. Hoard converts each to a z-score across your whole pile ("how &lt;em&gt;unusually&lt;/em&gt; close is this screenshot?"), ignores anything below average, and adds them with weights picked on the tuning queries: &lt;code&gt;1.0 × keywords + 0.75 × meaning + 1.5 × picture&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An honest caveat: I drew the sample pile myself, with big clean text. Real screenshots are messier, so read these numbers as "the design works", not as a benchmark. The eval page ships in the repo (&lt;code&gt;npm run dev&lt;/code&gt;, then open &lt;code&gt;/eval.html&lt;/code&gt;), so anyone can run it on their own changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The bugs the eval caught
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Tesseract was skipping the most important line.&lt;/strong&gt; The first OCR run read everything on the boarding pass except the words "BOARDING PASS", and missed the band name on the concert ticket. Both were white text on a coloured bar. It turns out tesseract.js defaults to page segmentation mode 6, which treats the image as one uniform block of text. A screenshot is a page of scattered blocks; switching to automatic segmentation (mode 3) found both headers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A line break hid a one-time code.&lt;/strong&gt; &lt;code&gt;G-552019 is your verification code.&lt;/code&gt; wasn't flagged as a stale code, because the phone had wrapped it as &lt;code&gt;verification&lt;/code&gt; / &lt;code&gt;code.&lt;/code&gt; and my regex wanted a space. Now whitespace is normalised first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A receipt looked like a ticket.&lt;/strong&gt; "Paid to Priya, for: concert tickets" has a date in the past and the word "tickets", so Sweep called it an expired ticket. The expired rule now needs words that only appear &lt;em&gt;on&lt;/em&gt; a ticket (boarding pass, gate and seat, "show this at the door").&lt;/p&gt;

&lt;p&gt;After those fixes, Sweep flags &lt;strong&gt;11 of the 11&lt;/strong&gt; screenshots it should on the sample pile, and nothing else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gemma: what changed when I measured it
&lt;/h3&gt;

&lt;p&gt;Ask hands the OCR text of the best matches to Gemma 3 1B and asks for the exact detail in one sentence. My first prompt showed it the top three screenshots. On eight questions, the better of two wordings got &lt;strong&gt;5 right&lt;/strong&gt;, and between them they invented things: one "paid Priya €47" (it was ₹2,500; OCR had lost the ₹ sign and turned a 🎶 emoji into "47"), and the other decided I'd parked at a concert hall, because a map was in the context.&lt;/p&gt;

&lt;p&gt;A 1B model is easily distracted, so I gave it fewer distractions: only the best match, plus up to two others if they scored within 70% of it, each labelled with its kind ("a note", "a receipt") and date, and an instruction to copy the detail rather than paraphrase it. On the same eight questions it got &lt;strong&gt;6 right&lt;/strong&gt; and stopped inventing answers. With two more questions added, ten in all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;7 right&lt;/strong&gt;: Wi-Fi password, payment amount, hotel confirmation number, dentist time, tracking number, rent due date, party address&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 half right&lt;/strong&gt;: the gate number is there, garbled with the seat&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 declined&lt;/strong&gt;: "where did I park?" got "I couldn't find that", although search had put the right note first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 wrong&lt;/strong&gt;: "who is bringing snacks?" OCR can't tell which chat bubbles are yours, so it credited the other person&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Median answer time also dropped, from 13 to &lt;strong&gt;8 seconds&lt;/strong&gt; on my laptop's integrated GPU, because there's less to read. It's good enough to keep, but not good enough to trust blindly, so every answer shows the screenshot it came from, and the label says so. Search is the product; Ask is the shortcut on top of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sweeping without fear
&lt;/h3&gt;

&lt;p&gt;A tool that deletes your files had better be right, so Hoard doesn't delete anything. Every rule explains itself in the UI, every suggestion has a checkbox, "Keep forever" excludes a screenshot permanently, and sweeping just moves files into a &lt;code&gt;Swept by Hoard&lt;/code&gt; subfolder using the File System Access API. Undo moves them back to wherever they came from, subfolders included. I tested the whole flow (scan, sweep, undo, rescan) against a fake folder in the browser's private file system, which speaks the same API as a real one.&lt;/p&gt;

&lt;p&gt;The duplicate rule needed two signals. A perceptual hash alone thinks every chat screenshot from the same app is a duplicate (white background, bubbles); text alone would merge two different photos with no words. So two screenshots are duplicates only if they look alike &lt;em&gt;and&lt;/em&gt; say the same thing, and Hoard keeps the copy with the most text.&lt;/p&gt;

&lt;h3&gt;
  
  
  Slow and steady, on purpose
&lt;/h3&gt;

&lt;p&gt;Indexing has three paces, under Settings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sloth:&lt;/strong&gt; one screenshot at a time, with a nap in between. The fans stay quiet and you forget it's running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Steady&lt;/strong&gt; (default): two at a time, so OCR and the embeddings overlap. About &lt;strong&gt;1.7 seconds per screenshot&lt;/strong&gt; on my laptop, so a thousand screenshots take roughly half an hour in a background tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sprint:&lt;/strong&gt; four at a time with two OCR workers, for when you're impatient.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It works newest first, because last week's screenshots are the ones you go looking for, and search works on whatever has been read so far.&lt;/p&gt;

&lt;h3&gt;
  
  
  The offline bug
&lt;/h3&gt;

&lt;p&gt;The first time I tested offline mode properly (load the app, stop the server, reload), it showed a blank page. Two bugs were stacked on top of each other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The first visit fetches the app's JavaScript &lt;em&gt;before&lt;/em&gt; the service worker is in control, so it was never cached. The build now writes the list of hashed bundle files into the service worker, which precaches them on install.&lt;/li&gt;
&lt;li&gt;Even then, the cached files didn't match. The server sends &lt;code&gt;Vary: Origin&lt;/code&gt;, module scripts are requested with an &lt;code&gt;Origin&lt;/code&gt; header, and the precache requests weren't, so &lt;code&gt;caches.match&lt;/code&gt; politely said "no". &lt;code&gt;ignoreVary: true&lt;/code&gt; fixed it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now the app loads with the server gone, the models come off disk, and &lt;code&gt;where did I park&lt;/code&gt; still finds the car.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hosting
&lt;/h3&gt;

&lt;p&gt;Hoard is a free static site on &lt;strong&gt;Render&lt;/strong&gt;, set up by a &lt;code&gt;render.yaml&lt;/code&gt; Blueprint. The setting that matters is the pair of cross-origin isolation headers (&lt;code&gt;COOP: same-origin&lt;/code&gt;, &lt;code&gt;COEP: credentialless&lt;/code&gt;), which unlock &lt;code&gt;SharedArrayBuffer&lt;/code&gt;, so ONNX Runtime can use every CPU core.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Screenshots are the most private files on your phone.&lt;/strong&gt; Look at the sample pile: bank login codes, private chats, a home address, a boarding pass with a name on it, a prescription, someone's mum's Wi-Fi password. Real folders are worse. A cloud screenshot organiser has to see all of that to be useful. Hoard runs open models in the browser tab, so the screenshots never leave the device. There's no upload code to trust; open DevTools → Network while it indexes and see for yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slow is only affordable when it's free.&lt;/strong&gt; With a paid vision API, indexing 4,000 screenshots is 4,000 billed calls, and so is re-indexing them after every improvement. That pushes you to process as little as possible as fast as possible. With open weights on your own laptop, the marginal cost is zero, so Hoard can take its time: read everything, at sloth pace, in the background, and do it again whenever the pipeline improves. The sloth approach only works because nobody is charging per leaf.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open let me look inside and fix things.&lt;/strong&gt; The OCR bug that cost me "BOARDING PASS" was a configuration default I could read in the source and change in one line. The z-score idea came from looking at the raw similarity scores of two models side by side. I ran the exact same weights in an eval page and in the app, so the numbers in this post are the numbers you get. With closed OCR and embedding APIs, I'd have been tuning a black box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It works where you need it.&lt;/strong&gt; No signal on the plane, no signal in the car park, roaming abroad: the screenshots you need most are often needed exactly where the internet isn't. A local model doesn't care.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; Gemma 3 1B runs on-device over WebGPU (or the 270M model on CPU) to answer questions from the user's own screenshots, with the prompt redesigned after measuring it: from 5 to 6 right on the same eight questions, no invented answers, and a median of 8 seconds instead of 13.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Render:&lt;/strong&gt; the app is hosted on Render as a static site, with a Blueprint that sets the cross-origin isolation headers multithreaded in-browser inference needs.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>Shoebox: a bookkeeper for freelancers that never sees your bank statement</title>
      <dc:creator>Arsh Goyal</dc:creator>
      <pubDate>Thu, 15 Oct 2026 08:21:24 +0000</pubDate>
      <link>https://dev.to/eternity0207/shoebox-a-bookkeeper-for-freelancers-that-never-sees-your-bank-statement-7d6</link>
      <guid>https://dev.to/eternity0207/shoebox-a-bookkeeper-for-freelancers-that-never-sees-your-bank-statement-7d6</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Every freelancer I know has the same January. You download twelve months of bank statements, open a spreadsheet, and spend a weekend deciding whether &lt;code&gt;SQ *PHILZ COFFEE SAN FRANCISCO&lt;/code&gt; was a client meeting or just coffee. Then you hand your accountant a CSV and hope.&lt;/p&gt;

&lt;p&gt;People pay real money to skip that weekend. Apps like Keeper and QuickBooks charge a monthly subscription for it, and the deal includes connecting your bank account to their servers so their models can read every transaction you've ever made.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shoebox&lt;/strong&gt; does the same job without the second part.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Drop in a CSV&lt;/strong&gt; from any bank or card. Shoebox guesses which column is which (one amount column or separate debit/credit, day-first dates, cards that list purchases as positive numbers) and shows a preview so you can fix it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It sorts every transaction&lt;/strong&gt; into US Schedule C categories: meals, travel, software, contract labor, personal, transfers and so on. Lines it's confident about are filed automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It asks about the rest, one merchant at a time.&lt;/strong&gt; The "Needs review" list is grouped by merchant, so one answer covers all 13 trips to the same coffee shop. Every answer becomes a rule, so next month's statement needs fewer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It adds it all up:&lt;/strong&gt; gross income, deductible expenses by Schedule C line (meals at 50%), net profit, a rough self-employment tax estimate, and quarter-by-quarter numbers for estimated payments. Export a CSV, a Markdown worksheet for your accountant, or print it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The AI runs &lt;strong&gt;inside your browser tab&lt;/strong&gt;. There's no server, no account, no bank login and no analytics. After the first visit it works with Wi-Fi off.&lt;/p&gt;

&lt;p&gt;Who would pay for it: freelancers and sole proprietors who do their own books, or who pay an accountant by the hour to do it. A one-time $29 licence is an easy sell against a subscription that renews every month, and since inference runs on the customer's laptop, a new customer costs me nothing to serve. That's the part of the business that only works because the model is open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt; &lt;a href="https://shoebox-hcdp.onrender.com/" rel="noopener noreferrer"&gt;shoebox-hcdp.onrender.com&lt;/a&gt;. It works in any modern browser; the first visit downloads a 34 MB model, and after that it runs offline.&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Try a sample freelancer's statement&lt;/strong&gt; to load nine months of a fictional designer's checking account (321 transactions). No real data needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run it yourself:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Eternity0207/shoebox
&lt;span class="nb"&gt;cd &lt;/span&gt;shoebox &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Eternity0207" rel="noopener noreferrer"&gt;
        Eternity0207
      &lt;/a&gt; / &lt;a href="https://github.com/Eternity0207/shoebox" rel="noopener noreferrer"&gt;
        shoebox
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Private bookkeeping for freelancers: sorts bank statements into Schedule C categories with an open model running in your browser.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Shoebox&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Private bookkeeping for freelancers.&lt;/strong&gt; Drop in a bank or credit card CSV and Shoebox sorts every transaction into US Schedule C tax categories, learns from your corrections, and adds it all up for tax time.&lt;/p&gt;

&lt;p&gt;The AI runs in your browser. Your bank statement is never uploaded: there is no server, no account and no analytics. After the first visit it works offline.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What it does&lt;/h2&gt;
&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Imports any bank's CSV.&lt;/strong&gt; It guesses which column is the date, description and amount (or separate debit/credit columns, day-first dates, cards that list purchases as positive), shows you a preview, and lets you fix the mapping. Re-importing an overlapping statement doesn't create duplicates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sorts transactions into tax categories&lt;/strong&gt; with a small open embedding model. Lines it's sure about are filed automatically; the rest go into a short "Needs review" list, grouped by merchant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learns from you.&lt;/strong&gt; Every correction becomes a rule for that…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Eternity0207/shoebox" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;Shoebox is static files: Vite and vanilla JavaScript, no framework. The model runs in a Web Worker through &lt;strong&gt;Transformers.js&lt;/strong&gt; and &lt;strong&gt;ONNX Runtime Web&lt;/strong&gt;, on the CPU. Transactions and rules live in IndexedDB. The plumbing for offline use (an Origin Private File System model cache, a service worker, ONNX Runtime served from the app's own origin) is reused from my Week 1 project, &lt;a href="https://github.com/Eternity0207/pocket-field-guide" rel="noopener noreferrer"&gt;Pocket Field Guide&lt;/a&gt;, which saved me a day.&lt;/p&gt;
&lt;h3&gt;
  
  
  The pipeline
&lt;/h3&gt;

&lt;p&gt;A bank line goes through five steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Clean the name.&lt;/strong&gt; &lt;code&gt;POS PURCHASE SQ *PHILZ COFFEE #12 SAN FRANCISCO CA&lt;/code&gt; → &lt;code&gt;philz coffee san francisco&lt;/code&gt;. This is boring regex work (processor prefixes, store numbers, phone numbers, state codes, &lt;code&gt;FACEBK&lt;/code&gt; → &lt;code&gt;facebook ads&lt;/code&gt;), and it matters more than any model choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your rules.&lt;/strong&gt; If you've told Shoebox about this merchant before, that wins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your corrections, generalized.&lt;/strong&gt; If the line is very close in embedding space to one you corrected, it copies your answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-shot ranking.&lt;/strong&gt; Each tax category has a plain-English hint listing the kinds of merchants that belong in it ("gas station fuel, parking garages, highway tolls, oil change, auto repair shop..."). The merchant name is embedded and scored against each hint. The sign of the amount rules out impossible answers (money coming in can't be an expense).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A confidence gate.&lt;/strong&gt; If the top category's probability is at least 0.5, it's filed. Otherwise you're asked.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;
  
  
  Picking the model by measuring
&lt;/h3&gt;

&lt;p&gt;Before writing any UI, I hand-labelled 145 bank lines written the way real exports look, with processor prefixes, truncated names and store numbers. Then I ran small open embedding models over them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Top-1&lt;/th&gt;
&lt;th&gt;Top-3&lt;/th&gt;
&lt;th&gt;Download&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;all-MiniLM-L6-v2&lt;/td&gt;
&lt;td&gt;84.8%&lt;/td&gt;
&lt;td&gt;93.8%&lt;/td&gt;
&lt;td&gt;23 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bge-small-en-v1.5&lt;/td&gt;
&lt;td&gt;86.2%&lt;/td&gt;
&lt;td&gt;97.9%&lt;/td&gt;
&lt;td&gt;34 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;gte-small&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90.3%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;97.2%&lt;/td&gt;
&lt;td&gt;34 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EmbeddingGemma-300M&lt;/td&gt;
&lt;td&gt;87.6%&lt;/td&gt;
&lt;td&gt;97.9%&lt;/td&gt;
&lt;td&gt;309 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;gte-small won, at a tenth of EmbeddingGemma's size. One trick helped every model: score each merchant against the whole category hint &lt;em&gt;and&lt;/em&gt; against each example phrase in it, then average. Whole-hint alone gave gte-small 80%.&lt;/p&gt;

&lt;p&gt;Then I calibrated the gate: &lt;strong&gt;at a confidence of 0.5 or above, gte-small files 106 of the 145 lines by itself and gets 99.1% of those right.&lt;/strong&gt; Everything below that goes to a human. For tax records, I'd rather ask than guess.&lt;/p&gt;
&lt;h3&gt;
  
  
  The number that matters: clicks
&lt;/h3&gt;

&lt;p&gt;Accuracy on a test set isn't what a freelancer feels. What they feel is how many times they have to click. So I scripted a user who works through "Needs review" biggest group first, and ran it on the sample statement, both in Node and in the real app in the browser:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;321 transactions&lt;/strong&gt; from 65 merchants, sorted in &lt;strong&gt;about 3.5 seconds&lt;/strong&gt; on a laptop CPU&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;72 lines from 17 merchants&lt;/strong&gt; flagged for review&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;17 clicks&lt;/strong&gt; to clear them (10 changes, 7 "✓ looks right")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0 lines wrong&lt;/strong&gt; at the end&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One honest caveat: I wrote both the sample statement and the category hints, so this is a best case. Real statements will have weirder merchants. But the shape holds, because every click teaches Shoebox a whole merchant, not just one line.&lt;/p&gt;

&lt;h3&gt;
  
  
  The bug that only showed up in the browser
&lt;/h3&gt;

&lt;p&gt;The browser flagged 90 lines where my Node script flagged 77. Same model, same code. It turned out that with the 8-bit model, &lt;strong&gt;padding a string into a batch shifts its embedding&lt;/strong&gt; enough to flip near-ties: "hyatt regency sacramento" scored Travel 0.29 / Meals 0.28 on its own, and the opposite inside a batch. In other words, a merchant's category depended on &lt;em&gt;what else was in the file&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The fix was to embed strings one at a time. It's still fast, because a statement has far fewer merchants than lines. Then I precomputed the category vectors at build time, so the browser and my eval scripts use identical numbers. Now both report exactly 72 lines from 17 merchants, and the app is ready a second or two after it opens instead of 7.5.&lt;/p&gt;

&lt;h3&gt;
  
  
  I tried Gemma, and cut it
&lt;/h3&gt;

&lt;p&gt;My plan was a two-model setup: the small embedding model for the easy lines, and &lt;strong&gt;Gemma 3 1B&lt;/strong&gt; on WebGPU for a second opinion on the uncertain ones. I built it, then measured it on the lines it would actually see (the ones below the confidence gate):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;On the 39 uncertain lines&lt;/th&gt;
&lt;th&gt;Correct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gte-small alone&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;66.7%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma picks from the top 3 (with category hints)&lt;/td&gt;
&lt;td&gt;51.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma picks from the top 3 (names only)&lt;/td&gt;
&lt;td&gt;43.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma describes the business, gte-small ranks that&lt;/td&gt;
&lt;td&gt;66.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first version scored &lt;em&gt;below chance&lt;/em&gt;. That was my bug: I'd prefilled the answer with &lt;code&gt;Answer:&lt;/code&gt; (trailing space), so the model's most likely next token was a newline, and I was reading noise from the letter logits. Fixed, multiple choice was still worse than the small model alone. With a list of category hints in front of it, the 1B model pattern-matched the hint text ("Logitech" → Software) instead of using what it knows about the merchant.&lt;/p&gt;

&lt;p&gt;Playing to Gemma's strength did better. Asking "what kind of business is this?" and letting the embedding model categorize the answer exactly tied the small model alone: it fixed some lines and broke the same number of others. It also invented things: "nintendo" → &lt;em&gt;auto repair shop&lt;/em&gt;, "bart clipper reload" → &lt;em&gt;mobile phone retailer&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;An ~800 MB download that's sometimes confidently wrong has no place in a tax tool. So Shoebox ships with one 34 MB model and the person who knows their own business. The experiment stays in the repo (&lt;code&gt;scripts/eval-gemma-variants.mjs&lt;/code&gt;) so anyone can rerun it with a bigger model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hosting
&lt;/h3&gt;

&lt;p&gt;Shoebox is hosted on &lt;strong&gt;Render&lt;/strong&gt; as a free static site, configured by a &lt;code&gt;render.yaml&lt;/code&gt; Blueprint. The one setting that matters is the cross-origin isolation headers (&lt;code&gt;COOP: same-origin&lt;/code&gt;, &lt;code&gt;COEP: credentialless&lt;/code&gt;), which let ONNX Runtime use every CPU core.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The data is the whole problem.&lt;/strong&gt; A bank statement is a complete map of someone's life: where they eat, who they pay, what they earn, which doctor they see. The closed version of this product starts with "connect your bank account" and ends with that map on someone else's server. With an open model running in the browser, there's nothing to connect and nothing to leak. You can check it yourself: open DevTools → Network, import a statement, and watch nothing go out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The economics only work because inference is free.&lt;/strong&gt; A closed API charges per call, so a bookkeeping app built on one has to meter usage, raise prices or both. Shoebox's marginal cost per customer is zero, because every customer brings their own CPU. That's what makes a one-time price possible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open let me measure and choose.&lt;/strong&gt; I compared four embedding models and a 1B LLM on my own data in an afternoon, picked the one that was best &lt;em&gt;for this job&lt;/em&gt;, and threw away the one that wasn't. With a closed API I'd have been stuck with whatever the endpoint gave me, and I'd never have found the batching bug, because I couldn't have run the same weights in Node and in the browser and compared the numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's yours to change.&lt;/strong&gt; The categories are a plain JavaScript file. A freelancer in another country can rewrite them for their own tax forms, run &lt;code&gt;npm run embed&lt;/code&gt;, and the model understands the new categories immediately, with no fine-tuning and no permission needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Render:&lt;/strong&gt; the app is hosted on Render as a static site, with a Blueprint that sets the cross-origin isolation headers multithreaded in-browser inference needs.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Pocket Field Guide: an AI that wants you to put your phone away</title>
      <dc:creator>Arsh Goyal</dc:creator>
      <pubDate>Sat, 10 Oct 2026 05:37:10 +0000</pubDate>
      <link>https://dev.to/eternity0207/pocket-field-guide-an-ai-that-wants-you-to-put-your-phone-away-13kb</link>
      <guid>https://dev.to/eternity0207/pocket-field-guide-an-ai-that-wants-you-to-put-your-phone-away-13kb</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Most nature apps are built like every other app: they want your attention. You point the camera at a bird and get an answer, and then you're scrolling a species page while the bird flies off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pocket Field Guide&lt;/strong&gt; is built the other way round. It's a browser app with two models running entirely on your device, and every screen is designed to send you back outside.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Go outside.&lt;/strong&gt; Tell it you have 30 minutes in a park. Gemma writes four things to find ("Touch the rough surface of a mossy rock", "Listen for a bird singing from a rooftop"). Then the app switches to a plain screen with a timer and a checklist, and tells you to put the phone away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identify.&lt;/strong&gt; When you spot something, take one photo. SigLIP names it from a built-in field guide of about 150 common species. Gemma then writes a short field note that doesn't tell you facts to read: it tells you &lt;strong&gt;what to check in person&lt;/strong&gt; to confirm it, and &lt;strong&gt;one related thing to go find next&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Journal.&lt;/strong&gt; Finds go into an on-device journal, with an optional location that never leaves the browser. You can export it as Markdown.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no account, no API key and no server. After the first download it works in airplane mode, which is the point: the places worth walking usually have the worst signal.&lt;/p&gt;

&lt;p&gt;It's for anyone who wants a reason to go for a walk and a little help noticing things on the way. It's made for beginners.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt; &lt;a href="https://pocket-field-guide.onrender.com/" rel="noopener noreferrer"&gt;pocket-field-guide.onrender.com&lt;/a&gt;. Use Chrome or Edge on a laptop. Open &lt;strong&gt;Setup&lt;/strong&gt;, download the models once (~900 MB), and then it works offline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New to it?&lt;/strong&gt; The &lt;a href="https://pocket-field-guide.onrender.com/guide.html" rel="noopener noreferrer"&gt;built-in guide&lt;/a&gt; explains every feature, how to check that offline mode works, and troubleshooting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Eternity0207" rel="noopener noreferrer"&gt;
        Eternity0207
      &lt;/a&gt; / &lt;a href="https://github.com/Eternity0207/pocket-field-guide" rel="noopener noreferrer"&gt;
        pocket-field-guide
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      On-device AI field guide that gets you outside. Gemma 3 + SigLIP running in the browser, works offline.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Pocket Field Guide 🌿&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;An AI field guide that runs &lt;strong&gt;entirely in your browser&lt;/strong&gt; and is built to get you off the screen.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Go outside:&lt;/strong&gt; pick 15, 30 or 60 minutes and where you are. Gemma writes a short list of things to look for, then the app switches to a quiet walking screen with a timer and a checklist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identify:&lt;/strong&gt; take or drop in a photo of a plant, bird, bug, mushroom or leaf. SigLIP matches it against a built-in field guide of about 150 species, and Gemma writes a short field note: what to check in person to confirm it, plus something related to find nearby.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Journal:&lt;/strong&gt; finds are saved on your device with an optional location, and you can export them as Markdown.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No account, no API key, no server. After the first download it works with &lt;strong&gt;no internet at all&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;New to the app?&lt;/strong&gt; The…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Eternity0207/pocket-field-guide" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The whole app is static files: Vite and vanilla JavaScript, with no framework. All the AI runs in a Web Worker through &lt;strong&gt;Transformers.js&lt;/strong&gt; and &lt;strong&gt;ONNX Runtime Web&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two open models, each doing what it's good at
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"What is this?"&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;SigLIP&lt;/strong&gt; base (Google, Apache-2.0), 8-bit vision tower, ~95 MB&lt;/td&gt;
&lt;td&gt;CPU (WASM, all threads)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Field notes, walk ideas&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Gemma 3 1B&lt;/strong&gt; instruct, 4-bit, ~785 MB&lt;/td&gt;
&lt;td&gt;GPU (WebGPU)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fallback when there's no GPU&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Gemma 3 270M&lt;/strong&gt;, fp16&lt;/td&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Identification is zero-shot.&lt;/strong&gt; Every species in &lt;code&gt;labels.js&lt;/code&gt; is described by three short phrasings ("a photo of a hoopoe, a type of bird.", "a close-up photo of a hoopoe."...). I embed those with SigLIP's text encoder &lt;strong&gt;at build time&lt;/strong&gt;, average them per species, and ship the result as a ~495 KB file. In the browser, identifying a photo is one pass through the vision encoder plus a dot product. It takes about 1–2 seconds on my laptop's CPU.&lt;/p&gt;

&lt;p&gt;I started with CLIP ViT-B/32 and it wasn't good enough: on the first 8 test photos it got 4 right as its top guess, and it called a hoopoe a starling. Swapping in SigLIP was a few lines of code (same library, same API) and it got &lt;strong&gt;15 of 16&lt;/strong&gt; Wikipedia lead photos right. It's an open model, so changing it was easy to try.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keeping a 1B model honest
&lt;/h3&gt;

&lt;p&gt;The first version simply asked Gemma 1B to "write a field note about a European robin". It confidently wrote that the robin has a &lt;em&gt;white throat patch&lt;/em&gt;. It doesn't. Small models are fluent and wrong, and in a nature app that's worse than useless.&lt;/p&gt;

&lt;p&gt;So Gemma never recalls facts any more. The fix has four parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Trusted traits.&lt;/strong&gt; Every species in the field guide has one line of ID traits written by hand ("pinkish-orange body; black-and-white striped wings; fan-like crest tipped black..."). Gemma only gets to &lt;em&gt;rephrase&lt;/em&gt; those into checks you can do in person.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A worked example and a prefill.&lt;/strong&gt; The prompt includes one complete example note, and the answer is started for the model with &lt;code&gt;**Check it:**&lt;/code&gt;. That stopped the "Okay, here's a field note for October 26, 2023..." openings completely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A check on every answer.&lt;/strong&gt; Each note is checked: both sections are there, the "Check it" line uses words from the trusted traits, and it didn't echo the prompt. If it fails, the app builds a plain note straight from the traits instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safety isn't generated.&lt;/strong&gt; "Never eat a wild mushroom identified by an app" comes from a fixed list per group. I don't want a 1B model improvising mushroom advice.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result is notes like this (Gemma 3 1B, on the GPU, about 10 seconds):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Check it:&lt;/strong&gt; Observe thin leathery brackets in fan shapes, colored with vibrant bands. Tiny pores are visible beneath.&lt;br&gt;
&lt;strong&gt;Look nearby:&lt;/strong&gt; Search for dead wood with colourful bands, noting the subtle pores.&lt;br&gt;
&lt;strong&gt;Careful:&lt;/strong&gt; Never eat a wild mushroom identified by an app.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Making it work offline
&lt;/h3&gt;

&lt;p&gt;Getting this to run offline took the most work, and I learned a few things along the way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Big files and the Cache API don't mix well.&lt;/strong&gt; Transformers.js caches models in the Cache API by default, and the 763 MB Gemma weights file kept failing to save. I wrote a small Cache-shaped store on the &lt;strong&gt;Origin Private File System&lt;/strong&gt; instead. It streams to disk and writes atomically, so a half-finished download never looks like a valid model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 4-bit Gemma builds don't run on the CPU backend.&lt;/strong&gt; ONNX Runtime Web has no WASM kernel for the quantized embedding op they use. Worse, a failed session load left the runtime unable to create &lt;em&gt;any&lt;/em&gt; new session in that worker. So the app never "tries and sees": it plans the device up front, and if the GPU path fails it restarts the worker and comes back on the CPU with Gemma 270M.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Threads need headers.&lt;/strong&gt; WASM only uses all CPU cores when the page is cross-origin isolated (&lt;code&gt;COOP: same-origin&lt;/code&gt;, &lt;code&gt;COEP: credentialless&lt;/code&gt;). On my laptop that took identification from about 2.7 s to about 1.7 s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ONNX Runtime WASM is served from the app's own origin&lt;/strong&gt;, not a CDN, and a service worker caches the app shell. Once the models are downloaded, nothing needs the network.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Hosting
&lt;/h3&gt;

&lt;p&gt;The app is hosted on &lt;strong&gt;Render&lt;/strong&gt; as a free static site, set up by a &lt;code&gt;render.yaml&lt;/code&gt; in the repo. The one setting that matters is the cross-origin isolation headers above: without them the CPU path runs on a single thread. Every push to &lt;code&gt;main&lt;/code&gt; redeploys automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;I couldn't have built this on a closed API, for four reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It has to work with no signal.&lt;/strong&gt; Trails, riverbanks and big parks are exactly where coverage drops. A cloud model would fail in the one place this app is meant for. Open weights let me ship the model &lt;em&gt;to the trail&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where you walk is private.&lt;/strong&gt; A journal of your locations with timestamps is sensitive data. With local inference there's no server to leak it, subpoena it or train on it. The privacy promise is simple to check: open DevTools and watch the network tab stay empty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It costs nothing to run, so it can afford to be generous.&lt;/strong&gt; No per-photo fee means no reason to ration identifications or put a paywall in front of a walk. Hosting is a free static site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I could look inside and change things.&lt;/strong&gt; When CLIP wasn't accurate enough, I swapped in SigLIP in an afternoon. When I wanted identification to be instant, I split the model and precomputed half of it at build time. That's only possible because I have the weights, not just an endpoint. The same applies to anyone using it: the field guide is a plain file, so a birder in Kerala or a naturalist in Oregon can add their local species, run &lt;code&gt;npm run embed&lt;/code&gt;, and the model knows them right away, with no fine-tuning.&lt;/p&gt;

&lt;p&gt;The closed version of this app would be easier to build. It would also stop working on the trail, cost money for every photo, and send everyone's walking routes to someone else's server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Taking It Outside
&lt;/h2&gt;

&lt;p&gt;I didn't want to build another app that keeps you on your phone. I wanted one that is happy when you close it.&lt;/p&gt;

&lt;p&gt;So my rule while building was simple: &lt;strong&gt;if a feature makes you look at the screen longer, it doesn't go in.&lt;/strong&gt; No feed, no streaks, no notifications. Just a short list, a timer, and a nudge to go and look closer.&lt;/p&gt;

&lt;p&gt;The best result for this app isn't more time in the app. It's more time outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; Gemma 3 (1B on WebGPU, 270M on CPU) runs locally in the browser and writes every field note and walk plan, grounded in trusted traits and checked before display.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Render:&lt;/strong&gt; the app's front end is hosted on Render as a static site, with a Blueprint that sets the cross-origin isolation headers multithreaded in-browser inference needs.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>hf26challenge</category>
      <category>devchallenge</category>
      <category>gemma</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Still Here: I built my friend a heartbeat, so silence stops being scary</title>
      <dc:creator>Arsh Goyal</dc:creator>
      <pubDate>Fri, 02 Oct 2026 12:36:13 +0000</pubDate>
      <link>https://dev.to/eternity0207/still-here-i-built-my-friend-a-heartbeat-so-silence-stops-being-scary-48bd</link>
      <guid>https://dev.to/eternity0207/still-here-i-built-my-friend-a-heartbeat-so-silence-stops-being-scary-48bd</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;My friend Ben lives far away, on their own. We talk, but the way friends who live apart talk: a burst of memes one week, then nothing for four days. Almost always, nothing just means &lt;em&gt;busy&lt;/em&gt;. But I never actually know, and I didn't want to become the friend who sends "you alive??" every morning.&lt;/p&gt;

&lt;p&gt;What I wanted didn't exist in the shape I needed. Location sharing felt like surveillance. Wellness apps ask for a mood journal nobody keeps up after day three. I wanted something that costs Ben &lt;strong&gt;one second a day&lt;/strong&gt; and takes the guesswork off &lt;strong&gt;me&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Still Here&lt;/strong&gt;: a small check-in companion that lives in Ben's Telegram.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every morning it sends Ben &lt;strong&gt;one short message&lt;/strong&gt; that remembers Ben's life: &lt;em&gt;"how did the interview go?"&lt;/em&gt;, not &lt;em&gt;"How are you feeling today?"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Ben replies with &lt;strong&gt;anything&lt;/strong&gt;: a word, an emoji, a photo of lunch. That's the whole job.&lt;/li&gt;
&lt;li&gt;If Ben goes quiet, it sends &lt;strong&gt;one gentle nudge&lt;/strong&gt; a few hours later: &lt;em&gt;"no pressure, a 👍 is plenty."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;If the silence lasts about &lt;strong&gt;10 hours&lt;/strong&gt;, it messages me, calmly: &lt;em&gt;"Haven't heard from Ben since this morning. Probably nothing, but a call would be lovely."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;The moment Ben replies, I get &lt;em&gt;"Ben just checked in 💛"&lt;/em&gt; and we both go back to our day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design rule I cared about most: &lt;strong&gt;I never see Ben's messages.&lt;/strong&gt; Each reply becomes a 1–5 mood score, one mood word, and one line written to be &lt;em&gt;shareable&lt;/em&gt;. That's all buddies get. Ben has a private page showing exactly what I can see, can &lt;code&gt;/pause&lt;/code&gt; for a few days without explaining why, and can &lt;code&gt;/forget&lt;/code&gt; everything the bot remembers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Buddies see&lt;/th&gt;
&lt;th&gt;Stays with Ben&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A mood score, one word, and one shareable line per day&lt;/td&gt;
&lt;td&gt;Every message Ben sends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An alert after a long silence, and "Ben's back" when it ends&lt;/td&gt;
&lt;td&gt;What the bot remembers (Ben can delete any item)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A short note about the week, every Sunday&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/pause&lt;/code&gt;, &lt;code&gt;/forget&lt;/code&gt;, and a button to remove any buddy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ben is also told every time someone becomes a buddy. Nobody gets added quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://still-here-9epp.onrender.com" rel="noopener noreferrer"&gt;https://still-here-9epp.onrender.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You don't need Telegram or an invite to look around. Both pages below run on the deployed app with a made-up month of data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Buddy dashboard:&lt;/strong&gt; &lt;a href="https://still-here-9epp.onrender.com/demo" rel="noopener noreferrer"&gt;https://still-here-9epp.onrender.com/demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ben's private view:&lt;/strong&gt; &lt;a href="https://still-here-9epp.onrender.com/demo/me" rel="noopener noreferrer"&gt;https://still-here-9epp.onrender.com/demo/me&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The centrepiece is the &lt;strong&gt;pulse&lt;/strong&gt;: Ben's last 30 days drawn as a heartbeat line.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A beat&lt;/strong&gt; is a day Ben checked in, and &lt;strong&gt;taller beats&lt;/strong&gt; are brighter days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A flat stretch&lt;/strong&gt; is a day Ben went quiet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A shaded block&lt;/strong&gt; is a pause.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wanted something I could read in one glance at 11pm and know whether to call.&lt;/p&gt;

&lt;p&gt;Here's how the model handled three messages I wrote while testing. These are real outputs from the deployed prompts, but &lt;strong&gt;not&lt;/strong&gt; Ben's real messages:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Message to the bot&lt;/th&gt;
&lt;th&gt;What the bot replied&lt;/th&gt;
&lt;th&gt;What buddies saw&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;"honestly kinda nervous lol, didn't sleep much. my landlord also raised rent by 8k which is stressing me out"&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;"man, that's a lot to deal with all at once. i'm sorry about the rent situation. do you think you'll be able to get some rest today?"&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2/5 · stressed&lt;/strong&gt;: "Ben's having a bit of a heavy week."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;"it went great!! they want a second round 🎉"&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;"omg ben that's amazing!! so happy for you. when is the second round?"&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;5/5 · excited&lt;/strong&gt;: "Ben is doing great and moving to the next interview round!"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;"don't tell anyone but i got into a fight with my sister, feeling crap"&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;"oh no, i'm so sorry to hear that. sending you a big hug. do you want to vent about it or just distract yourself?"&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1/5 · upset&lt;/strong&gt;: "Ben's having a bit of a rough day."&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rent amount and the sister never reach buddies. That third row is the one I care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Eternity0207" rel="noopener noreferrer"&gt;
        Eternity0207
      &lt;/a&gt; / &lt;a href="https://github.com/Eternity0207/StillHere" rel="noopener noreferrer"&gt;
        StillHere
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🫀 Still Here&lt;/h1&gt;
&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;A daily check-in for the friend who lives far away.&lt;/h3&gt;
&lt;/div&gt;
&lt;p&gt;One small Telegram message every morning. Reply with anything and you're done.&lt;br&gt;
Go quiet for too long, and the people who love you get a gentle nudge to call.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://dev.to/challenges" rel="nofollow"&gt;&lt;img src="https://camo.githubusercontent.com/0bf98885d697dce23f06725bd63c735d328b4f48b78c10bd5b4e3bc64762002a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4861636b746f626572666573745f323032362d4275696c645f666f725f615f467269656e642d6666376135633f7374796c653d666f722d7468652d6261646765" alt="Hacktoberfest 2026"&gt;&lt;/a&gt;
&lt;a href="https://ai.google.dev/gemma" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/162420b6c1464582ed4fdd21f2fcb9faa2db14a31f56d306645fa3719681ee20/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f41492d47656d6d615f345f286f70656e5f77656967687473292d3432383546343f7374796c653d666f722d7468652d6261646765" alt="Gemma 4"&gt;&lt;/a&gt;
&lt;a href="https://render.com" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/4eeb50b78d92a9d1ad798b9ec05574c139e44ea12ece8e8f1a873036352bfae1/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f52756e735f6f6e2d52656e6465722d3436453342373f7374796c653d666f722d7468652d6261646765" alt="Render"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://render.com/deploy?repo=https://github.com/Eternity0207/StillHere" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/c3053e93bc9f0a2cd84050a5ff9f07cc5e639621a72e50dce48781f4a38f10e2/68747470733a2f2f72656e6465722e636f6d2f696d616765732f6465706c6f792d746f2d72656e6465722d627574746f6e2e737667" alt="Deploy to Render"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;💡 Why I built this&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;My friend &lt;strong&gt;Ben&lt;/strong&gt; lives far away and on their own. We text, but texting is uneven. Some weeks it's memes every hour, then nothing for four days. Usually nothing just means &lt;em&gt;busy&lt;/em&gt;. But I never actually &lt;strong&gt;know&lt;/strong&gt;, and I didn't want to be the friend who sends "you alive??" every morning.&lt;/p&gt;
&lt;p&gt;The apps that exist for this are either &lt;strong&gt;surveillance&lt;/strong&gt; (location sharing, read receipts) or &lt;strong&gt;wellness apps&lt;/strong&gt; nobody opens after day three. I wanted something in between:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero effort for Ben.&lt;/strong&gt; No app to install, no forms, no streaks to feel guilty about. One message, and any reply counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero anxiety&lt;/strong&gt;…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Eternity0207/StillHere" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;It's Python and FastAPI, about 2,700 lines including templates, CSS and tests, with no frontend build step. &lt;code&gt;render.yaml&lt;/code&gt; deploys the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Architecture
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ben ⇄ Telegram ⇄ Render web service (FastAPI)  ⇄  Render Postgres
                        │   ▲
                        │   └── POST /heartbeat  ◀── GitHub Actions, every 10 min
                        ▼
                 Gemma 4 (open weights, via Google AI Studio)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Render Web Service&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Telegram webhook, buddy dashboard, Ben's private page, setup page, and the heartbeat endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Render Postgres&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Check-ins, moods, memories, events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;GitHub Actions&lt;/strong&gt; (&lt;code&gt;*/10 * * * *&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Calls the authenticated &lt;code&gt;POST /heartbeat&lt;/code&gt;, which also wakes the free instance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Gemma 4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Writes, understands, summarises and redacts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Telegram Bot API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The whole interface. Raw HTTP, no SDK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The heartbeat is a small, boring state machine, on purpose
&lt;/h3&gt;

&lt;p&gt;Each beat converts "now" into Ben's local time and asks four questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;checkin_time&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkin_for_day&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;send_checkin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                                   &lt;span class="c1"&gt;# once a day
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ci&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;waiting&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;hours&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;nudge_after&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;nudge&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                                          &lt;span class="c1"&gt;# never during Ben's night
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ci&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;waiting&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nudged&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;hours&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;escalate_after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;tell_buddies&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                                   &lt;span class="c1"&gt;# once, calmly
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_sunday_evening&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;digest_sent_today&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;send_digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every beat is &lt;strong&gt;stateless and idempotent&lt;/strong&gt;: it only reads Postgres and the clock. If a beat is missed, the next one catches up. If two run at once, the second does nothing. That mattered more than anything else in the codebase, because the worst bug this app can have is telling me Ben has gone quiet when Ben hasn't.&lt;/p&gt;

&lt;p&gt;The tests simulate whole days with a frozen clock and a fake Telegram that records every message:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a quiet day that nudges, escalates exactly once, and resolves when Ben replies&lt;/li&gt;
&lt;li&gt;a reply that closes the day with no alerts&lt;/li&gt;
&lt;li&gt;a pause that suppresses everything&lt;/li&gt;
&lt;li&gt;a crisis message&lt;/li&gt;
&lt;li&gt;the Sunday digest sent exactly once&lt;/li&gt;
&lt;li&gt;forged invite codes, tampered dashboard links, and unauthenticated webhook or heartbeat calls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are 10 tests and all of them pass.&lt;/p&gt;

&lt;p&gt;I originally used a native &lt;strong&gt;Render Cron Job&lt;/strong&gt; for the heartbeat. Then I hit a wall: cron jobs need a paid plan, and I didn't have a card this weekend. So the web service exposes the same tick at &lt;code&gt;POST /heartbeat&lt;/code&gt;, protected by a key, and a free GitHub Actions schedule calls it. The cron job is still in &lt;code&gt;render.yaml&lt;/code&gt; as a commented-out block for anyone who prefers it.&lt;/p&gt;

&lt;p&gt;Render made the rest easy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One Blueprint&lt;/strong&gt; creates the web service, the database, and generated secrets.&lt;/li&gt;
&lt;li&gt;The service &lt;strong&gt;registers its own Telegram webhook on boot&lt;/strong&gt; using the &lt;code&gt;RENDER_EXTERNAL_URL&lt;/code&gt; that Render injects, so deploying is: paste two keys, click once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Gemma 4 has four bounded jobs
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Write the check-in&lt;/td&gt;
&lt;td&gt;Up to 30 short memories, the last few mood words, Ben's local time&lt;/td&gt;
&lt;td&gt;One short message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Understand a reply&lt;/td&gt;
&lt;td&gt;The message (or a photo; Gemma 4 is multimodal), recent chat, memories&lt;/td&gt;
&lt;td&gt;JSON (via JSON mode): &lt;code&gt;reply&lt;/code&gt;, &lt;code&gt;mood_score&lt;/code&gt;, &lt;code&gt;mood_word&lt;/code&gt;, &lt;code&gt;share_line&lt;/code&gt;, &lt;code&gt;memory&lt;/code&gt;, &lt;code&gt;concern&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nudge&lt;/td&gt;
&lt;td&gt;This morning's check-in&lt;/td&gt;
&lt;td&gt;One guilt-free follow-up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weekly digest&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Only&lt;/strong&gt; the week's shareable lines and scores, never raw messages&lt;/td&gt;
&lt;td&gt;A short note for buddies&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;share_line&lt;/code&gt; prompt has explicit rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no other people's names&lt;/li&gt;
&lt;li&gt;no health or money details&lt;/li&gt;
&lt;li&gt;nothing Ben asked to keep private&lt;/li&gt;
&lt;li&gt;when in doubt, "Checked in, sounds okay."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The digest is built only from those lines, so even a bad summary can't leak a message it never saw.&lt;/p&gt;

&lt;h3&gt;
  
  
  What went wrong, and what I changed
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Thinking tokens.&lt;/strong&gt; Gemma 4 returns its reasoning as response parts flagged &lt;code&gt;thought&lt;/code&gt;. I filter those out, and setting &lt;code&gt;thinkingLevel: "minimal"&lt;/code&gt; cut a simple JSON call from &lt;strong&gt;~7.2s to ~1.4s&lt;/strong&gt; in my tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hosted free tier is flaky.&lt;/strong&gt; During one test run I got HTTP 500s, 503 "high demand" errors, and a digest that took &lt;strong&gt;88 seconds&lt;/strong&gt;. Every call now alternates between &lt;code&gt;gemma-4-26b-a4b-it&lt;/code&gt; (a fast MoE) and &lt;code&gt;gemma-4-31b-it&lt;/code&gt;, with backoff and a 30-second timeout. Every job also has a hand-written fallback. A model outage should never mean Ben gets silence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model guessed a pronoun.&lt;/strong&gt; An early digest called Ben "he". I don't know Ben's pronouns from a name, and neither does a model. Every prompt now says to use Ben's name or "they".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safety can't depend on a model's mood.&lt;/strong&gt; Gemma returns a &lt;code&gt;concern&lt;/code&gt; level, but a plain regex also runs on every message. If it matches crisis language, Ben immediately gets helpline information and buddies are alerted, whatever the model said.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A double message on day one.&lt;/strong&gt; If Ben joined in the afternoon, the next beat would send a "morning" check-in right after the welcome. Now the welcome counts as the day's check-in. A test covers it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Current limits
&lt;/h3&gt;

&lt;p&gt;I'd rather list these than have a judge find them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inference isn't local yet.&lt;/strong&gt; Ben's messages currently go to Gemma through Google AI Studio's hosted API. Open weights make moving it to my own hardware a one-file change, but I haven't done that yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The free Render instance sleeps.&lt;/strong&gt; The first message after it goes idle can take about a minute to answer. Telegram retries, so nothing is lost, and the heartbeat wakes it every 10 minutes anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub's schedule can run late,&lt;/strong&gt; sometimes by 10–20 minutes. That's fine for a 10-hour alert window, but not for anything time-critical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The crisis check is English-only&lt;/strong&gt; and keyword-based. It's a floor, not a clinician.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free Postgres on Render expires after 30 days.&lt;/strong&gt; Keeping Ben's history means upgrading the database plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One friend per deployment,&lt;/strong&gt; and text and photos only. Voice notes are acknowledged but not transcribed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's tested on simulated days and my own messages.&lt;/strong&gt; Real use with Ben is what comes next.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Still Here is &lt;strong&gt;not an emergency service&lt;/strong&gt;, and the app says so on every page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Still Here handles something more sensitive than most hackathon data: a running record of how one person is feeling, every day. That changed what I was willing to build on.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I can choose where inference happens.&lt;/strong&gt; Gemma 4 is open-weight. Today Google AI Studio serves it free. If that changes, or if Ben would rather nothing leave our own infrastructure, I point &lt;code&gt;stillhere/gemma.py&lt;/code&gt; at vLLM or Ollama on a GPU instance and nothing else changes. With a closed API, the provider decides where those words live, permanently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One instance per friend.&lt;/strong&gt; There's no Still Here company holding a database of people's bad days. Each person deploys their own copy, and the data sits in a Postgres they own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rules are readable.&lt;/strong&gt; The exact moment I get an alert about Ben (&lt;code&gt;escalate_after_h = 10&lt;/code&gt;) is a number in a file Ben can open, along with every prompt and the crisis check. That kind of trust is hard to offer from a black-box app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I could actually test the model.&lt;/strong&gt; With open weights in two sizes I could compare them on the same prompts, use the faster one by default, and fall back to the larger one, instead of being stuck with whatever a single closed endpoint does that day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs nothing to run:&lt;/strong&gt; a free Render web service and Postgres, a free GitHub Actions heartbeat, and the free Gemma tier. A safety net for someone you love shouldn't need a subscription.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;I built Still Here with an AI coding agent in my terminal. The session covered probing which Gemma 4 models my key could reach and how they behaved (thinking parts, JSON mode, latency), writing the heartbeat state machine test-first with a frozen clock, checking the UI in light and dark mode at phone width, and moving the heartbeat from a paid Render Cron Job to a free GitHub Actions trigger when I didn't have a card.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handing It to Ben
&lt;/h2&gt;

&lt;p&gt;Still Here is live, and Ben's invite link is a single tap: open it, press &lt;strong&gt;Start&lt;/strong&gt;, and the first message arrives. I'll update this section with Ben's first reply and whatever Ben asks me to change, because that feedback decides what I build next.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Voice notes,&lt;/strong&gt; transcribed by an open speech model, for days when typing is too much&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma on a small GPU instance&lt;/strong&gt; I control, so not a single word leaves our own infrastructure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A buddy rota by time zone,&lt;/strong&gt; so alerts go to whoever is awake rather than everyone at 3am&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spotting a slow slide:&lt;/strong&gt; a two-week drift in mood, not just one bad day, with a gentle suggestion to call before it becomes a crisis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing with Ben for a few weeks,&lt;/strong&gt; and changing whatever Ben finds annoying first&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Render.&lt;/strong&gt; Render runs the whole agent: a Web Service and Postgres from a single free Blueprint, a Telegram webhook that registers itself from &lt;code&gt;RENDER_EXTERNAL_URL&lt;/code&gt;, and an authenticated &lt;code&gt;/heartbeat&lt;/code&gt; that wakes the free instance and runs the check-in state machine. A native Render Cron Job is included as an option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 (&lt;code&gt;gemma-4-26b-a4b-it&lt;/code&gt;, falling back to &lt;code&gt;gemma-4-31b-it&lt;/code&gt;) writes the daily check-ins, understands replies and photos, produces structured JSON mood data, and redacts each reply into a shareable line that buddies can safely see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of GitHub Copilot.&lt;/strong&gt; A GitHub Actions schedule &lt;em&gt;is&lt;/em&gt; the heartbeat: every 10 minutes it wakes the Render service and runs the tick that decides whether Ben needs a check-in, a nudge, or a buddy alert.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Credits
&lt;/h2&gt;

&lt;p&gt;Thanks to the Gemma team for open weights worth building on, Render for a free tier that can host a real agent, and Telegram for a bot API you can drive with plain HTTP.&lt;/p&gt;

&lt;p&gt;AI tools assisted with the implementation and with preparing this write-up.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
    </item>
  </channel>
</rss>
