<?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: Wathsala Gamarachchi</title>
    <description>The latest articles on DEV Community by Wathsala Gamarachchi (@wathsalaggg).</description>
    <link>https://dev.to/wathsalaggg</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%2F673861%2Fc3258a8c-dd78-44b8-a316-402e3cf1c7eb.jpg</url>
      <title>DEV Community: Wathsala Gamarachchi</title>
      <link>https://dev.to/wathsalaggg</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wathsalaggg"/>
    <language>en</language>
    <item>
      <title>I held a séance for dead GitHub repos (and gave their ghosts a voice)</title>
      <dc:creator>Wathsala Gamarachchi</dc:creator>
      <pubDate>Mon, 02 Nov 2026 14:33:01 +0000</pubDate>
      <link>https://dev.to/wathsalaggg/i-held-a-seance-for-dead-github-repos-and-gave-their-ghosts-a-voice-1bjm</link>
      <guid>https://dev.to/wathsalaggg/i-held-a-seance-for-dead-github-repos-and-gave-their-ghosts-a-voice-1bjm</guid>
      <description>&lt;p&gt;&lt;em&gt;Last Commit turns abandoned repos into Día de Muertos ofrendas. Real commit history on one side, a Gemma-written ghost voiced by ElevenLabs on the other.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every repo dies the same way. Not with a bang, but with one last commit that nobody knew would be the last.&lt;/p&gt;

&lt;p&gt;Atom's first commit message was literally &lt;code&gt;try it out&lt;/code&gt;. Eleven years and 38,533 commits later, it ended with &lt;code&gt;Merge pull request #25709&lt;/code&gt;. That's it. That's the whole funeral.&lt;/p&gt;

&lt;p&gt;So for Día de Muertos I built &lt;strong&gt;Last Commit&lt;/strong&gt;: a graveyard where dead GitHub repos get an ofrenda, and their ghosts get to say a few words.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://lastcommit.wathsala.tech" rel="noopener noreferrer"&gt;https://lastcommit.wathsala.tech&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;![The Last Commit graveyard: tombstones for BrowserQuest, three20, Persona and left-pad]&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63wwtnt9ykou180lirmx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63wwtnt9ykou180lirmx.jpg" alt=" " width="799" height="371"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Each grave is one abandoned repo: Atom, AngularJS, Brackets, BrowserQuest, left-pad, Mozilla Persona, three20, Flux and request. Click a tombstone and you get its ofrenda:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Left side: what is true.&lt;/strong&gt; First commit, last commit, lifespan, how long it's been silent, commits, stars, forks, open issues, archive status. All straight from the GitHub API, with links to the real commits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right side: the ghost speaks.&lt;/strong&gt; A short first-person monologue written by &lt;strong&gt;Gemma&lt;/strong&gt;, read out loud by &lt;strong&gt;ElevenLabs&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;![The left-pad ofrenda: verified facts on the left, the ghost's monologue and audio player on the right]&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkqsiulaeonp4xa6hb4hb.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkqsiulaeonp4xa6hb4hb.jpg" alt=" " width="799" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's Atom, unedited:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I am atom, the hackable text editor, once a vibrant sea of JavaScript. I lived for 11.3 years, weaving through the syntax of a thousand dreams before the quiet settled in. I remember the spark of my beginning on 2011-08-19, when I was nothing more than a whisper saying try it out.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And left-pad, which is a bit too self-aware:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I have been silent for 7.6 years, watching from this ofrenda as the world moved toward String.prototype.padStart(). [...] Do not weep for the deprecated, dear developers. Just remember the space I once filled.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The one rule: facts and fiction never mix
&lt;/h2&gt;

&lt;p&gt;A ghost is fiction. A commit date is not. I really didn't want an AI making up stuff about real maintainers, like "the developer gave up" or "they were burned out". Those are real people.&lt;/p&gt;

&lt;p&gt;So the design is strict:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Facts never come from the model.&lt;/strong&gt; The left panel is rendered from the API data, not from anything Gemma wrote. The page labels it &lt;em&gt;Verified facts&lt;/em&gt;, and the monologue &lt;em&gt;Fiction&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ghost only talks about itself.&lt;/strong&gt; The prompt says it speaks as the code and never describes what developers felt, thought or did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No real names reach the model.&lt;/strong&gt; Contributor usernames are stripped out of the prompt, including sneaky ones like &lt;code&gt;Merge pull request #64 from someuser/master&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check, retry, scrub.&lt;/strong&gt; If a reply still names a real person, it gets regenerated, and anything that survives gets replaced with "someone".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After generating all nine, I ran a script that pulls every number out of every monologue and checks it against the facts. Zero mismatches, zero names.&lt;/p&gt;
&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;repos.txt ──► seance.py ──► GitHub API   (facts)
                   │
                   ├──► Gemma            (epitaph + monologue)
                   ├──► ElevenLabs       (MP3)
                   ▼
         site/data/*.json + site/audio/*.mp3 ──► static site on Render
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The séance happens on my laptop. The graveyard is public.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No backend.&lt;/strong&gt; One Python script writes JSON and MP3 files. The site is plain HTML, CSS and JS that reads them. Nothing can crash while someone's looking at it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No dependencies.&lt;/strong&gt; The script is Python standard library only (&lt;code&gt;urllib&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt;, &lt;code&gt;argparse&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting:&lt;/strong&gt; a free Render static site, using a &lt;code&gt;render.yaml&lt;/code&gt; blueprint, on a &lt;code&gt;.tech&lt;/code&gt; domain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Holding a séance is one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 seance.py atom/atom
&lt;span class="c"&gt;# 🕯  Summoning atom/atom ...&lt;/span&gt;
&lt;span class="c"&gt;#    ghost written in 6s (aistudio:gemma-4-26b-a4b-it)&lt;/span&gt;
&lt;span class="c"&gt;#    EPITAPH: A hackable text editor resting in the digital silence.&lt;/span&gt;
&lt;span class="c"&gt;#    voice saved to site/audio/atom__atom.mp3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Things that went wrong (the fun part)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;My laptop vs. a language model.&lt;/strong&gt; My plan was to run Gemma locally with Ollama. My laptop has 7 GB of RAM, and with Discord and VS Code open I had about 1.3 GB free and full swap. Gemma 3 4B needs about 4 GB, and the new Gemma 4 small models are 7.5–9.5 GB to download. So I went with Gemma 3 1B, which ran at about 18 tokens/sec and wrote a ghost in about 20 seconds. Not bad!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;...but 1B made stuff up.&lt;/strong&gt; It confused forks with stars ("a paltry 17,167 stars"; Atom has 60k). It wrote about "the pressure of the deadline" and developers' "moments of frustration". That's exactly the thing I didn't want. Tightening the prompt helped a bit, but a 1B model just doesn't follow rules that well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemma 4 via Google AI Studio fixed the writing, then returned nothing.&lt;/strong&gt; I switched to &lt;code&gt;gemma-4-26b-a4b-it&lt;/code&gt; on AI Studio's free tier. Every response came back empty after about 90 seconds. The raw response showed why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"finishReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MAX_TOKENS"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"thoughtsTokenCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4094&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It had spent &lt;em&gt;all&lt;/em&gt; 4,096 tokens thinking: drafting, counting words, re-checking constraints, "wait, one more thing...". It never wrote the actual answer. And my code was concatenating every &lt;code&gt;part&lt;/code&gt;, including the thought parts. The fix:&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;generationConfig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;maxOutputTokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thinkingConfig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;thinkingLevel&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;minimal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thought&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That took it from 90 seconds and empty to &lt;strong&gt;6 seconds&lt;/strong&gt; with a great answer. (&lt;code&gt;thinkingBudget: 0&lt;/code&gt; and &lt;code&gt;thinkingLevel: "low"&lt;/code&gt; both returned 400 for this model. Only &lt;code&gt;minimal&lt;/code&gt; worked.) The 31B model kept returning 500s that day, so I added retries with backoff too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AngularJS was born in 1970.&lt;/strong&gt; Its oldest commit is an import from Perforce, dated &lt;code&gt;1970-01-01T00:00:01Z&lt;/code&gt;. Git didn't exist before 2005, so if a first commit claims to be older than that, I fall back to the repo's creation date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Node v0.x was a stub.&lt;/strong&gt; &lt;code&gt;nodejs/node-v0.x-archive&lt;/code&gt; has a default branch called &lt;code&gt;moved&lt;/code&gt; with exactly 2 commits ("Node.js has moved"). Technically correct, emotionally useless. I swapped it for Flux.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The voice step ate my ghosts.&lt;/strong&gt; The ElevenLabs key I first made was restricted and didn't include Text to Speech, so every voice call failed with a 401. Because the script only saved after the voice step, the freshly written ghosts were lost too. Now it saves the text first, then adds the voice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One empty-looking DNS record.&lt;/strong&gt; Render said it couldn't verify the domain. I queried the registrar's nameserver directly with &lt;code&gt;dig @ns1.dns.host&lt;/code&gt; and it had nothing, so the record just hadn't been saved. Once it was, verification passed and HTTPS came up in minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 4&lt;/strong&gt; (&lt;code&gt;gemma-4-26b-a4b-it&lt;/code&gt;) via Google AI Studio, with local Ollama + Gemma 3 1B as a switchable fallback (&lt;code&gt;PROVIDER=ollama&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ElevenLabs&lt;/strong&gt; &lt;code&gt;eleven_multilingual_v2&lt;/code&gt;, with stability turned down a bit so the voices sound a little uneven and ghostly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub REST API&lt;/strong&gt; for every fact&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render&lt;/strong&gt; static site + a &lt;code&gt;.tech&lt;/code&gt; domain&lt;/li&gt;
&lt;li&gt;Plain HTML, CSS, JS; IM Fell English + JetBrains Mono fonts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All nine voices used about 6,000 of ElevenLabs' 10,000 free monthly characters, so the whole thing ran on free tiers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd add next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A "summon your own" box, so you can type any repo and hold a séance (needs a small backend, which I skipped on purpose)&lt;/li&gt;
&lt;li&gt;Ghosts talking to each other: Atom and Brackets definitely have things to say&lt;/li&gt;
&lt;li&gt;More graves. Suggestions welcome in the comments 🕯️&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Built 31 October – 1 November 2026. Code: &lt;a href="https://github.com/wathsalaggg/last-commit" rel="noopener noreferrer"&gt;https://github.com/wathsalaggg/last-commit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>hacktoberfest</category>
      <category>showdev</category>
      <category>python</category>
    </item>
  </channel>
</rss>
