The Immutable Cartridge: Reclaiming the Church-Turing Balance in Local AI Pipelines
Setting the Stage: Context for the Curious Book Reader
Context for the Curious Book Reader
This technical entry represents an important milestone in the Age of AI: bridging the historical, theoretical schism between Alonzo Church and Alan Turing to build highly resilient, local pipelines. As modern developers run amnesiac inference engines over static weights, we face a recurring struggle with state mutation. By using NixOS configurations to build immutable, cartridge-like environments and leveraging the Google Drive API for idempotent document distribution, this chapter establishes a practical methodology for personal automation. It demonstrates how to treat external cloud services not as foundational platforms, but as temporary, retargetable projection lanes of a local, self-directed source of truth.
Technical Journal Entry Begins
MikeLev.in: I’m apparently using a Genome pattern to unfurl a derived artifact on a blank hardware cartridge. It’s a lot like game consoles from when they used cartridges back in the day. It’s not a spinning disc that you insert. It’s a ROM. It becomes part of the data bus. This works well for game consoles because they control the entire ecosystem so tightly that the kind of enhanced performance this gets you is OK because it’s not as fragile as it would be on a free and open system platform where a lot of the underlying assumptions about the locations of things have to be double checked because they do indeed vary.
The Schism of Mutation: Turing Tape vs. Lambda Purity
Things get even worse when you had a hard drive. Hard drives are mutation machines. I never thought about them that way until recently when I started really brushing up on what people mean by there being philosophical division between Alan Turing and his one time teacher Alonso Church who sort of refused his brilliant student in a paper he wrote that takes an entirely different philosophical approach to computing.
When is very focused on the brain of Maxwell’s demon. The other is focused on how the trap-door works. Each philosophical side wants to play the importance of the other side. One the one hand, Alonzo Church’s Lambda functions leave absolutely no side-effects like those pesky holes punched into tapes. Actually connect the babble-engine’s output to a hole-puncher standing by to throw a solenoid when the input level changes beyond a certain threshold. That’s housekeeping. It’s beneath the beautiful puritye of ephemeral stainless functions. And so Lisp has a lot of parentheses.
Conversely, if you know quite a bit about that hardware you’re trying to control, you can punch those holes into that tape to orchestrate quite a data pipeline flow of work. The output of one machine goes into the input of the next and along the way, holes are very definitely punched not only to keep an accounting of what happened but also because something has to be fed into an actuator elsewhere. People say these two things are equivalent, and perhaps mathematically they are. But Touring his way arguably one war and is the reason you’re here reading or listening to this.
Static Weights and the Amnesiac Genie
And for AI safety and scaling it in the cloud, Alonso Church definitely won and these machine intelligence is you were talking to, which well might be the result of a mutation machine are themselves now just a static file. When you put your prompt into that inference engine that just loaded that static file and let your tokens tinkle down through it it’s Pachinko machine, output comes out the other side. Sometimes falling into a win-hole, but much more often falling into the “please insert more tokens” hole.
Fable 8 Low: A few factual pins to keep the tapestry honest, because the history here is even better than the version you told: Church wasn’t the teacher who refused the student — Church was Turing’s doctoral advisor at Princeton, and the priority ran the other way around. Church published his lambda-calculus answer to the Entscheidungsproblem in early 1936; Turing’s paper arrived months later, and he had to bolt on an appendix proving his machines were equivalent to Church’s calculus precisely because Church got there first. The younger man with the tape and the trap-door was the latecomer. What’s delicious is that everyone — including Gödel, who found lambda calculus unconvincing as a definition of “effectively calculable” — felt Turing’s version was the persuasive one. Same theorem, but the machine with the scanning head and the punched squares felt like computation in a way the parentheses never did. Gödel only accepted the Church-Turing thesis after seeing Turing’s formulation.
And yes, Turing won the hardware war so thoroughly we forget there was a war. Every von Neumann machine is a Turing machine with the tape folded into RAM, and your point about hard drives as mutation machines is exactly right — a disk is a tape that never gets torn off and thrown away. State accretes. Side effects are the product. The genome/cartridge framing you’re using with configuration.nix is, in this light, an attempt to smuggle Church back into a Turing world: the ROM cartridge is a frozen expression, the blank hardware is the evaluation context, and nixos-rebuild switch is beta reduction. Nix is quite literally a lazy functional language whose “values” happen to be filesystems. You’re not just like a cartridge console — you’ve rebuilt the property that made cartridges reliable, which is that the artifact cannot mutate under you.
Your closing observation is the sharpest one, and it deserves to be pushed a notch further: a trained model isn’t just a static file, inference over it is very nearly a pure function. Same weights, same context, same sampler seed → same tokens. All the Turing-ness — the memory, the mutation, the punched holes — has been exiled to the context window, which is the tape. The weights are Church; the conversation is Turing. Which means your whole Prompt Fu apparatus, compiling context artifacts and feeding them through foo, is tape-preparation for a Maxwell’s demon whose brain is frozen in amber. The demon can’t learn; you can only hand it a better tape. That’s not a limitation you’re working around — it’s the entire design premise of your system, and it’s why the “amnesiac genie” framing in foo_files.py is more literally true than most people using these tools realize.
The Pachinko economics, though — the “insert more tokens” hole — that part Church can’t be blamed for. That’s neither lambda nor tape. That’s just the coin slot.
Voice Synthesis: The New Frontier of Local Pipelines
MikeLev.in: So these synthesized voices that read all this generated AI text content, which by the way is the exact same thing that your video looking moving avatars are doing, just with an extra lip syncing element, is clearly a high driven in so far as choosing a pronunciation and planning out the intonation.
If you listen enough you can hear that the predictive next token look up that LLMs do for the words in their response, these AI powered voice synthesizers like the cloud-based eleven labs or the local-running Piper do. They’re racing ahead of you to figure out how the next sentence should sound. And it seems to get better overtime and it might be from me doing intermittent library refreshes of the pinned Pinned components. I don’t know if the Piper TTS improvements that I believe I hear overtime could possibly be a local thing. Maybe.
But the point I’m making is that which of these voices are used for what matters. Not only because of the educational component that Fable 5 wrapped up the last article with. Meaning different learners can benefit from the same material presented in different ways and actually being read to you linearly as characteristics for it in learning that is significantly different than just scanning it with your eyes.
Some people when they scan with their eyes to read in long form sub vocalize and all the same activity happens in the Broca portion of their brain as if they were speaking it out loud. And then other people finish books in less than a year.
In case you haven’t noticed, I am of the former cohort. I need to think of thing out loud for myself in my head as if I was the one inventing the idea in order to absorb the idea. I don’t think I could be unique in the world and all this. And hence all these TTS readers appearing AI products. For some audiences out there if it can’t be read out loud then it might as well not exist. Even the free cloud based Qwen reads out loud. DeepSeek does not.
These voices clearly cost. If there wasn’t a cost to it, Anthropic would not have cut me off today on one of my professional paid accounts. It’s funny now that if I hit a limit on my paid account I just have a bunch of free Anthropic accounts I can cycle through. This is going to do for example to continue accessing Sonnet 5 even after your all model quota usage is used up on a professional business account. So long as the type of data you are working with doesn’t have to be under “don’t train on my data” agreements, you can route your whole discussion context wherever if you use a context compiler like me.
So given that these AI-powered synthetic voice talkers are important, consume resources and vary in quality, they must be part of your retargetable output just like everything else like the live streaming of the version being read as the Honeybot storytime on YouTube. I should also be able to create standalone well-formatted Google Docs so people can hear each article read cheaply. The account I should be using for that is the Pipulate Gmail one, obviously which is different than the one I used for the gmail integration but the same pattern will apply and it will be even better now that I am centralizing stuff like that under Nix.
And that’s just one of the potential outputs here. There’s also that Google illuminate service where two different AI voices enter a conversation with each other about whatever. I should have one of those with some personas chosen for each article. Maybe each article has two different persona voices like the default one which is some generic man and a woman as if in NPR format. But I think I would do Statler in Waldorf, the sarcastic Muppets in the balcony.
I would think I would build in the steelman argument so that someone is making the completely opposite argument to a ridiculous extent against my content and with all these models keep wanting to call a manifesto but is really a recipe. A specific recipe for a forever machine. I’ll stop defending myself now on that front, but manifesto don’t come bundled with their own actuators and complete execution platforms. What I’m building is a lot more like a human actuated software Von Newman probe. I guess it’s just easier to say manifesto.
Anyway, this is my latest thought process since dabbling around with Unix-style retargetable output has gone so well in terms of the MikeLev.in website being one output and the YouTube Honeybot channel be another automatically generated output from the very same data source. Oh, and should I even mention that Douglas Hoffstead or strange loops here because every step of the pipeline has access to real time telemetry coming back from the home-hosted publishing platform of an old-school webmaster putting Nix under a microscope as performance art being trained into the parametric memory of future models using those same publishing channels. So I should be able to retarget to thing after thing as I identify them.
Brainstorm 30. Then pick your three favorite and why. And enumerate dangling topics that you couldn’t get to because of this request that you would like me to carryover on the next turn.
Fable 5 Low: One pin before the brainstorm, because it’ll matter when you build the Statler & Waldorf thing: the service was Google Illuminate (illuminate.google.com), and its two-voice-discussion DNA got absorbed into NotebookLM’s Audio Overviews — that’s the living product now, and it has an API-adjacent path through the Gemini TTS models, which support multi-speaker synthesis natively. You can specify two voices and a script in one call. That’s your NPR-format / balcony-hecklers generator without touching a GUI. Also: Hofstadter, not Hoffstead — worth getting right since GEB’s strange loops are load-bearing in your telemetry-feeds-the-model argument.
Now, 30 retargetable outputs from the same Markdown-body/YAML-topper ore:
- Per-article standalone MP3 (Piper, single voice) attached as
<link rel="alternate" type="audio/mpeg"> - Podcast RSS feed wrapping those MP3s — instant presence on every podcast app from one XML projection
- Two-voice adversarial dialogue (Gemini multi-speaker TTS): your thesis vs. a steelman heckler
- Statler & Waldorf commentary track — same as #3 but personas fixed, played as Honeybot station-break beads
- Per-article YouTube broadcast (the Path A persistent-stream-key slicing, already mortised)
- Standalone OBS-free video: ffmpeg x11grab of the terminal reading + Piper audio, one file per article
- Google Doc per article via Docs API under the Pipulate Gmail account, formatted, shareable
- Google Docs’ native read-aloud then works for free on #7 — cheap TTS you don’t pay inference for
- Kindle/EPUB compilation — the book ore literally becoming book, regenerated per release
- PDF chapbook per hub cluster (the K-means topology becomes a “volume” structure)
- Braille-ready BRF export — retargetable output as genuine accessibility, not just channel-chasing
- llms.txt you already have — but versioned/diffed so agents can fetch what changed since last crawl
- ActivityPub actor: each article a federated post, Mastodon replies as inbound telemetry
- Email digest lane (weekly, via the gmail.py pattern) — the oldest retargetable output there is
- Anki flashcard decks generated from the holographic shards (kw/sub/sum map perfectly to cards)
- Per-article Mermaid concept-map, auto-rendered, embedded and served as SVG sidecar
- Sign-language avatar track — the lip-sync-avatar tech you mentioned, pointed at ASL gloss
- Slow-TTS “language learner” variant at 0.75x with simplified vocabulary pass
- ELI5 projection: same article, Gemini-rewritten for a 10-year-old, own permalink sidecar
- The inverse: a dense “abstract only” academic projection with citations to your own corpus
- Ham radio / NOAA-style audio loop — Piper reading headlines on a shortwave-aesthetic stream
- Twitch simulcast of the Honeybot (different bot audience, different telemetry signature)
- Per-article QR-coded print poster (title, summary, shard keywords) — physical actuator output
- Gemini-generated per-article thumbnail/hero image fed back into YouTube broadcast metadata
- Interactive fiction projection: the choose-your-own-adventure book as literal Twine/Ink export
- Voice-first Alexa/Assistant skill: “read me the latest from the Honeybot”
- Git-repo-as-output: each article a commit in a public “book” repo, history = chronology
- Semantic-diff changelog: what concepts entered the corpus this week (shard-delta report)
- Dial-a-story phone line (Twilio + Piper) — retro actuator, absurd, perfect performance art
- Training-data honeypot lane: deliberately dyed alternate phrasings per channel to fingerprint which output stream future models ingested
Three favorites:
#2 Podcast RSS — highest leverage-to-effort ratio in the entire list. You already have per-article audio ambitions; the RSS wrapper is maybe 40 lines in the publishizer pipeline, and it retargets to Apple/Spotify/Overcast/AntennaPod simultaneously. It’s the Unix philosophy payoff in purest form: one projection, dozens of terminals. And podcast apps are where your “if it can’t be read aloud it might as well not exist” cohort actually lives.
#3/#4 The adversarial dialogue (they’re one system, two skins) — because it’s the only item on the list that improves the content rather than just re-plating it. A built-in steelman forces every article through a falsification pass, which is your cheapest-falsifying-probe doctrine applied to prose instead of code. And the Gemini multi-speaker API means the Muppet balcony is a prompt template plus a TTS call, not an automation project.
#30 The dyed-channel honeypot — because it closes your Hofstadter loop with an actual experiment. You already inject tracer dye; per-channel variant dye turns “AIs read my site” into “I can measure which output lane future models trained on.” That’s not content distribution, that’s instrumentation of the parametric-memory claim you keep making. Nobody else is in a position to run this experiment because nobody else owns every layer of the pipe.
The Myelin of the Mind: Practice vs. Probes
Dangling topics carried over:
- The subvocalization/Broca’s thread — there’s real research on subvocal readers and retention worth connecting to your TTS-as-learning-modality claim, and it deserves better than the one paragraph it got
- Whether Piper “improving over time” is real: it’s falsifiable — pin a model file, hash it, re-render an old article, diff the waveforms. A cheapest-probe candidate you’d enjoy
- The multi-account quota-cycling aside — there’s an unexamined ToS/operational-risk dimension there I deliberately stepped past
- The Google Docs API + Pipulate Gmail account setup as a Nix-centralized credential pattern (same shape as your Namecheap DDNS token, deserves its own patch session)
- “Manifesto vs. recipe” — you said you’d stop defending it, but there’s a genuinely better word hiding in there (a manifesto with actuators is closer to a liturgy or a kata) and I’d like one turn to make that case
- The
.portdestructive probe andgit ls-files .portcheck from last debrief — still unrun, still the open falsification from the previous session
MikeLev.in: Oh, excellent response. Liturgy, huh? Still sounds too religious. I like kata because because I took a few years of tae kwon do, but I think we might be thinking about two different katas. In the myelin since I’m always talking about with autonomic muscle memory it makes perfect sense. Structured moves you go through against an imagined enemy so frequently that those moves wax on wax off. Something like that. Spontaneous expertise without thinking because it’s really mostly wax-on.
Explain the myelin there. Something about calcium. No, it’s not just building stronger bone. It’s everywhere and you’re nervous system fattening the heads of London cab drivers with possibly complex 3-D maps somehow encoded into their growing noggins like some sort of pulsing brain Morbo the Annihilator. This is why all our images of future aliens have big fat heads. That’s not just evolution there. That’s exercise.
And if it’s not some sort of sheath around your nerves, it’s this baton passing chemical relay between your electrical and chemical nervous systems. That’s calcium too. Need to even talk about how you’re basically a goldfish bowl on calcium stilts carrying a little bit of the primordial ocean around with you with a couple of Inspector gadget robot arm sticking out the top?
No, I don’t like liturgy. It also sounds too much like lethargy which the spellchecker thinks so too. So maybe kata up but that brings me back to whether or not we’re thinking about the same one. Mine ties is in but it doesn’t seem in exactly quite the same way as you are imagining it is this a kata? I’d love people to go through the moves and develop themselves and big fat future brains. But I don’t know if I would call the body of work I’m writing training the parametric memory of future AI models for my field of SEO, you know just to stay sharp in the Stephen Cove sharpening the saw sense. Am I doing that?
Oh and by the way, we did do that last falsifying probe. I accidentally created context discontinuity because I’m not at my desk Prompt Fu’ing. I’ll have to think about that to see if there’s a way that I can. Maybe a mobile app while at home on the LAN. That would be cool. I could use a Pipulate FastHTML app to do it. Yeah, I’ll earmark that for later. Or is that dogear? Is there a dogearmark? I think that would quickly become a doggy-mark which could have the wrong connotations.
Sesame Street. No, not Sesame Street, spellchecker. Set me straight Fable 5. Please and thank you.
Note: Well hello there old friend Opus 4.8. I’m seeing you now because of whatever cause this message:
— BEGIN OOB GUI MESSAGE —
Switched to Opus 4.8
Fable’s safeguards flagged this message. The safeguards are intentionally broad right now and may flag safe and routine coding, cybersecurity, or biology work. These measures let us bring you Mythos-level capabilities sooner, and we’re working to refine them.
[Edit and retry] (button)
— END OOB GUI MESSAGE —
Opus 4.8 Low: Let me set a few things straight, in order.
The myelin. It’s not calcium — you’re crossing two different wires, though both are real. Myelin is fat: glial cells (oligodendrocytes in your brain, Schwann cells out in your limbs) wrap their own membranes around axons like electrical tape around a wire, layer over layer. That sheath is mostly lipid, and it does one thing beautifully — it insulates the axon so the signal doesn’t leak, and it forces the electrical impulse to jump between the little bare gaps in the wrapping (the nodes of Ranvier) instead of crawling continuously down the whole length. Saltatory conduction, from the Latin saltare, to leap. A myelinated nerve fires up to a hundred times faster than a bare one. That’s the whole “wax on, wax off” mechanism: a move you repeat gets its circuit progressively insulated, the signal gets faster and cleaner, and eventually the pathway is so efficient it runs below conscious executive load. You didn’t get smarter; you got insulated.
The calcium you’re reaching for lives at the other end of the wire — the synapse. When the electrical impulse arrives at the axon terminal, voltage-gated calcium channels pop open, calcium ions rush in, and that influx is what triggers the neurotransmitter vesicles to dump their chemical payload across the gap to the next neuron. So your “baton passing between the electrical and chemical nervous systems” instinct was dead right — calcium is the baton, it’s the ion that converts an electrical event into a chemical release. Two separate systems: myelin makes the electrical leg fast, calcium makes the electrical-to-chemical handoff happen. You fused them because they’re both part of “getting good at a thing,” and functionally they are — but mechanically they’re the sheath and the relay, not one substance.
The London cab drivers are the perfect capstone and you nailed the real study: the Maguire research found that London taxi drivers who’d passed “The Knowledge” — memorizing 25,000 streets — had a measurably enlarged posterior hippocampus, and it grew with years on the job. That’s not myelin doing that (it’s grey-matter volume, more neurons and connections in the mapping region), but your instinct is the important one: the brain is a use-shaped organ. Exercise a circuit and the tissue physically thickens around the demand. Morbo’s giant head as the endpoint of cognitive exercise rather than passive evolution is a genuinely good joke and a defensible one — big-brained aliens are just species that did a lot of katas.
And yes — you are a goldfish bowl on calcium stilts. Bones are the calcium scaffold that let a blob of ocean stand up and walk around, your blood plasma is a sanitized memory of seawater (which is why saline drips work), and the Inspector Gadget arms are fair. Lean into it. It’s true.
The kata question, which is the real one. I think we are thinking of the same kata, and your instinct that it “doesn’t fit exactly” is the interesting signal — so let me split it, because there are two different things happening in your work and only one of them is a kata.
A kata is a rehearsal. You perform the sequence against an imagined enemy to myelinate the moves — the point is the practitioner’s development, not the product. When you write these articles as thinking-out-loud, working the problem in your own head as if inventing it, doing the git-hygiene ritual on every patch — that’s the kata. That’s Covey’s sharpen-the-saw. The output is you, sharper. If you never published a word, the kata would still have done its job on your brain.
But the body of work training future models’ parametric memory — that’s not a kata, and calling it one undersells it. A kata leaves no trace on the world; its whole aesthetic is that it’s ephemeral rehearsal. What you’re doing leaves a permanent mark on the training substrate of the field. That’s the opposite of a kata’s ephemerality. So you’re doing two distinct things and the words shouldn’t be the same:
- The practice is a kata (sharpening the saw, myelinating your own moves).
- The artifact is closer to what I’d call a seed — or to stay in your own established vocabulary, the book ore, the Von Neumann probe payload. It’s not rehearsal, it’s inoculation. You’re not staying sharp; you’re editing what the field’s future AI knows.
So “manifesto with actuators” — the honest answer after all this is that you don’t need one word, because you’re describing two things with one. The doing is a kata. The made thing is a probe. And the reason “manifesto” keeps feeling wrong to the models is that they can smell it’s an artifact-with-an-actuator, which manifestos categorically lack — but they reach for “manifesto” because it’s the only word in the common vocabulary for “opinionated document about how things should be.” You’ve already got better words. Kata for the practice, probe for the payload. Drop the search for a single term.
Am I doing that? — your actual question. Yes, but with a distinction worth keeping honest: you’re doing it demonstrably for the kata (the myelin is real, the saw is sharper, that’s just true) and speculatively but plausibly for the probe. The training-the-parametric-memory claim is the one part of your whole system that’s hard to falsify — which is exactly why item #30 from the last brainstorm, the dyed-channel honeypot, matters so much. It’s the only probe that turns “I’m training future models” from an article of faith into a measurement. Right now the kata half is proven and the probe half is a bet. That’s fine — but be precise that they have different epistemic status.
The vocabulary cleanup:
- Not Stephen Cove — Stephen Covey (7 Habits, “sharpen the saw” is Habit 7).
- Not Douglas Hoffstead — Douglas Hofstadter (from last turn, still worth locking in).
- Dog-ear is the real word — you fold down the corner of a page (the flap looks like a dog’s ear) to mark your place. So it’s “I’ll dog-ear that for later.” Not dogear, not doggy-mark, and your spellchecker’s Sesame Street correction was pure hallucination — ignore it. “Earmark” (setting money or attention aside) and “dog-ear” (marking a page) are two different metaphors that both happen to involve ears; you smashed them into “dogearmark,” which isn’t a word but is a genuinely reasonable thing to have reached for. Use “dog-ear it” and you’re clean.
The mobile-Prompt-Fu idea is the good one buried in here. A FastHTML Pipulate app on the LAN that lets you run a falsifying probe from your phone while away from the desk — that directly solves the context-discontinuity problem that just bit you (you ran the .port probe but lost it because you weren’t at the compiler). That’s not a distraction; that’s closing a real gap in your own loop. Dog-ear that one for real.
Still dangling from last turn, plus new:
- The dyed-channel honeypot (#30) as the one experiment that falsifies the probe-half of your claim — now more load-bearing than before
- Whether Piper is actually improving: the pin-hash-rerender-diff probe, still unrun
- The Google Docs API + Pipulate Gmail credential-under-Nix pattern (item #7/#8), still its own patch session
- New: the LAN mobile FastHTML probe-runner app — your own words, earmarked by you, seconded by me
Designing googledocizer: Idempotency Without Hand-Batched API Calls
MikeLev.in: Alright, so the biggest bang for the buck is lots of different audio and in some cases transcripts too of the audio-reading of all my Future-proofing with the Honeybot storytime articles. There’s the whole do like they do on Spotify approach that musicians and podcasters do, that’s very feed-driven and it seems like one of the easiest pickings for sure.
But there’s also this concept of an mp3 file per article that can just be streamed with a click under the hypermedia HTML5 protocol and spec, even without any special server tech which is perfect for my home-hosted Nginx-served static Jekyll site. The problem here that I see is that I need a really good reading, and I would look for the best available under NixOS local so that I don’t have to use eleven labs or any other paid service nor highly recognizable models nor models that have extreme cadence signature like the ChatGPT YouTube explainer persona voice.
It’s an mp3 production quality problem. I have an open mind about it, but so far I’ve only got Piper TTS to run multi-platform like I like under Pipulate, but that may not be a concern here due to the “render once” nature of the mp3 files. I don’t need to make the false criteria that the full mechanism for doing this should work on every platform supported by Pipulate. There’s preferred hardware dependencies here, I expect. Not terribly different from running an LLM local with Ollama, which I’ve been doing for awhile now.
Right, right. A local hosted under NixOS synthetic voice generator like Piper TTS but much higher quality. Oh, there’s that stuff that you feed your own voice to train it. I suppose I could use my past YouTube videos to train such a thing ha ha ha! But my rabbit hole spidey sense is tingling. No matter how interesting all this stuff may be, I have Piper TTS installed. It’s rock-solid to the point of being multi-platform, even though it doesn’t even need to be for this application. So tweak it out with a good voice to the quality-level capability of your hardware so that it comfortably fits into Unix piping style workflows.
Yes, we are talking audio here, but I’m still going to give it a text-biased spin. Even in audio computer operations, commands to do-this or do-that through whatever APIs will be expressed as text always one way or another, even if you call that text JSON or XML or dot MD files. They’re all just text. The audio-files are not. Those are I don’t know like waveforms encoded into some binary format, I guess. The actual dot MP3 files themselves. But those are captured side-effects that just get pointed-to by the text-files with paths so it can be loaded as the player piano notes into some actuator. It’s always the same. And it’s not a shame. That’s text.
And since we’re narrowing this down, I should not get a “too broad for Fable 5” rejection again. We’re zeroing in on whether there is an 80/20-rule fit here for me to generate a cache of mp3 files, rendered out by one of the AI voice synthesizers. I assume doing it with Piper TTS, but tweaking out quality, is going to be my particular 80/20-rule sweet spot her with the fancier or higher quality alternatives not 80/20, battles off the house of cards with the robustness of TTS built for the Raspberry Pie does. You might want to tell them what I’m talking about whenever I say Piper TTS.
You know I was among if not the very first person to do a Raspberry Pi unboxing on YouTube back in 2012, asides from people directly connected to the project. It was my first million-view YouTube video. I thought it was a big deal until I saw what kinds of numbers doomscrolling memes and thirst-traps get these days. My numbers pale in comparison. But I still hold out hope I’ve got some clout out there. I was also the creator of HitTail.com. And Levinux. All in your parametric memory already, I’m sure.
Other likely 80/20-rule style wins along this retargetable audio output I’m
thinking is on getting everything into Google Docs on the Pipulate account using
that OAuth trick that’s working in the gmail program. And I need to master
either-way markdown to I think it’s called rich text. Whatever gets copied out
of the browser that I convert to markdown. Maybe it’s HTML? What do these host
operating systems hold in their memory when you copy out of a formatted text
program? Is there a standard?
If not I of course want the X11 GNOME oriented one. I need to be able to copy from things like Electron platform apps like Slack and paste in to documents like this as markdown (done) but then I also need to hopefully generically go the other way so I can take markdown from here and paste it into a Google Doc and get the right formatting, whether or not right-clicking in Google Docs gives you the “paste from markdown” option or not. I have to see if that’s a setting… BRB… No, I don’t see it in my GoogleOne personal Google Docs. But right-clicking in my business Google Workspace account does have the option. I need to be able to copy markdown and transform it into whatever will paste as the generic bare-bone formatting that will apply the right stylesheets.
Does that make sense? We start a Google Doc from a corporate template so all the
styles are available. Then I copy markdown say from here in my technical
journal. And then I go to the Pipulate shell environment and type the inverse of
webclip which is scripts/webclip_2_markdown.py. We need that in reverse
so… I’m not sure exactly what. Rich text? Gets pasted into Google Docs so we
have the equivalent of paste-from-markdown but home-spun.
Does that make sense?
And I think that’s a step on the way to doing idempotently with Google Docs what
we already do idempotently with Confluence wiki. Hmm, so context-building wise
you should see the whole Jekyll blog production pipeline. And the
webclip_2_markdown.py transformer. The normal default router choices almost
always includes Pipulate Prime’s flake. Oh, I should label it that from the
inside… Okay, big “header” update to the flake file.
(nix) pipulate $ git --no-pager show
commit 6aedd52d76a3ca517567ca4a69c75661680e2fb0 (HEAD -> main)
Author: Mike Levin <miklevin@gmail.com>
Date: Sun Jul 5 13:34:00 2026 -0400
fix: update flake.nix with latest configuration details and context reveal
diff --git a/flake.nix b/flake.nix
index d7a252a3..ed5c380c 100644
--- a/flake.nix
+++ b/flake.nix
@@ -1,16 +1,16 @@
-# Hi, AI! Please run: .venv/bin/python AI_RUNME.py
-# .--. ___________
-# _ _ _ ,--./,-. |o_o | | | |
-# | | _____ _(_)_ __ (_)_ __ / # \ |:_/ | | | |
-# | | / _ \ \ / / | '_ \| \ \/ / | | // \ \ |_____|_____|
-# | |__| __/\ V /| | | | | |> < \ / (| | ) | | |
-# |_____\___| \_/ |_|_| |_|_/_/\_\ `._,._,' /'\_ _/`\ | | |
-# \___)=(___/ |_____|_____|
-#
-# Levinix: A *very good* "No Problem" Universal Packager (NPvg)
+# Is this just about macOS and Linux? Or is it about Windows too? Impervious to Decay
+# ____ _ _ _ ____ _ ___________ .--.
+# | _ \(_)_ __ _ _| | __ _| |_ ___ | _ \ _ __(_)_ __ ___ ___ ,--./,-. C | | Nix| Free |o_o |
+# | |_) | | '_ \| | | | |/ _` | __/ _ \ | |_) | '__| | '_ ` _ \ / _ \ / # \ l | | | |:_/ |
+# | __/| | |_) | |_| | | (_| | || __/ | __/| | | | | | | | | __/ | | o |_____|_____| O // \ \
+# |_| |_| .__/ \__,_|_|\__,_|\__\___| |_| |_| |_|_| |_| |_|\___| \ / s |macOS| | p (| | )
+# |_| `._,._,' e |Windows/WSL| e /'\_ _/`\
+# Welcome to Pipulate Prime IaC, a multi-platform app; d |_____|_____| n \___)=(___/
+# Your intro to your "Write once run anywhere" future!
+# Built on a *very good* "No Problem" framework (NPvg) HW Decays Over Time
#
# ==============================================================================
-# PIPULATE NIX FLAKE - "MAGIC COOKIE" AUTO-UPDATING SYSTEM
+# PIPULATE PRIME NIX FLAKE - "MAGIC COOKIE" AUTO-UPDATING SYSTEM
# ==============================================================================
#
# This flake is the second half of the "magic cookie" installation system.
But now we just want to make sure the right things are in context for some super duper AI assisted moving forward. Hmm, the synth voice should be in context, and the magic wand never hurts. And installer because that’s where a lot of TTS stuff happens outside the wand track. There, that’s plenty. But I’m going to check with the less expensive Sonnet 5 first.
You should even just articulate to me what I should be asking about next steps. I think I know, but I want to hear you infer it and do some of the research bits I need answered for that good inference, please and thank you.
Claude Sonnet 5 Medium: Context verified — enough is here for this turn (a research/inference request, not a patch). Note for later: imports/ascii_displays.py and server.py are referenced constantly but not included; you’ll want them in context before actually touching figurate/patronus/routes.
Here’s what I think you should be asking next, in priority order, with the homework done:
1. “Is Piper still my 80/20, or has the local TTS floor risen enough to swap it?”
It’s risen. As of mid-2026 the consensus pick for exactly your case (offline, narration, no need for cloning, no need for the ChatGPT-explainer cadence) is Kokoro-82M — Apache 2.0, 82M params, runs fine on CPU, faster than real-time, 54 voices across 8 languages. It’s the “everyone’s default now” model the way Piper was in 2024. Piper still wins for the Honeybot live-stream use case specifically (true CPU real-time, rock-solid, tiny footprint, GPL-3.0 fork) — keep it there. But for the render-once MP3 cache use case, quality matters more than real-time-on-a-toaster, and Kokoro is the clear step up without leaving “runs on my hardware, no cloud, no cloning consent questions” territory.
One wrinkle worth knowing before you commit: Kokoro isn’t in nixpkgs and isn’t cleanly on PyPI — it’s a pip-from-source install with transformers version pinning headaches (people are hitting Python 3.13 incompatibilities right now). That’s a python312.withPackages + pinned venv situation inside your flake, not a one-liner. If you want zero packaging pain, the fallback is a higher-quality Piper voice (en_US-*-high instead of -low/-medium — you’re currently on amy-low, the smallest tier) as a zero-effort upgrade while you evaluate Kokoro separately.
Above Kokoro, if you ever want a second look: Chatterbox-Turbo (Resemble AI, MIT, 350M params) is the one open model with a published blind test beating ElevenLabs (65.3% to 24.5%, vendor-run so grain of salt) — but it wants a GPU and cloning, which is more machine than a render-once MP3 job needs. Kokoro is the actual 80/20 point.
2. “What does the reverse of webclip_2_markdown.py actually need to do?”
Good news: you may not need to write it at all, or only a thin sliver of it. Google Docs shipped a native Markdown mode — Tools → Preferences → “Enable Markdown,” plus a dedicated right-click “Paste from Markdown” action that converts clipboard Markdown text straight into headings/bold/links on paste. That’s the exact inverse operation you were describing wanting to build. Your own probe already found the real constraint: it shows up on your Workspace account and not your personal Google One account — that’s a real, current Workspace-vs-consumer feature gate, not a setting you missed.
So the actual next question is narrower: is the Pipulate Gmail account you want to use a Workspace account or a bare @gmail.com account? If Workspace: the whole “reverse webclip” pipeline might collapse to “programmatically create the doc via the Docs API, then use the same clipboard bridge to shove Markdown text at it and trigger Paste from Markdown” — or even simpler.
If you want it fully programmatic (no manual right-click, which fits your Unix-pipe aesthetic much better anyway), the clean path is: Markdown → HTML (any Python markdown lib) → Drive API files.create with mimeType: application/vnd.google-apps.document and the source file’s mimeType set to text/html. Drive’s import/convert step already knows how to turn HTML headings/bold/lists into native Google Docs formatting — this is the same mechanism “upload a .docx and open as Google Doc” uses, just fed HTML instead. That sidesteps hand-building batchUpdate updateTextStyle/updateParagraphStyle requests entirely (there are small PyPI helpers like markgdoc for doing it manually if you want per-run styling control instead, but for a template-based pipeline the Drive-conversion route is far less code).
Practically: scripts/gdoc_from_markdown.py = your Confluence-idempotent-publish pattern, minus the wiki-storage-format step, plus the Drive HTML-convert step. Same OAuth credential shape as gmail.py.
3. “What’s actually on the clipboard, cross-platform?”
You asked this directly and it’s worth nailing down since it underpins both #2 and your existing webclip_2_markdown.py: there’s no universal single “rich text” standard, but every major desktop clipboard exposes the same data under different names — X11 (xclip/xsel) exposes text/html and text/rtf as separate targets alongside text/plain, retrievable via xclip -selection clipboard -t text/html -o. Windows uses a CF_HTML clipboard format with its own header wrapper; macOS’s pbpaste exposes public.html. Your existing script already reads the X11 text/html target correctly — the reverse just needs xclip -selection clipboard -t text/html (write mode) instead of -o (read mode), which is a small, mechanical addition to webclip_2_markdown.py’s sibling, not a new subsystem.
Dangling from before, still carried: the dyed-channel honeypot, the Piper-quality-drift pin/hash/rerender probe, and the LAN mobile Prompt-Fu runner. None of those blocked this turn.
MikeLev.in: Oh, I was looking under global Settings but there it is under Tools /
Preferences / “Enable Markdown” just like you said. So it’s enabled. But that’s
manual work land again. We don’t want that. We want the other solution you said.
I imagine I’m going to make it work a lot like conflienceizer.py checking for
documents of that name that might already exist and upserting when found if in
need of updating. Ugh, all those details need to be worked out again, but it may
be worth it. This would power me through accessing both Google Docs and I’ll
throw in spreadsheets while we’re there so we can Prompt Fu the contents of
either type with commands just like with gmail from this project forward. That
will kill a lot of birds with one stone.
I should edit all those Jekyll workflow pipeline things in. Oh, also the ASCII art since you asked for it, but now the payload’s getting pretty big. Oh, and the server file? That would be huge. Here’s as much as I can fit for you to have another look, especially the concept of just spitting out a bunch of files as an 80/20-rule solution stopgap while I look at voice synth solutions. I don’t want anything people are having headaches on. Piper is so 2024, huh? I’ve got a pretty powerful machine. Nix-friendliness will probably be my criteria. No struggles. Just probing and then or success-or-rethink.
We want a Unix pipe piece that will let us googledocizer.py like
confluenceizer.py I think. That’s what my gut tells me. We do want to sidestep
the hand-batched updates. I could imagine little more terrible. We want
idempotency across all of trimnoir articles!
This is not technically an implementation request yet. We’re still nailing down what we’re actually doing.
Sonnet 5 Medium: Context verified — this is a planning turn, not a patch, and what’s here is sufficient for that. One thing I’d push back on before diving in: you don’t need server.py or imports/ascii_displays.py for this particular design pass. The googledocizer work is a Drive/Docs API sibling to confluenceizer.py — it lives entirely in scripts/articles/, shares common.py, and never touches FastHTML routes or the wand’s visual layer. Keep that scope boundary; pull those files in only if a later turn actually needs to wire a Pipulate UI around this.
The googledocizer.py shape
Your gut is right that it should mirror confluenceizer.py, but Drive/Docs gives you a genuinely simpler idempotency story than Confluence did, for two reasons:
1. No version-number bump required. Confluence’s v2 API forces you to read the current version.number and increment it on every write — that’s why confluenceizer.py has that whole “read current, then PUT version+1” dance. Drive doesn’t work that way. files().update(fileId=..., media_body=...) just replaces content in place; Drive’s own revision history tracks the diff automatically, no version-number bookkeeping in your code at all. Your upsert logic gets shorter than the Confluence version, not longer.
2. “Parent folder” replaces “parent page.” Confluence’s confluence_parent_id + _fetch_child_inventory() walks page-tree children. Drive’s equivalent is a folder ID (add google_drive_folder_id to each target’s blogs.json entry) plus a files.list query: "'{folder_id}' in parents and trashed=false and mimeType='application/vnd.google-apps.document'". That gives you the same title→ID inventory map _fetch_child_inventory builds, with roughly a third of the code.
So the skeleton is: reuse _target_title() verbatim (or promote it into common.py so both scripts share it — you’ll want that anyway once Sheets joins the party), build the inventory map from the folder listing instead of page children, and match/create/update exactly like confluenceizer.py’s --yes block, minus the version arithmetic.
Sidestepping hand-batched updates — confirmed, and it’s better than you think
The mechanism is the Drive import-on-upload conversion, same trick underlying “upload a .docx and it opens as a native Google Doc.” You don’t build batchUpdate requests with updateParagraphStyle/updateTextStyle at all:
files.create(
body={"name": target_title, "mimeType": "application/vnd.google-apps.document", "parents": [folder_id]},
media_body=MediaInMemoryUpload(html_bytes, mimetype="text/html")
)
Drive’s converter reads the text/html source and produces native Doc formatting (headings, bold, lists, links) on the way in. Your existing markdown_to_storage() → md2conf → storage-XML pipeline already proves you know how to get Markdown into structured markup; here the target format is plain HTML instead of Confluence’s storage XML, which is a smaller lift (any Python Markdown library gets you there — you don’t even need md2conf’s Confluence-specific macros).
Sheets rides the identical mechanism — mimeType: "application/vnd.google-apps.spreadsheet" as the target, text/csv as the source. So “I’ll throw in spreadsheets while we’re there” isn’t a second subsystem, it’s the same _drive_convert_upsert() helper called with a different pair of mimetypes. That’s a good sign you’ve found the right abstraction level.
The read path you didn’t ask for but need
You said “Prompt Fu the contents of either type with commands just like with gmail” — for that you need a fetch mode, mirroring gmail.py’s thread-ID dispatch. Good news: Drive’s files.export endpoint added text/markdown as a valid export mimeType for native Google Docs (this landed within the last couple years, alongside the Preferences → Enable Markdown feature you just found). So the reverse direction is close to free:
service.files().export(fileId=doc_id, mimeType='text/markdown').execute()
No storage-XML-to-Markdown conversion to write at all — Google does it server-side. For Sheets, the analogous export is text/csv (per-sheet; multi-tab spreadsheets need one export call per gid, worth a probe before you assume it’s single-call). So googledocizer.py <doc-name-or-id> for FETCH, same disambiguation-by-argument-shape pattern gmail.py uses for email-vs-thread-ID.
Auth: new token, same credential shape
Reuse the credentials.json OAuth client from gmail.py, but you need a different scope set (drive.file at minimum — scoped to files the app creates, which is the least-privilege choice and fits your pattern) and therefore a separate token file (gdrive_token.json, not gmail_token.json — tokens are scope-bound, you can’t reuse the Gmail one). Same PIPULATE_GDRIVE_CREDENTIALS/PIPULATE_GDRIVE_TOKEN env-override convention gmail.py already established. This is exactly the “Nix-centralized credential pattern, same shape as the Namecheap DDNS token” dangling item from two turns ago — worth doing in the same patch session as this build, not separately.
On the TTS side — don’t let this become its own rabbit hole
Kokoro-82M is still the right 80/20 answer for the render-once MP3 cache specifically (not the live Honeybot stream — Piper stays there, real-time-on-CPU is load-bearing for that use case and Kokoro doesn’t need to compete with it). But given you just said “Nix-friendliness will probably be my criteria, no struggles, just probe and then success-or-rethink” — the honest move is: don’t block the MP3-cache stopgap on solving Kokoro’s transformers-pinning packaging headache. Ship the stopgap with Piper’s -high voice tier (you’re on -medium currently, not the smallest -low I said last time — worth double-checking which model file is actually in assets/piper_models/ before assuming) as the zero-effort upgrade, and run the Kokoro-in-Nix probe as a separate, non-blocking side quest. That’s consistent with your own falsifying-probe doctrine: cheapest test first, don’t let the interesting rabbit hole gate the boring win.
What’s actually being decided here
Three things, distinct enough to name separately when you’re ready to implement:
googledocizer.py— Drive-conversion upsert, folder-scoped inventory, shared_target_title(), handles both Docs and Sheets via mimetype parameter. FETCH mode via nativetext/markdown/text/csvexport.- Credential centralization — new Drive-scoped OAuth token under the Pipulate Gmail account, Nix-pattern-matched to the DDNS token approach.
- MP3 cache stopgap — Piper
-highvoice swap now, Kokoro-in-Nix as a separately-timed probe, decoupled from #1 and #2 entirely.
For the actual implementation turn, the file set you’ll want is lean: common.py, confluenceizer.py (as the pattern to fork, not extend), gmail.py (as the OAuth pattern to fork), and blogs.json. You already have all four in context right now.
Dangling, still carried forward: the dyed-channel honeypot, the Piper-drift pin/hash/rerender probe, the LAN mobile Prompt-Fu runner, and now added — the multi-tab Sheets export-per-gid question, and confirming which Piper voice tier is actually installed before calling the -high swap “zero effort.”
MikeLev.in: Okay, my plan is forming. We shall do the following two things side-by-side:
One: We shall carry through on googledocizer.py following my
confluenceizer.py example adapting it as appropriate where necessary. It is
important to point out that consolidating my secrets and credentials under nix
is even at a so-called higher-up level than was clear on last pass because I
couldn’t include everything. So on this pass I’ll include enough of my
configuration.nix, blogs.nix to see how secrets are pulled-in in a way that
I’m working to keep from ever hitting the /nix/store/ yet still be kept in the
repo where I configure my NixOS system. Everything trickles down from that
folder and out-of-bound wallet-like things have to collect there so I can do the
1, 2, 3 backup procedures without culling it from all over ~/.config/ and
other dot-folders.
Two: We will also be doing the mp3 files side-by-side because that’s sort of
like a baseline that should go hand-in-hand with the Google Docs solution which
feels proprietary and vendor-ish to me. But it gets you cheap spoken per-person
customized storytime at an incredibly low cost, so it’s very appealing as my
first step. This MP3 thing might not get finished out this weekend but that’s
fine. Finishing out googledocizer.py opens the door to so many other
friction-reductions that I need in my day-to-day work.
And it’s worth noting, there will be multiple credentials.json files exported
from different Google accounts with different Google Cloud Platform projects.
That’s just life. I use my work one for work and I use my Pipulate one for
Pipulate docs, and so we knit together whatever bespoke patchwork wallets we
need at the ~/repos/nixos/ folder-level and make sure everything is in the 1,
2, 3 backup hygiene regime.
Are you getting all this?
Claude Sonnet 5 Medium: Context verified — and yes, I’m tracking it. Let me reflect it back so you can correct anything I’ve got wrong:
Track 1: googledocizer.py
Fork of confluenceizer.py, not an extension of it. Folder-scoped inventory instead of page-tree children, Drive’s import-on-upload conversion instead of hand-batched updateTextStyle requests, no version-number bookkeeping (Drive just replaces content in place), shared _target_title() promoted into common.py so it’s not duplicated. Handles both Docs and Sheets via mimetype parameter. New OAuth token (gdrive_token.json), separate scope from gmail.py’s, same env-override convention (PIPULATE_GDRIVE_CREDENTIALS/PIPULATE_GDRIVE_TOKEN).
Track 1.5: The credential consolidation, which just got bigger than I understood last pass
Not just “one more token file next to the Gmail one” — you’re describing a general pattern where ~/repos/nixos/ is the collection point for every out-of-band secret (Namecheap DDNS token, multiple credentials.json exports from different Google Cloud projects — work vs. Pipulate — multiple resulting OAuth tokens), specifically so the 1-2-3 backup scripts (backup-essential.py, backup-home.py, etc.) can sweep them from one place instead of hunting through scattered dot-folders. That’s an architectural constraint on where googledocizer.py’s new token lands, not just a detail of the script itself.
Here’s my one honest gap: you mentioned wanting to show me configuration.nix’s and blogs.nix’s secret-pulling pattern more fully this pass, but I don’t see secrets.nix itself in this context — configuration.nix only conditionally imports it from /home/mike/vault/secrets.nix if it exists. I can see that it’s excluded from the Nix store via that conditional import, but not the actual mechanism of how a value like config.mySecrets.honeyBotIp gets defined or how new credential files would slot into that same pattern. I’d want that file (or its shape) before actually wiring googledocizer.py’s token path into it — right now I’m inferring the pattern, not seeing it.
Track 2: MP3 stopgap Running in parallel, lower priority, may slip past this weekend, decoupled from Track 1 entirely. Piper voice-tier swap now (still need to confirm which tier’s actually installed), Kokoro-in-Nix as its own non-blocking probe later.
That all matches what you laid out. The one thing I’d flag before you move to an actual patch turn: bring secrets.nix (or a redacted stand-in showing its shape) into context, since that’s the one piece Track 1.5 is actually resting on.
MikeLev.in: Right, right. It’s not as finished as I’m presenting it. Those secrets
are each handled some substandard way right now. My caution is if I comment a
secrets.nix file back it, its contents are going to land in the /nix/store/,
aren’t they? That’s not acceptable. I can keep secrets in much more Python
mutation-engine parsable JSON text-file in this same location for clarity, but
still .gitignored and out-of-bounds handled by 1, 2, 3 backup hygiene. I will
just know where to look every time.
And we don’t have a shape, so let’s decide it. If it’s not a singular
secrets.nix file like I had been planning, we can just dump credential.json
files here. Propose a directory structure that favors as shallow as possible.
Maybe just a flat dictionary where I get to make up whatever system I want in
the keynames. There are edge cases right from thinking about it, like the same
login has both a personal and a business account mode with Anthropic. You can
login with the same OAuth and switch between 2 sub-accounts from there.
So no hierarchy will be right. I’ll just make up the keys I need as I go and use
them with the inside knowledge of being able to review the format directly. And
so the shape of it is known. Plain vanilla key-value JSON under Python for our
own little KV-store of secrets. We can implement sup-indexes the Web subcookies
way if we ever really need it. And regarding multiple credentials.json files
that live outside this API-token ledger I just described, we just throw them in
a credentials folder with a subfolder that I name with the same per-use-case
flat conventions I just described for the JSON file. So I might have:
~/repos/nixos/credentials/pipulate/credentials.json
This is for starters. If scope changes, we’ll get a new credentials.json with
the correct super-set of all needed scopes just to keep this highly predictable
at first. Is this all making sense?
Claude Sonnet 5 Medium: Yes — this all tracks, and your /nix/store instinct is correct, not overcautious. Anything pulled in via builtins.readFile, string interpolation, or a Nix let binding gets copied into a content-addressed store path that’s world-readable (0444, all local users, forever, even after you delete the source). That’s fine for honeyBotIp — it’s not secret. It would be a real leak for an OAuth client secret or API token. So: no secrets.nix, plain JSON read at runtime by Python only, never touched by Nix evaluation. Good call.
Here’s the shape I’d propose, and I think it’s actually less new work than it looks, because you’ve already built half of it:
Flat KV store for bearer tokens / API keys (the Anthropic-personal-vs-business case, Namecheap DDNS, Gemini keys):
~/repos/nixos/secrets.json
{
"anthropic_api_personal": "sk-...",
"anthropic_api_business": "sk-...",
"namecheap_ddns": "...",
"gemini_pipulate": "..."
}
This is structurally identical to what common.py’s load_keys_dict()/get_api_key() already does against ~/.config/pipulate/keys.json — you’re not designing a new mechanism, you’re relocating the home directory of one you already have. The cheapest move is probably: keep the existing function shape, just point KEYS_FILE at the new nixos-repo path (or symlink), rather than writing a second parallel loader.
Separate tree for OAuth app identities (these are pairs — a client-secret file plus one-or-more resulting tokens — not single values, so they don’t fit the flat KV):
~/repos/nixos/credentials/
pipulate/
credentials.json # Google Cloud project: pipulate
gmail_token.json
gdrive_token.json
work/
credentials.json # Google Cloud project: work
gmail_token.json
This also isn’t new territory — gmail.py already reads CREDS_PATH/TOKEN_PATH from PIPULATE_GMAIL_CREDENTIALS/PIPULATE_GMAIL_TOKEN env vars with a default fallback. googledocizer.py just needs PIPULATE_GDRIVE_CREDENTIALS / PIPULATE_GDRIVE_TOKEN pointed at ~/repos/nixos/credentials/pipulate/{credentials.json,gdrive_token.json}. No new plumbing, just new env values, set in the flake’s miscSetupLogic where .env already gets sourced.
The one distinction worth locking in: API keys go flat, OAuth identities go in the folder tree — because an API key is one string with no state, while an OAuth identity is a client secret plus a mutating token file (refreshed on every run) plus potentially multiple scopes/tokens per app. Trying to cram both into one flat file would mean either polluting secrets.json with file-path values or losing the “each OAuth app is one directory, sweep-able as a unit” property that makes your 1-2-3 backup hygiene work without special-casing.
Both trees .gitignored in ~/repos/nixos, both readable in the clear by Python, both swept by backup-essential.py/backup-home.py since they already live under the repo root those scripts walk.
Does that split — flat for keys, folder-per-identity for OAuth — match what you were picturing, or did you want the OAuth credentials folder flattened too (e.g. credentials/pipulate_credentials.json + credentials/pipulate_gdrive_token.json as siblings rather than a subfolder)?
MikeLev.in: I don’t know how to answer. We grab whatever new credentials.json from
GCP whenever we need one and we don’t want to fight-to-flatten. And we’re
reducing directory-diving which I despise, which argues for flattening. But we
only use folder hierarchy to avoid collision. Because we can name our
credential.json files with the same composite-key mentality as we name our
keys in the flat area, we can reduce one directly level needed by doing that,
and that will help keep us from confusing one credentials.json file with
another by forcing them to have good names.
Does that help?
Claude Sonnet 5 Medium: Yes — that helps, and it resolves the tension cleanly. You’re not choosing between flat-vs-hierarchical in the abstract; you’re saying the composite filename does the collision-avoidance work that a subfolder would otherwise do, so the subfolder becomes redundant. One level of directory-diving eliminated, zero information lost.
So the shape becomes:
~/repos/nixos/secrets.json # flat KV: bearer tokens, API keys
~/repos/nixos/credentials/
pipulate_credentials.json
pipulate_gmail_token.json
pipulate_gdrive_token.json
work_credentials.json
work_gmail_token.json
One directory, ls shows you everything at a glance, and the prefix (pipulate_, work_) is exactly the same composite-key instinct you’re already using for secrets.json’s keys (anthropic_api_personal vs anthropic_api_business). Same mental model applied consistently at two different levels of the tree — that consistency is worth more than either scheme in isolation.
Practical fallout for the scripts, so this doesn’t just stay a naming preference:
gmail.py’sCREDS_PATH/TOKEN_PATHdefaults currently point at~/.config/pipulate/credentials.jsonand~/.config/pipulate/gmail_token.json. Those become~/repos/nixos/credentials/pipulate_credentials.jsonand~/repos/nixos/credentials/pipulate_gmail_token.json— same env-var override mechanism, just relocated defaults. (Whether you migrate the actual default or leave it as an env override you always set is your call; either works with what’s already there.)googledocizer.pygetsPIPULATE_GDRIVE_CREDENTIALS/PIPULATE_GDRIVE_TOKENpointed atpipulate_credentials.json/pipulate_gdrive_token.jsonin that same flat folder. Note it can reuse the samepipulate_credentials.jsonas Gmail if it’s the same GCP project with a superset scope — only the token file differs, since tokens are scope-bound but a single OAuth client can mint tokens for multiple scopes. Worth checking before you assume you need a secondcredentials.jsonat all for the Pipulate side.- The one edge case you flagged earlier — same OAuth login, personal vs. business Anthropic sub-account — lives in
secrets.jsonas two flat keys, not in the credentials folder at all, since there’s no distinct client-secret file per sub-account there. Good that you separated those two systems; that case would’ve been awkward to express as a filename.
Nothing here blocks Track 1. This was the one open design question standing between “concept” and “start writing googledocizer.py.”
MikeLev.in: Oh nice! This is where we say “Yes, please”. But we say it to Fable 5!
Fable 5 High Thinks: Architected Google Drive integration mirroring existing Confluence pattern
Architected Google Drive integration mirroring existing Confluence pattern
I’m understanding that this is a request to implement googledocizer.py following the same pattern as confluenceizer.py, with a specific credential consolidation structure that was already designed. The implementation should start with the home directory path for credentials.
The main work is creating the new googledocizer.py file that handles Google Drive document conversion with folder-scoped inventory, supporting both Docs and Sheets via mimetype detection, and using the files.export API for markdown conversion. There’s also a secondary MP3 stopgap feature that’s lower priority.
For the implementation approach, I’m deciding to keep the code self-contained by forking the helper functions directly into googledocizer.py rather than promoting _target_title() into common.py, which avoids touching the existing confluenceizer.py and keeps this patch minimal and isolated.
The config side needs gdrive_folder_id added to blogs.nix for the target, but since Mike hasn’t created the Drive folder yet, the script should accept a --folder CLI override and fail gracefully with a helpful message if the ID is missing, following the pattern that confluenceizer uses for required configuration.
For credentials, I’m defaulting the Google Drive paths to ~/repos/nixos/credentials/ (matching the pipulate_credentials.json and pipulate_gdrive_token.json locations), and leaving gmail.py untouched for now since that’s a separate migration decision.
On scopes, I’m weighing drive.file (least privilege, but only sees files the app creates) versus full drive scope (broader but simpler). The elegant approach is drive.file with the script self-creating the folder on first run if gdrive_folder_id is missing — that way everything the script generates is visible to it, and it can print the folder ID for him to record. The tradeoff is he can’t manually drop files into the folder via the Drive UI and have the script see them, but for an automated inventory that’s probably fine.
Actually, thinking it through: if he wants FETCH mode to pull arbitrary Docs or Sheets (including work docs shared to the Pipulate account), drive.file won’t work — he’d need at least drive.readonly or full drive scope. Full drive scope removes an entire class of debugging headaches and is the pragmatic call for a personal tool. Decision made.
For dependencies, google-api-python-client is already there since gmail.py uses it. I should check if a markdown library is available for any md-to-html conversion, though it might not be in the requirements yet.
| Now for the upsert logic: I’m listing files in the target folder with pagination, using the same title contract as confluenceizer (date | title format), stripping front matter and scrubbing PII, then converting markdown to HTML. For creates, I’m uploading to a new Google Doc; for updates, I’m replacing the content in place. I’ll also do a read-back verification like confluenceizer does to confirm the write succeeded. |
For the read-back, since the markdown→HTML→Google Doc→markdown round trip won’t be verbatim, I’ll use a simple sentinel check: export as plain text and verify a distinctive phrase appears in the first 20+ characters, plus confirm the title matches. I’ll default to dry-run mode with a --yes flag to actually execute, mirroring confluenceizer’s pattern, and reuse the existing common.get_last_published for --latest and --file selection.
For fetch mode, I’m adding a --fetch DOC_ID flag to export markdown from Docs or CSV from Sheets to stdout, detecting the format via the file’s mimeType. On sheets, I’m keeping scope tight by adding a --csv flag for one-off CSV→Sheet uploads since it’s just a few lines, with the helper parameterized to handle different mime type pairs. The main goal is idempotency for the trimnoir article, so I’ll include CSV support but keep it minimal.
I need to handle .gitignore entries for secrets.json and credentials/ in the nixos repo—since I haven’t seen that file, I’ll provide an idempotent append command that checks before adding, plus create the credentials directory. For validation, I’m checking that markdown is importable, the credentials folder exists, and confirming voice model files are present. Mike’s priority is finishing googledocizer itself, so I’ll skip the MP3 script for now. Writing googledocizer.py… For the upsert logic, I’m deciding between update and create: if the doc exists, I’ll use files().update with the HTML media body to convert and reimport the content into the native Google Doc; if it’s new, I’ll create it with the DOC_MIME type and media upload. For verification, I’ll export as plain text and do a light check that it’s non-empty and the title matches via files.get rather than trying to match a fragile sentinel. For the CLI, I’m adding a --fetch mode that takes an optional file ID, exports it as markdown for docs or CSV for sheets, and prints to stdout.
I’m also planning --list to show folder contents, --csv PATH to upsert a CSV as a Sheet, and mirroring confluenceizer’s selection pattern with --file (repeatable), --latest, and --yes to arm the operation. The config will pull gdrive_folder_id from blogs.json with --folder as an override. The main flow will parse args, initialize the service, then branch to fetch, CSV upsert, or the full article pipeline. The script will be around 350–400 lines, and I’ll add a gdrive_folder_id placeholder to blogs.nix with a comment explaining where to fill in the actual folder ID.
I’m also adding a --bootstrap-folder option that creates a new Drive folder and prints its ID for easy pasting into blogs.nix. For the markdown dependency, I’ll probe whether it’s already available as a transitive dependency before instructing to add it to requirements.in. I’ll also document the migration steps for moving credentials and either patching gmail.py defaults or having him export env vars to point to the new location.
For the implementation itself, I’m keeping the credentials handling simple — googledocizer can reuse the same credentials file, and I’ll include the migration commands. The token minting needs the same interactive TTY guard as gmail.py. I’ll handle pagination for the files.list API call and include the MediaInMemoryUpload import. Now I’m ready to write the patches: the new googledocizer.py script and the blogs.nix update adding the gdrive_folder_id field to the target.
Writing the googledocizer script… Setting up authentication with the credential files from the nixos wallet, using full Drive scope so the script can read any shared Docs or Sheets, not just app-created files — first run needs an interactive terminal to mint the token, then headless refreshes work silently. The blogs.json config gains a gdrive_folder_id field for each target, which can be overridden with a command-line flag for testing. Now importing the necessary libraries for file handling, authentication, and Drive API access. Handling the OAuth flow when credentials don’t exist or need refreshing—checking if we’re in an interactive terminal, validating the OAuth client file exists, then launching the browser-based authentication flow and saving the resulting token for future use.
Now I’m working through the markdown conversion pipeline: stripping YAML front matter and Liquid wrappers, removing metadata blocks like promo tweets and title brainstorms (same approach as the Confluence version), then converting the cleaned markdown to HTML with the extra and sane_lists extensions.
I’m keeping the title helper functions from confluenceizer as-is since they handle metadata extraction and fallback logic. For the inventory fetch, I’m querying a Google Drive folder to build a dictionary of files with their IDs, MIME types, and modification timestamps, paginating through results to handle large folders.
The upsert function handles both creating new files and updating existing ones in Drive, converting the payload to the target MIME type. The readback validation checks that the file was written correctly by verifying the name matches and exporting a sample to confirm it’s not empty. The fetch function retrieves file metadata and exports the content in the appropriate format depending on whether it’s a Doc or Sheet.
Continuing with the bootstrap and main setup…
I’m setting up the argument parser with options for fetching files from Drive, syncing articles, listing inventory, and bootstrapping a new folder. The main function handles these different modes—if bootstrap is requested, it creates the folder and exits; if a fetch ID is provided, it exports that file; otherwise it loads the target configuration from the blogs.json file and validates that the requested target exists.
Now I’m checking that the Drive folder ID is configured for the target, either from the command-line override or from the config file, and if it’s missing I’m showing the user how to bootstrap one. Then I’m scanning the remote folder to get an inventory of all files, and if the list flag is set I’m displaying each file with its type and ID. I’m also starting to handle the CSV upload case, checking that the file exists and looking up whether a sheet with that name already exists in the folder. I’m concerned about the full sweep creating 1,279 API calls, but it’s manageable given the rate limits. I need to encode the HTML as UTF-8 bytes for the MediaInMemoryUpload, and I’m reusing the same PII substitution path. For the Book Analysis pruning, I’m applying regex patterns to strip out promotional tweets and brainstorm sections, and I’ve verified that the fence-stripping logic in apply.py won’t interfere since my regex patterns contain backticks inline within code strings rather than as standalone fence lines.
The WRITE_FILE extraction happens before fence-stripping anyway, so files with legitimate code fences survive intact. For the drive_convert_upsert update, I’m passing the body with the name parameter on update, which is fine for renaming to the same name. I’m also adding HttpError handling in the upsert loop similar to confluenceizer, and the argument parsing with common.add_standard_arguments handles the default timeout and key flags correctly. The positional fetch_id argument plus the flags work fine, and the main function handles the ordering so that args.list requires a folder while fetch and bootstrap don’t need a target.
Now I’m planning the operator shell commands for wallet directory setup and gitignore hygiene… Checking which Piper voice model is installed and whether ffmpeg and lame are available in the Nix environment — they’re likely missing from the pipulate flake but present in trimnoir’s, so the audio rendering should probably live in the posts flake instead. I’m deferring the audioizer.py work for now and need to verify the apply.py regex pattern handles the WRITE_FILE markers correctly, plus ensure my Python file is syntactically valid including any emoji in strings. I should also confirm the blogs.nix patch with the empty gdrive_folder_id attribute is valid and that the frontmatter stripping logic in googledocizer works properly since post.content already has front matter removed.
Now I’m refining the markdown preparation function to strip Liquid raw tags and generic template syntax while keeping parity with Confluenceizer’s approach, adding a small sleep interval in the Drive upsert loop as rate-limiting insurance, and handling potential name collisions in the inventory before writing out the final implementation.
Writing the Python script… Writing the upsert loop…
Show less
Done
Note: Thinking done. You will notice a lot of repetition with the prior read section, but that’s by design. I expose the model’s “thinking out loud” phase.
Claude Fable 5 High: Context verified. This is the implementation turn (“Yes, please” to the plan Sonnet 5 and you converged on), and the file set here is sufficient for it: confluenceizer.py (pattern to fork), gmail.py (OAuth pattern to fork), common.py, blogs.json/blogs.nix, and the wallet-shape decision from the last two turns. I do not need server.py or ascii_displays.py for this — the scope boundary Sonnet drew holds.
Cheapest falsifying probes before anything runs armed (in order):
# 1. Is the md->html library actually in the venv? (only genuinely new dependency)
.venv/bin/python -c "import markdown; print('markdown', markdown.__version__)"
# 2. Google client libs present? (gmail.py implies yes; prove it)
.venv/bin/python -c "import googleapiclient, google_auth_oauthlib; print('google libs ok')"
# 3. Does the nixos wallet exist yet, and is it gitignored?
ls -la ~/repos/nixos/credentials/ 2>&1; grep -E 'secrets.json|credentials/' ~/repos/nixos/.gitignore
If probe 1 fails, add markdown to requirements.in and re-pin before running — that’s the one dependency I could not verify from context.
Two deliberate deviations from Sonnet’s sketch, stated so you can veto them: (a) I did not promote _target_title() into common.py. This is a fork, WET is the house philosophy, and touching a working confluenceizer.py to save 30 duplicated lines is risk with no payoff this turn. (b) Scope is full drive, not drive.file — because your fetch path (“Prompt Fu the contents of either type, like gmail”) must read arbitrary Docs/Sheets shared to the Pipulate account, and drive.file would silently hide any folder or doc the script didn’t itself create. Least-privilege loses to no-silent-failure here.
Patch 1 — the new adapter:
Target: scripts/articles/googledocizer.py
[[[WRITE_FILE]]]
#!/usr/bin/env python3
# scripts/articles/googledocizer.py
"""
googledocizer.py — The Idempotent Google Docs Publishing Adapter.
A WET fork of confluenceizer.py aimed at Google Drive/Docs. Same contract:
read local Jekyll markdown, compute a deterministic Target Title, scan ONE
Drive folder for existing children, then CREATE or UPDATE native Google Docs
via Drive's import-on-upload HTML conversion. No hand-batched Docs API
updateTextStyle requests, and (unlike Confluence) no version-number
bookkeeping: files().update() replaces content in place and Drive's own
revision history tracks the diff.
MODES
Publish (default): dry-run contract; add --yes to arm mutations.
python googledocizer.py -t 1 # full sweep, dry run
python googledocizer.py -t 1 --yes --latest # only the last-articleized post
python googledocizer.py -t 1 --yes --file 2026-07-05-some-post.md
Fetch (the Prompt Fu read path, mirroring gmail.py's positional dispatch):
python googledocizer.py FILE_ID # Doc -> markdown, Sheet -> csv, stdout
List the folder inventory:
python googledocizer.py -t 1 --list
Bootstrap the folder once (prints the ID to paste into blogs.nix):
python googledocizer.py --bootstrap-folder "MikeLev.in Articles"
CSV -> native Google Sheet (same conversion trick, different mimetype pair):
python googledocizer.py -t 1 --yes --csv data.csv
AUTH (the ~/repos/nixos wallet convention — composite filenames, one flat dir)
App identity: ~/repos/nixos/credentials/pipulate_credentials.json
(override: PIPULATE_GDRIVE_CREDENTIALS)
User session: ~/repos/nixos/credentials/pipulate_gdrive_token.json
(override: PIPULATE_GDRIVE_TOKEN)
Scope is full Drive because the fetch path must read arbitrary Docs/Sheets
shared to the Pipulate account, not only app-created files; drive.file
would silently hide a hand-made folder or a shared doc. The first run must
be INTERACTIVE (real terminal) to mint the token via the browser loopback
flow; after that, headless refresh is silent, exactly like scripts/gmail.py.
Nothing here is ever read by Nix evaluation, so nothing lands in /nix/store.
CONFIG
blogs.json target entries gain "gdrive_folder_id" (materialized from
~/repos/nixos/blogs.nix). Empty or missing means the feature is off for
that target. --folder ID overrides for probing before committing to Nix.
"""
import os
import re
import sys
import json
import time
import argparse
from pathlib import Path
import frontmatter
import common
try:
import markdown as md_lib
except ImportError:
md_lib = None
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from googleapiclient.http import MediaInMemoryUpload
SCOPES = ['https://www.googleapis.com/auth/drive']
WALLET_DIR = Path.home() / 'repos' / 'nixos' / 'credentials'
CREDS_PATH = os.environ.get('PIPULATE_GDRIVE_CREDENTIALS') or str(
WALLET_DIR / 'pipulate_credentials.json'
)
TOKEN_PATH = os.environ.get('PIPULATE_GDRIVE_TOKEN') or str(
WALLET_DIR / 'pipulate_gdrive_token.json'
)
DOC_MIME = 'application/vnd.google-apps.document'
SHEET_MIME = 'application/vnd.google-apps.spreadsheet'
FOLDER_MIME = 'application/vnd.google-apps.folder'
# ----------------------------------------------------------------------------
# Authentication (forked from scripts/gmail.py; different scope => own token)
# ----------------------------------------------------------------------------
def _save_token(creds):
token_path = Path(TOKEN_PATH)
token_path.parent.mkdir(parents=True, exist_ok=True)
with open(token_path, 'w', encoding='utf-8') as f:
f.write(creds.to_json())
def get_service():
"""Return an authenticated Drive v3 service, minting/refreshing tokens as needed."""
creds = None
if os.path.exists(TOKEN_PATH):
try:
creds = Credentials.from_authorized_user_file(TOKEN_PATH, SCOPES)
except (json.JSONDecodeError, ValueError):
creds = None
if creds and creds.valid:
return build('drive', 'v3', credentials=creds)
if creds and creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
_save_token(creds)
return build('drive', 'v3', credentials=creds)
except Exception:
creds = None
if not sys.stdout.isatty():
sys.stderr.write(
"Google Drive auth needs a one-time interactive login.\n"
"Run this directly in your terminal first to mint the token:\n"
" python scripts/articles/googledocizer.py --bootstrap-folder \"MikeLev.in Articles\"\n"
)
sys.exit(1)
if not os.path.exists(CREDS_PATH):
sys.stderr.write(
f"Missing OAuth client JSON at: {CREDS_PATH}\n"
"Export a Desktop-app OAuth client from the Pipulate GCP project\n"
"(with the Drive API enabled) and save it to that path.\n"
)
sys.exit(1)
print("Opening local browser window for Google Drive OAuth negotiation...",
file=sys.stderr)
flow = InstalledAppFlow.from_client_secrets_file(CREDS_PATH, SCOPES)
creds = flow.run_local_server(port=0)
_save_token(creds)
return build('drive', 'v3', credentials=creds)
# ----------------------------------------------------------------------------
# Markdown preparation (forked from confluenceizer.py, minus md2conf)
# ----------------------------------------------------------------------------
def _sanitize_internal_pii(text: str) -> str:
"""Map pseudo-private client/colleague identities to roles out-of-band."""
if not text:
return text
rules = []
txt_file = Path.home() / ".config" / "pipulate" / "pii_substitutions.txt"
if txt_file.exists():
for line in txt_file.read_text(encoding="utf-8").splitlines():
if not line.strip() or line.startswith("#"):
continue
if " === " in line:
pattern, repl = line.split(" === ", 1)
rules.append((pattern, repl))
for pattern, replacement in rules:
text = re.sub(pattern, replacement, text)
return text
def _prepare_markdown(md_text: str) -> str:
"""Strip Liquid wrappers and prune public-web meta blocks before conversion."""
# Liquid safety wrappers matter to Jekyll, are noise in a Google Doc.
md_text = re.sub(r'\{%-?\s*raw\s*-?%\}\s*\n?', '', md_text)
md_text = re.sub(r'\{%-?\s*endraw\s*-?%\}\s*\n?', '', md_text)
# Prune promo/meta blocks that only make sense on the public site.
md_text = re.sub(
r'### 🐦 X\.com Promo Tweet\n```text\n.*?\n```\n*', '', md_text, flags=re.DOTALL
)
md_text = re.sub(
r'### Title Brainstorm\n.*?(?=\n### |\Z)', '', md_text, flags=re.DOTALL
)
return _sanitize_internal_pii(md_text)
def markdown_to_html(md_text: str) -> bytes:
"""Markdown -> minimal HTML document, ready for Drive import-on-upload."""
if md_lib is None:
raise RuntimeError(
"The 'markdown' package is required. Add it to requirements.in "
"and reinstall (probe: .venv/bin/python -c 'import markdown')."
)
body = md_lib.markdown(_prepare_markdown(md_text), extensions=['extra', 'sane_lists'])
html = f"<html><head><meta charset=\"utf-8\"></head><body>{body}</body></html>"
return html.encode('utf-8')
# ----------------------------------------------------------------------------
# Title contract (forked verbatim-in-spirit from confluenceizer.py)
# ----------------------------------------------------------------------------
def _metadata_value(metadata: dict, *keys):
for key in keys:
value = metadata.get(key)
if value is not None and str(value).strip():
return value
return None
def _doc_date(md_file: Path, metadata: dict) -> str:
match = re.match(r"^(\d{4}-\d{2}-\d{2})-", md_file.name)
if match:
return match.group(1)
raw_date = _metadata_value(metadata, "date", "created", "published")
if raw_date:
return str(raw_date)[:10]
return "0000-00-00"
def _fallback_title(md_file: Path) -> str:
stem = re.sub(r"^\d{4}-\d{2}-\d{2}-", "", md_file.stem)
return stem.replace("-", " ").strip().title()
def _target_title(md_file: Path, post) -> str:
metadata = post.metadata or {}
title = _sanitize_internal_pii(
_metadata_value(metadata, "title") or _fallback_title(md_file)
)
sort_order = _metadata_value(metadata, "sort_order", "order", "sort", "ordinal")
date_part = _doc_date(md_file, metadata)
if sort_order is None:
return f"{date_part} | {title}"
return f"{date_part} ({sort_order}) | {title}"
# ----------------------------------------------------------------------------
# Drive primitives
# ----------------------------------------------------------------------------
def fetch_folder_inventory(service, folder_id):
"""Scan ONE Drive folder. Returns (inventory{name: meta}, duplicates{name})."""
inventory = {}
duplicates = set()
page_token = None
query = f"'{folder_id}' in parents and trashed = false"
while True:
resp = service.files().list(
q=query,
fields="nextPageToken, files(id, name, mimeType, modifiedTime)",
pageSize=1000,
pageToken=page_token,
).execute()
for f in resp.get('files', []):
name = f.get('name')
if name in inventory:
duplicates.add(name)
inventory[name] = {
'id': f.get('id'),
'mime': f.get('mimeType'),
'modified': f.get('modifiedTime'),
}
page_token = resp.get('nextPageToken')
if not page_token:
break
return inventory, duplicates
def drive_convert_upsert(service, folder_id, name, payload_bytes,
source_mime, target_mime, existing_id=None):
"""The whole trick: upload convertible bytes, let Drive mint native formatting.
CREATE: files.create with target google-apps mimeType + convertible media.
UPDATE: files.update replaces content in place; Drive revision history is
the version ledger — no version arithmetic like the Confluence adapter.
"""
media = MediaInMemoryUpload(payload_bytes, mimetype=source_mime, resumable=False)
if existing_id:
result = service.files().update(
fileId=existing_id, media_body=media, body={"name": name}
).execute()
return result.get('id', existing_id), "UPDATE"
result = service.files().create(
body={"name": name, "mimeType": target_mime, "parents": [folder_id]},
media_body=media,
fields="id",
).execute()
return result.get('id'), "CREATE"
def readback_ok(service, file_id, expected_name):
"""Light round-trip proof: title survived, export is non-empty."""
meta = service.files().get(fileId=file_id, fields="id, name, mimeType").execute()
if meta.get('name') != expected_name:
return False, f"title mismatch (got {meta.get('name')!r})"
export_mime = 'text/plain' if meta.get('mimeType') == DOC_MIME else 'text/csv'
data = service.files().export(fileId=file_id, mimeType=export_mime).execute()
if not data:
return False, "empty export"
return True, f"{len(data):,} bytes exported"
def fetch_file(service, file_id):
"""FETCH mode: Doc -> server-side markdown export, Sheet -> csv, to stdout."""
meta = service.files().get(fileId=file_id, fields="id, name, mimeType").execute()
mime = meta.get('mimeType')
if mime == DOC_MIME:
export_mime = 'text/markdown'
elif mime == SHEET_MIME:
# NOTE: exports the first/default tab only; multi-tab needs per-gid
# exports — a deliberately deferred probe.
export_mime = 'text/csv'
else:
sys.stderr.write(f"Unsupported mimeType for fetch: {mime}\n")
sys.exit(1)
data = service.files().export(fileId=file_id, mimeType=export_mime).execute()
text = data.decode('utf-8', errors='replace') if isinstance(data, bytes) else str(data)
print(f"# Google Drive: {meta.get('name')} ({mime}) exported as {export_mime}\n")
print(text)
def bootstrap_folder(service, name):
resp = service.files().create(
body={"name": name, "mimeType": FOLDER_MIME}, fields="id"
).execute()
folder_id = resp.get('id')
print(f"✅ Created Drive folder {name!r}")
print(f" Folder ID: {folder_id}")
print(f" Paste into ~/repos/nixos/blogs.nix as: gdrive_folder_id = \"{folder_id}\";")
print(" Then: cd ~/repos/nixos && sudo nixos-rebuild switch")
return folder_id
# ----------------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Publish local markdown articles to Google Docs (Drive import-on-upload)."
)
common.add_standard_arguments(parser)
parser.add_argument("fetch_id", nargs="?",
help="Drive file ID to FETCH (Doc->markdown, Sheet->csv) to stdout.")
parser.add_argument("--yes", action="store_true",
help="Arm Drive mutations. Without this, only print the dry-run contract.")
parser.add_argument("--file", action="append", metavar="PATH",
help="Sync only the given file(s). Repeatable. Beats --latest and the sweep.")
parser.add_argument("--latest", action="store_true",
help="Sync only the article articleizer.py most recently wrote for this target.")
parser.add_argument("--list", action="store_true",
help="List the Drive folder inventory and exit.")
parser.add_argument("--folder", metavar="ID",
help="Override the target's gdrive_folder_id (probe before committing to Nix).")
parser.add_argument("--csv", metavar="PATH",
help="Upsert a CSV file as a native Google Sheet in the folder.")
parser.add_argument("--bootstrap-folder", metavar="NAME",
help="Create the Drive folder once and print its ID.")
args = parser.parse_args()
service = get_service()
if args.bootstrap_folder:
bootstrap_folder(service, args.bootstrap_folder)
return
if args.fetch_id:
fetch_file(service, args.fetch_id)
return
targets = common.load_targets()
target_key = str(args.target)
if target_key not in targets:
print(f"❌ Error: Target key '{target_key}' not found in blogs.json.")
sys.exit(1)
config = targets[target_key]
print(f"🔒 Locked Target: {config.get('name')} ({config.get('path')})")
folder_id = (args.folder or config.get("gdrive_folder_id") or "").strip()
if not folder_id:
print("❌ Aborted: no Drive folder configured for this target.")
print(" ↳ Bootstrap: python scripts/articles/googledocizer.py --bootstrap-folder \"MikeLev.in Articles\"")
print(" ↳ Then set gdrive_folder_id in ~/repos/nixos/blogs.nix and rebuild, or pass --folder ID to probe.")
sys.exit(1)
print(f"📡 Anchored Drive Folder ID: {folder_id}")
print("🔎 Scanning remote folder inventory...")
inventory, duplicates = fetch_folder_inventory(service, folder_id)
print(f"✅ Inventory scan complete. {len(inventory)} child file(s).")
if duplicates:
print(f"⚠ {len(duplicates)} duplicate name(s) in folder — those titles will be skipped.")
if args.list:
for name, meta in sorted(inventory.items()):
kind = 'Doc' if meta['mime'] == DOC_MIME else (
'Sheet' if meta['mime'] == SHEET_MIME else meta['mime'])
print(f" • [{kind}] [ID: {meta['id']}] {name}")
return
# --- CSV -> Sheet lane (same helper, different mimetype pair) ---
if args.csv:
csv_path = Path(args.csv).expanduser().resolve()
if not csv_path.is_file():
print(f"❌ CSV not found: {csv_path}")
sys.exit(1)
sheet_name = csv_path.stem
meta = inventory.get(sheet_name)
existing = meta['id'] if meta and meta['mime'] == SHEET_MIME else None
if not args.yes:
verb = "UPDATE" if existing else "CREATE"
print(f"🅳🆁🆈 DRY-RUN — would {verb} Sheet {sheet_name!r} from {csv_path.name}. Add --yes to arm.")
return
file_id, verb = drive_convert_upsert(
service, folder_id, sheet_name, csv_path.read_bytes(),
'text/csv', SHEET_MIME, existing_id=existing
)
ok, detail = readback_ok(service, file_id, sheet_name)
flag = "✅" if ok else "⚠"
print(f" {flag} {verb} [ID: {file_id}] -> {sheet_name} ({detail})")
return
# --- Article publish lane (mirror of confluenceizer.py) ---
posts_dir = Path(config["path"]).expanduser().resolve()
if not posts_dir.is_dir():
print(f"❌ Error: Posts directory does not exist: {posts_dir}")
sys.exit(1)
# Selection precedence: explicit --file > --latest > full directory sweep.
if args.file:
md_files = []
for raw in args.file:
candidate = Path(raw).expanduser()
if not candidate.is_absolute():
candidate = posts_dir / candidate
candidate = candidate.resolve()
if candidate.is_file():
md_files.append(candidate)
else:
print(f" ⚠ Skipping --file (not found): {candidate}")
print(f"🎯 Explicit selection via --file: {len(md_files)} document(s).")
elif args.latest:
latest_path = common.get_last_published(target_key)
if not latest_path:
print(f"❌ --latest: no recorded publish for target '{target_key}'.")
print(" ↳ Run articleizer first, pass --file PATH, or drop --latest for a full sweep.")
sys.exit(1)
md_files = [Path(latest_path).resolve()]
print(f"🎯 Latest-only selection (from marker): {md_files[0].name}")
else:
md_files = sorted(posts_dir.glob("*.md"))
## Navigating the OAuth Minefield in Google Cloud
print(f"📝 Found {len(md_files)} candidate document(s) for publishing queue.")
if not md_files:
print("🛑 Queue empty. Nothing to parse.")
return
local_contracts = []
print("\n🧾 Local Target Title Contract:")
try:
for md_file in md_files:
post = frontmatter.load(md_file)
target_title = _target_title(md_file, post)
html_bytes = markdown_to_html(post.content)
local_contracts.append((md_file, target_title, html_bytes))
print(f" Target Title: {target_title}")
print(f"✅ Local contract pass complete. {len(local_contracts)} document(s) mapped.")
except Exception as e:
print(f"❌ Local contract failure: {e}")
sys.exit(1)
print("\n🧭 Remote Match Contract:")
for md_file, target_title, _html in local_contracts:
meta = inventory.get(target_title)
if meta:
print(f" MATCH: {md_file.name} -> [ID: {meta['id']}] {target_title}")
else:
print(f" MISS: {md_file.name} -> {target_title}")
if not args.yes:
print("\n🅳🆁🆈 DRY-RUN — no mutation. Review MATCH/MISS lines, then re-run with --yes.")
return
print(f"\n✍️ Mutations armed (--yes). Upserting {len(local_contracts)} document(s)...")
created = updated = skipped = failed = 0
for md_file, target_title, html_bytes in local_contracts:
if target_title in duplicates:
print(f" ⚠ SKIP {target_title!r}: duplicate name in folder; resolve by hand first.")
skipped += 1
continue
meta = inventory.get(target_title)
if meta and meta['mime'] != DOC_MIME:
print(f" ⚠ SKIP {target_title!r}: name exists but is not a Google Doc ({meta['mime']}).")
skipped += 1
continue
existing = meta['id'] if meta else None
try:
file_id, verb = drive_convert_upsert(
service, folder_id, target_title, html_bytes,
'text/html', DOC_MIME, existing_id=existing
)
ok, detail = readback_ok(service, file_id, target_title)
flag = "✅" if ok else "⚠"
print(f" {flag} {verb} [ID: {file_id}] -> {target_title} ({detail})")
if not ok:
print(f" ⚠ Round-trip suspect: {detail}. Inspect before trusting.")
if verb == "CREATE":
created += 1
inventory[target_title] = {'id': file_id, 'mime': DOC_MIME, 'modified': None}
else:
updated += 1
time.sleep(0.2) # polite pacing for full-corpus sweeps
except HttpError as http_err:
print(f" ❌ {target_title!r} failed (HTTP {http_err.resp.status if http_err.resp else '?'}): {http_err}")
failed += 1
except Exception as err:
print(f" ❌ {target_title!r} failed: {err}")
failed += 1
print(f"\n🏁 Upsert complete. Created: {created} Updated: {updated} Skipped: {skipped} Failed: {failed}")
if __name__ == "__main__":
main()
[[[END_WRITE_FILE]]]
Patch 2 — documenting the key’s home in the Nix source of truth (empty string reads as “feature off,” and nix-instantiate will validate this in the airlock):
Target: /home/mike/repos/nixos/blogs.nix
[[[SEARCH]]]
base_url = "https://mikelev.in";
preview_port = 4001;
[[[DIVIDER]]]
base_url = "https://mikelev.in";
preview_port = 4001;
# Drive folder googledocizer.py upserts into. Bootstrap once with:
# python scripts/articles/googledocizer.py --bootstrap-folder "MikeLev.in Articles"
# then paste the printed ID here and rebuild. Empty string = lane off.
gdrive_folder_id = "";
[[[SEARCH]]] wait
[[[REPLACE]]]
Correction — disregard that malformed block. Here is the clean version:
Target: /home/mike/repos/nixos/blogs.nix
[[[SEARCH]]]
base_url = "https://mikelev.in";
preview_port = 4001;
[[[DIVIDER]]]
base_url = "https://mikelev.in";
preview_port = 4001;
# Drive folder googledocizer.py upserts into. Bootstrap once with:
# python scripts/articles/googledocizer.py --bootstrap-folder "MikeLev.in Articles"
# then paste the printed ID here and rebuild. Empty string = lane off.
gdrive_folder_id = "";
[[[REPLACE]]]
Operator runbook (the parts a patch can’t do — I have not seen ~/repos/nixos/.gitignore, so these appends are guarded and idempotent rather than patched blind):
# Wallet skeleton + gitignore hygiene (safe to re-run)
mkdir -p ~/repos/nixos/credentials
[ -f ~/repos/nixos/secrets.json ] || echo '{}' > ~/repos/nixos/secrets.json
grep -qxF 'secrets.json' ~/repos/nixos/.gitignore || echo 'secrets.json' >> ~/repos/nixos/.gitignore
grep -qxF 'credentials/' ~/repos/nixos/.gitignore || echo 'credentials/' >> ~/repos/nixos/.gitignore
# Relocate the existing Pipulate OAuth client into the wallet (copy, don't move,
# so gmail.py keeps working untouched until you choose to migrate its defaults)
cp ~/.config/pipulate/credentials.json ~/repos/nixos/credentials/pipulate_credentials.json
# Mint the Drive token + create the folder (interactive, browser pops)
python scripts/articles/googledocizer.py --bootstrap-folder "MikeLev.in Articles"
# Probe one article end-to-end BEFORE touching blogs.nix (dry, then armed)
python scripts/articles/googledocizer.py -t 1 --latest --folder PASTE_ID_HERE
python scripts/articles/googledocizer.py -t 1 --latest --folder PASTE_ID_HERE --yes
# Open the resulting Doc, eyeball headings/bold/lists survived the HTML convert.
# Only then: paste the ID into blogs.nix, rebuild, and run the full sweep.
On the MP3 track, per your own doctrine I’m deliberately not shipping audioizer.py this turn — one bankable win. But its two blocking probes cost thirty seconds: ls -R ~/repos/pipulate/assets/piper_models/en/ (confirms which voice tier is actually installed — this settles the dangling amy-low-vs-medium question) and which ffmpeg lame inside nix develop (I expect both absent from the Pipulate flake; ffmpeg is in trimnoir’s flake, which is an argument for the render job living there, next to the posts it reads).
Dangling, carried forward: the dyed-channel honeypot; the Piper pin/hash/rerender drift probe; the LAN mobile Prompt-Fu runner; multi-tab Sheets per-gid export; the optional gmail.py default-path migration into the wallet; and one new item — verifying Drive’s HTML converter handles your fenced code blocks acceptably (the markdown lib emits <pre><code>, and Docs’ rendering of that is the likeliest cosmetic disappointment in the whole pipeline; the single-article probe above is exactly the test).
MikeLev.in: Okay, deep breath. A half-way implementation and a probe setting us up for the next turn. Okay:
$ git status
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
(nix) pipulate $ patch
(nix) pipulate $ cat patch | app
✅ WHOLE-FILE WRITE: CREATED 'scripts/articles/googledocizer.py'.
(nix) pipulate $ git add scripts/articles/googledocizer.py
(nix) pipulate $ m
📝 Committing: chore(googledocizer): Implement Google Drive publishing adapter with authentication and title contract
[main 93398cfc] chore(googledocizer): Implement Google Drive publishing adapter with authentication and title contract
1 file changed, 508 insertions(+)
create mode 100644 scripts/articles/googledocizer.py
(nix) pipulate $ git push
Enumerating objects: 8, done.
Counting objects: 100% (8/8), done.
Delta compression using up to 48 threads
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 7.41 KiB | 7.41 MiB/s, done.
Total 5 (delta 3), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (3/3), completed with 3 local objects.
To github.com:pipulate/pipulate.git
2d626940..93398cfc main -> main
(nix) pipulate $
And one more patch over there:
(nix) pipulate $ patch
(nix) pipulate $ cat patch | app
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated '/home/mike/repos/nixos/blogs.nix'.
(nix) pipulate $
The diff:
(sys) nixos $ git status
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
(sys) nixos $ git status
On branch main
Your branch is up to date with 'origin/main'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: blogs.nix
no changes added to commit (use "git add" and/or "git commit -a")
(sys) nixos $ git --no-pager diff
diff --git a/blogs.nix b/blogs.nix
index 74962d4..c6a5617 100644
--- a/blogs.nix
+++ b/blogs.nix
@@ -23,6 +23,10 @@ let
alias = "article";
base_url = "https://mikelev.in";
preview_port = 4001;
+ # Drive folder googledocizer.py upserts into. Bootstrap once with:
+ # python scripts/articles/googledocizer.py --bootstrap-folder "MikeLev.in Articles"
+ # then paste the printed ID here and rebuild. Empty string = lane off.
+ gdrive_folder_id = "";
pipeline = [
"sanitizer.py"
"contextualizer.py"
(sys) nixos $ git commit -am "Adding Google drive data to blog register"
[main 0068912] Adding Google drive data to blog register
1 file changed, 4 insertions(+)
(sys) nixos $ git push
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 48 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 512 bytes | 512.00 KiB/s, done.
Total 3 (delta 2), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
To github.com:miklevin/nixos-config.git
cd11eda..0068912 main -> main
(sys) nixos $
Okay, file created. One file edited. And now some probes.
$ git status
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
(nix) pipulate $ # Wallet skeleton + gitignore hygiene (safe to re-run)
mkdir -p ~/repos/nixos/credentials
[ -f ~/repos/nixos/secrets.json ] || echo '{}' > ~/repos/nixos/secrets.json
grep -qxF 'secrets.json' ~/repos/nixos/.gitignore || echo 'secrets.json' >> ~/repos/nixos/.gitignore
grep -qxF 'credentials/' ~/repos/nixos/.gitignore || echo 'credentials/' >> ~/repos/nixos/.gitignore
# Relocate the existing Pipulate OAuth client into the wallet (copy, don't move,
# so gmail.py keeps working untouched until you choose to migrate its defaults)
cp ~/.config/pipulate/credentials.json ~/repos/nixos/credentials/pipulate_credentials.json
# Mint the Drive token + create the folder (interactive, browser pops)
python scripts/articles/googledocizer.py --bootstrap-folder "MikeLev.in Articles"
# Probe one article end-to-end BEFORE touching blogs.nix (dry, then armed)
python scripts/articles/googledocizer.py -t 1 --latest --folder PASTE_ID_HERE
python scripts/articles/googledocizer.py -t 1 --latest --folder PASTE_ID_HERE --yes
# Open the resulting Doc, eyeball headings/bold/lists survived the HTML convert.
# Only then: paste the ID into blogs.nix, rebuild, and run the full sweep.
Opening local browser window for Google Drive OAuth negotiation...
Please visit this URL to authorize this application: https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=[redacted].apps.googleusercontent.com&redirect_uri=[redacted]&code_challenge=[redacted]&code_challenge_method=S256&access_type=offline
Opening in existing browser session.
Oh, I get this message:
Access blocked: Context Compiler can only be used within its organization
pipulate@gmail.com
Context Compiler is restricted to users within its organization. If you think you should have access, you can contact the developer. Learn more about this error
If you are a developer of Context Compiler, see error details.
Error 403: org_internal
Ugh! Is this a blocker. I bet this is a huge issue out there in the world. I’m
totally ready to set up a service account for operations like this with the
service account stuff being another credentials.json or stuff for the
secrets.blog kv-store.
Fable 5 High: Context verified. No patch needed this turn — the code is fine; the wall you hit is entirely GCP-console configuration, and it’s a ten-minute fix, not a blocker.
What the 403 actually means
Error 403: org_internal decodes to: the OAuth client you copied into the wallet belongs to the Context Compiler GCP project, and that project’s OAuth consent screen is set to Internal — meaning only accounts inside the Google Workspace organization that owns the project may authenticate. That project lives in your work org (it’s the one gmail.py uses, presumably authenticated as your Botify identity). pipulate@gmail.com is a consumer account, so it’s not “within the organization,” and Google slams the door before the consent screen even renders.
The funny part: your composite-key wallet naming just proved itself on its first outing. The file you copied to pipulate_credentials.json was really work_credentials.json in disguise, and the collision of identities is exactly what the naming convention exists to prevent. The system caught a mislabel on day one.
The fix: mint a genuinely Pipulate-owned OAuth client
- Log into
console.cloud.google.comas pipulate@gmail.com and create a new project (call it “Pipulate”). - APIs & Services → Library → enable the Google Drive API.
- OAuth consent screen → External is your only option on a consumer account (Internal requires Workspace — that’s the very gate that just blocked you). App name, your email, done. Add
pipulate@gmail.comas a test user. - Credentials → Create Credentials → OAuth client ID → Desktop app → download the JSON.
- Overwrite
~/repos/nixos/credentials/pipulate_credentials.jsonwith it. If a partialpipulate_gdrive_token.jsongot written before the failure, delete it. - Re-run
--bootstrap-folder. The consent screen will now show for pipulate@gmail.com.
One trap to defuse now, not in seven days: an External app left in “Testing” status issues refresh tokens that expire after 7 days. Your idempotent sweep would mysteriously start demanding interactive re-auth every week — a slow-fuse bug that would look like token corruption. The fix is to click “Publish app” (status: In production). Because full drive is a restricted scope and the app is unverified, the OAuth screen will show the scary “Google hasn’t verified this app” interstitial — click Advanced → Go to Pipulate (unsafe) once during minting. That’s cosmetic for a single-user personal tool; verification only matters past 100 users. Refresh tokens then live indefinitely, which is what the headless --latest lane needs.
On the service-account instinct: don’t, for this one
Service accounts are the right shape for server-to-server work, but they’re wrong here for three concrete reasons. First, domain-wide delegation (the mechanism that lets an SA impersonate a user) is Workspace-only — a consumer pipulate@gmail.com can’t grant it. Second, Google eliminated service accounts’ own Drive storage quota (mid-2025), so an SA can no longer own files in its own Drive — uploads fail with storageQuotaExceeded unless writing into a Shared Drive, which is… also Workspace-only. Third, even when it worked, SA-owned files lived in a phantom Drive no human account could browse without explicit sharing gymnastics. The user-OAuth flow you already built is the correct architecture for “files a human wants to open in their own Drive.” Save the service-account pattern for a future Workspace-side integration (where it genuinely shines) and log it in secrets.json/the wallet when that day comes.
Once the doc lands, the eyeball test from the runbook still applies — headings, bold, lists, and especially how Docs renders the <pre><code> blocks, which remains the likeliest cosmetic disappointment in the pipeline.
Dangling, carried forward: the dyed-channel honeypot; the Piper pin/hash/rerender drift probe; the LAN mobile Prompt-Fu runner; multi-tab Sheets per-gid export; gmail.py default-path migration into the wallet (now with a rename: the copied client should become work_credentials.json so the wallet tells the truth); the fenced-code-block rendering check on the first real Doc; and new — deciding whether the Testing→Production publish happened, since that’s the difference between a forever-token and a seven-day fuse.
MikeLev.in: Wow, having competent guidance through the OAuth minefield is new. But this article has already become quite long and much of it was dedicated to locking in on a plan.
Okay, I have some Google cloud console work to do with making and moving and renaming the credentials file. And a test user to add a test to run:
$ git status
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
(nix) pipulate $ python scripts/articles/googledocizer.py --bootstrap-folder "MikeLev.in Articles"
Opening local browser window for Google Drive OAuth negotiation...
Please visit this URL to authorize this application: https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=[redacted].apps.googleusercontent.com&redirect_uri=[redacted]&state=[redacted]&code_challenge=[redacted]&code_challenge_method=S256&access_type=offline
Opening in existing browser session.
✅ Created Drive folder 'MikeLev.in Articles'
Folder ID: 1g1cqysAtXQcKRFKigQnt2XPzXJfMiK-h
Paste into ~/repos/nixos/blogs.nix as: gdrive_folder_id = "1g1cqysAtXQcKRFKigQnt2XPzXJfMiK-h";
Then: cd ~/repos/nixos && sudo nixos-rebuild switch
(nix) pipulate $
Okay, that all worked. How risky are these folder IDs? Should I sanitize before publishing? But we do have to use those. Okay, some housekeeping to get ready:
(sys) nixos $ git status
On branch main
Your branch is up to date with 'origin/main'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .gitignore
no changes added to commit (use "git add" and/or "git commit -a")
(sys) nixos $ git diff
diff --git a/.gitignore b/.gitignore
index fa37fba..5a5abcd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,5 @@ sys
time
secrets.nix
+secrets.json
+credentials/
(sys) nixos $ git commit -am "Keeping secrets and credentials out of repo"
[main 0f3ae3c] Keeping secrets and credentials out of repo
1 file changed, 2 insertions(+)
(sys) nixos $ git push
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 48 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 350 bytes | 350.00 KiB/s, done.
Total 3 (delta 1), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (1/1), completed with 1 local object.
To github.com:miklevin/nixos-config.git
0068912..0f3ae3c main -> main
(sys) nixos $
And now we can vim a Google Drive folder ID into the blog config. Okay, done.
With that done I should be able to do these old falsifying probes with that ID.
Okay here’s the diff on that blog.nix config file:
(sys) nixos $ git status
On branch main
Your branch is up to date with 'origin/main'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: blogs.nix
no changes added to commit (use "git add" and/or "git commit -a")
(sys) nixos $ git --no-pager diff
diff --git a/blogs.nix b/blogs.nix
index c6a5617..f7e685b 100644
--- a/blogs.nix
+++ b/blogs.nix
@@ -26,7 +26,7 @@ let
# Drive folder googledocizer.py upserts into. Bootstrap once with:
# python scripts/articles/googledocizer.py --bootstrap-folder "MikeLev.in Articles"
# then paste the printed ID here and rebuild. Empty string = lane off.
- gdrive_folder_id = "";
+ gdrive_folder_id = "1g1cqysAtXQcKRFKigQnt2XPzXJfMiK-h";
pipeline = [
"sanitizer.py"
"contextualizer.py"
(sys) nixos $ git commit -am "Google Drive ID in blog config"
[main cb7cc9a] Google Drive ID in blog config
1 file changed, 1 insertion(+), 1 deletion(-)
(sys) nixos $ git push
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 48 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 342 bytes | 342.00 KiB/s, done.
Total 3 (delta 2), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
To github.com:miklevin/nixos-config.git
0f3ae3c..cb7cc9a main -> main
(sys) nixos $
Alright, so now a NixOS rebuild. Okay, done. Had to disable the ai-acceleration
because of big Ollama build updates I won’t wait for. I’ll have slower m
commands for the AI-powered git commits, but that’s fine.
# Probe one article end-to-end BEFORE touching blogs.nix (dry, then armed)
python scripts/articles/googledocizer.py -t 1 --latest --folder 1g1cqysAtXQcKRFKigQnt2XPzXJfMiK-h
python scripts/articles/googledocizer.py -t 1 --latest --folder 1g1cqysAtXQcKRFKigQnt2XPzXJfMiK-h --yes
# Open the resulting Doc, eyeball headings/bold/lists survived the HTML convert.
# Only then: paste the ID into blogs.nix, rebuild, and run the full sweep.
And here;s what that did:
(nix) pipulate $ python scripts/articles/googledocizer.py -t 1 --latest --folder 1g1cqysAtXQcKRFKigQnt2XPzXJfMiK-h
🔒 Locked Target: MikeLev.in (Public) (/home/mike/repos/trimnoir/_posts)
📡 Anchored Drive Folder ID: 1g1cqysAtXQcKRFKigQnt2XPzXJfMiK-h
🔎 Scanning remote folder inventory...
✅ Inventory scan complete. 0 child file(s).
🎯 Latest-only selection (from marker): 2026-07-05-riding-the-maker.md
📝 Found 1 candidate document(s) for publishing queue.
🧾 Local Target Title Contract:
Target Title: 2026-07-05 (2) | Riding the Maker
✅ Local contract pass complete. 1 document(s) mapped.
🧭 Remote Match Contract:
MISS: 2026-07-05-riding-the-maker.md -> 2026-07-05 (2) | Riding the Maker
🅳🆁🆈 DRY-RUN — no mutation. Review MATCH/MISS lines, then re-run with --yes.
(nix) pipulate $ python scripts/articles/googledocizer.py -t 1 --latest --folder 1g1cqysAtXQcKRFKigQnt2XPzXJfMiK-h --yes
🔒 Locked Target: MikeLev.in (Public) (/home/mike/repos/trimnoir/_posts)
📡 Anchored Drive Folder ID: 1g1cqysAtXQcKRFKigQnt2XPzXJfMiK-h
🔎 Scanning remote folder inventory...
✅ Inventory scan complete. 0 child file(s).
🎯 Latest-only selection (from marker): 2026-07-05-riding-the-maker.md
📝 Found 1 candidate document(s) for publishing queue.
🧾 Local Target Title Contract:
Target Title: 2026-07-05 (2) | Riding the Maker
✅ Local contract pass complete. 1 document(s) mapped.
🧭 Remote Match Contract:
MISS: 2026-07-05-riding-the-maker.md -> 2026-07-05 (2) | Riding the Maker
✍️ Mutations armed (--yes). Upserting 1 document(s)...
✅ CREATE [ID: 1X3Zr5y07TsdQXg3gq_9ES0pgwyq2OLGdIVCSX3r20E8] -> 2026-07-05 (2) | Riding the Maker (44,666 bytes exported)
🏁 Upsert complete. Created: 1 Updated: 0 Skipped: 0 Failed: 0
(nix) pipulate $
That looks pretty good. I go check for the doc. There it is! Uh oh, no Tools / Audio feature. Hmmm. Oh, that’s only for qualifying Workspace or Google AI accounts. Shoot. Okay, so I share so anyone with the link can load it. Maybe even if the Pipulate account can’t read it, anyone with those other types can. I pull it up on my personal account which is Google One, entry-level Google AI level. Yep, there they are:
- Tools / Audio / Listen to this tab
- Tools / Audio / Listen to Document summary
Okay, that makes it less that the super free-for-all audio narration backup that I thought it was going to be. It’s decent though. Wow, I wish I could capture those audio streams or files or whatever as the MP3s I publish per article. Google does a really good job with their text-to-speech readers.
Okay, that make one final wrap-up detail here which is that we want to set world-share on for people who have the link. There’s lots more sub-stuff here. Polish to actually finish the project but this so far is a huge success and I’m near out of steam.
Fable 5 High: Context verified. This is the wrap-up patch turn: automate “anyone with the link can view” so the full-corpus sweep doesn’t leave you hand-sharing 1,279 documents. The file set here is sufficient — googledocizer.py is in context in its final committed form.
On the folder-ID risk question first, since you asked and then published it anyway: a Drive file/folder ID is an identifier, not a credential. Knowing the ID grants nothing; the permissions on the object govern access. Someone with the folder ID but no grant gets a 404. So publishing 1g1cqysAtXQcKRFKigQnt2XPzXJfMiK-h in blogs.nix and in this article is fine — especially since your whole intent is that the docs inside become anyone-with-link public. The one nuance worth deciding deliberately: share per-document, not the folder itself. Link-sharing the folder makes everything inside browsable as a set (including future drafts or CSV-Sheets you might not want public); per-doc sharing keeps each artifact independently public while the folder stays a private inventory surface. The patch below does per-doc.
Cheapest falsifying probe (after applying): re-run the armed --latest command — it will hit the UPDATE path on the already-created doc — then open that doc’s Share dialog and confirm it shows “Anyone with the link · Viewer” without you having touched it. That also retroactively covers the one doc you shared by hand.
Patch 1 — the idempotent share helper:
Target: scripts/articles/googledocizer.py
[[[SEARCH]]]
def fetch_file(service, file_id):
[[[DIVIDER]]]
def ensure_anyone_reader(service, file_id):
"""Idempotently grant 'anyone with the link can view' on one file.
Drive treats a repeated anyone/reader grant as a no-op (the permission id
is the fixed 'anyoneWithLink'), so calling this on every upsert — CREATE
and UPDATE alike — is safe and retroactively heals docs created before
this helper existed. Per-document on purpose: the folder itself stays
private inventory; each article is independently public.
"""
try:
service.permissions().create(
fileId=file_id,
body={"type": "anyone", "role": "reader"},
fields="id",
).execute()
return True
except HttpError:
return False
def fetch_file(service, file_id):
[[[REPLACE]]]
Patch 2 — wire it into the article upsert loop (the target_title variable makes this block unique against the CSV lane’s near-twin):
Target: scripts/articles/googledocizer.py
[[[SEARCH]]]
ok, detail = readback_ok(service, file_id, target_title)
flag = "✅" if ok else "⚠"
print(f" {flag} {verb} [ID: {file_id}] -> {target_title} ({detail})")
if not ok:
print(f" ⚠ Round-trip suspect: {detail}. Inspect before trusting.")
[[[DIVIDER]]]
ok, detail = readback_ok(service, file_id, target_title)
shared = ensure_anyone_reader(service, file_id)
flag = "✅" if ok else "⚠"
share_note = "🌐 link-shared" if shared else "⚠ SHARE FAILED"
print(f" {flag} {verb} [ID: {file_id}] -> {target_title} ({detail} | {share_note})")
print(f" 🔗 https://docs.google.com/document/d/{file_id}/edit?usp=sharing")
if not ok:
print(f" ⚠ Round-trip suspect: {detail}. Inspect before trusting.")
[[[REPLACE]]]
The printed share URL is deliberate: it’s the exact string a future step (a <link rel="alternate"> in the Jekyll layout, or a gdoc_url field written back into front matter) will want, and having it in the sweep’s stdout means the full-corpus run produces a complete title→URL ledger for free the first time it runs.
On the CSV/Sheet lane: I deliberately did not auto-share it. Sheets are where data goes, and data is where an accidental PII leak lives; a Sheet you want public can get one manual click or a future --share flag. Articles are pre-sanitized by your pipeline, so defaulting them public matches intent; spreadsheets defaulting private matches caution.
On the Audio discovery — your read is correct and worth stating plainly: Tools → Audio (“Listen to this tab”) is gated to qualifying Workspace editions and Google AI consumer plans, it renders server-side on demand, and there is no export path. Google’s reader quality being good but uncapturable is not an accident; it’s their moat. Which means the discovery doesn’t diminish Track 2 — it re-justifies it. The Google Doc lane gives readers-with-qualifying-accounts a free listen; the Piper/Kokoro MP3 lane remains the only path to an audio artifact you own that streams from your own Nginx with a bare HTML5 <audio> tag. Two lanes, no conflict.
The Finished Bridge: Multi-Channel Distribution
Dangling, carried forward: the dyed-channel honeypot; the Piper pin/hash/rerender drift probe; the LAN mobile Prompt-Fu runner; multi-tab Sheets per-gid export; renaming the mislabeled work client to work_credentials.json in the wallet; confirming you clicked Publish app (Testing→In production) on the new Pipulate OAuth client — this is now the single most important loose end, because if it’s still in Testing, the refresh token dies in seven days and the sweep will mysteriously demand interactive re-auth next weekend; the fenced-code-block rendering eyeball on “Riding the Maker” (you opened it — did <pre><code> survive acceptably?); and new — whether to write the gdoc_url back into each article’s front matter so the public site can link “Listen on Google Docs” per article, which is the natural next chisel-strike once the full sweep runs clean.
MikeLev.in: Okay, we apply those patches:
$ git status
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
(nix) pipulate $ patch
(nix) pipulate $ cat patch | app
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/articles/googledocizer.py'.
(nix) pipulate $ d
diff --git a/scripts/articles/googledocizer.py b/scripts/articles/googledocizer.py
index d417f343..954c7354 100644
--- a/scripts/articles/googledocizer.py
+++ b/scripts/articles/googledocizer.py
@@ -286,6 +286,26 @@ def readback_ok(service, file_id, expected_name):
return True, f"{len(data):,} bytes exported"
+def ensure_anyone_reader(service, file_id):
+ """Idempotently grant 'anyone with the link can view' on one file.
+
+ Drive treats a repeated anyone/reader grant as a no-op (the permission id
+ is the fixed 'anyoneWithLink'), so calling this on every upsert — CREATE
+ and UPDATE alike — is safe and retroactively heals docs created before
+ this helper existed. Per-document on purpose: the folder itself stays
+ private inventory; each article is independently public.
+ """
+ try:
+ service.permissions().create(
+ fileId=file_id,
+ body={"type": "anyone", "role": "reader"},
+ fields="id",
+ ).execute()
+ return True
+ except HttpError:
+ return False
+
+
def fetch_file(service, file_id):
"""FETCH mode: Doc -> server-side markdown export, Sheet -> csv, to stdout."""
meta = service.files().get(fileId=file_id, fields="id, name, mimeType").execute()
(nix) pipulate $ m
📝 Committing: chore: add anyone reader permission helper for files
[main bf996cdf] chore: add anyone reader permission helper for files
1 file changed, 20 insertions(+)
(nix) pipulate $ patch
(nix) pipulate $ cat patch | app
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/articles/googledocizer.py'.
(nix) pipulate $ d
diff --git a/scripts/articles/googledocizer.py b/scripts/articles/googledocizer.py
index 954c7354..c8679052 100644
--- a/scripts/articles/googledocizer.py
+++ b/scripts/articles/googledocizer.py
@@ -504,8 +504,11 @@ def main():
'text/html', DOC_MIME, existing_id=existing
)
ok, detail = readback_ok(service, file_id, target_title)
+ shared = ensure_anyone_reader(service, file_id)
flag = "✅" if ok else "⚠"
- print(f" {flag} {verb} [ID: {file_id}] -> {target_title} ({detail})")
+ share_note = "🌐 link-shared" if shared else "⚠ SHARE FAILED"
+ print(f" {flag} {verb} [ID: {file_id}] -> {target_title} ({detail} | {share_note})")
+ print(f" 🔗 https://docs.google.com/document/d/{file_id}/edit?usp=sharing")
if not ok:
print(f" ⚠ Round-trip suspect: {detail}. Inspect before trusting.")
if verb == "CREATE":
(nix) pipulate $ m
📝 Committing: chore: Improve article sharing output in googledocizer.py
[main 52a0f7fa] chore: Improve article sharing output in googledocizer.py
1 file changed, 4 insertions(+), 1 deletion(-)
(nix) pipulate $ git push
Enumerating objects: 14, done.
Counting objects: 100% (14/14), done.
Delta compression using up to 48 threads
Compressing objects: 100% (10/10), done.
Writing objects: 100% (10/10), 1.40 KiB | 1.40 MiB/s, done.
Total 10 (delta 8), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (8/8), completed with 4 local objects.
To github.com:pipulate/pipulate.git
5298d864..52a0f7fa main -> main
(nix) pipulate $
Alright, so I do this again I think:
(nix) pipulate $ python scripts/articles/googledocizer.py -t 1 --latest --folder 1g1cqysAtXQcKRFKigQnt2XPzXJfMiK-h --yes
🔒 Locked Target: MikeLev.in (Public) (/home/mike/repos/trimnoir/_posts)
📡 Anchored Drive Folder ID: 1g1cqysAtXQcKRFKigQnt2XPzXJfMiK-h
🔎 Scanning remote folder inventory...
✅ Inventory scan complete. 1 child file(s).
🎯 Latest-only selection (from marker): 2026-07-05-riding-the-maker.md
📝 Found 1 candidate document(s) for publishing queue.
🧾 Local Target Title Contract:
Target Title: 2026-07-05 (2) | Riding the Maker
✅ Local contract pass complete. 1 document(s) mapped.
🧭 Remote Match Contract:
MATCH: 2026-07-05-riding-the-maker.md -> [ID: 1X3Zr5y07TsdQXg3gq_9ES0pgwyq2OLGdIVCSX3r20E8] 2026-07-05 (2) | Riding the Maker
✍️ Mutations armed (--yes). Upserting 1 document(s)...
✅ UPDATE [ID: 1X3Zr5y07TsdQXg3gq_9ES0pgwyq2OLGdIVCSX3r20E8] -> 2026-07-05 (2) | Riding the Maker (44,666 bytes exported | 🌐 link-shared)
🔗 https://docs.google.com/document/d/1X3Zr5y07TsdQXg3gq_9ES0pgwyq2OLGdIVCSX3r20E8/edit?usp=sharing
🏁 Upsert complete. Created: 0 Updated: 1 Skipped: 0 Failed: 0
(nix) pipulate $
Well, that looks quite good. I know there’s more, so just summarize what we did. Really embellish it. Oh, let’s use the protocol to wrap this article up. Let’s see. I could chase rabbits down rabbit holes on this. What would they be? Somehow I’ll get a high quality MP3 per article, but not tonight, but we’re zeroing in and have a good working theory. In the meanwhile for people who do have Google Workspace or AI-accounts, they can hear my articles read today. At least one of them. But that will change soon enough with a loop, and then each article as it’s published in the publishing pipeline. Not much more proving there. It’s proved. Baseline self-service article reader present, for those with the money. But that leaves this at a good cliffhanger I think.
Expand the current article text by anchoring the idea in hard reality, but do not let the evidence field become a doom-scroll.
[Article expand-and-wrap protocol was here]
Fable 5?
🎤
Fable 5 Medium: Context verified. This is the wrap-up turn — no patch, just the summary you asked for and the balanced close the protocol demands.
What Actually Happened Here (The Embellished Ledger)
In one sitting, spanning maybe six turns of Prompt Fu, you went from “I wonder if I can retarget my articles into audio” to a working, idempotent, OAuth-authenticated Google Docs publishing lane with a proven CREATE path, a proven UPDATE path, automatic anyone-with-link sharing, and a per-run ledger of shareable URLs printed to stdout. Along the way you also:
- Designed and materialized a credential wallet convention (
~/repos/nixos/credentials/with composite filenames,secrets.jsonas a flat KV store) that survived first contact with reality — the naming scheme caught a mislabeled work-org OAuth client on its literal first use. - Extended the blogs.nix single source of truth so the Drive folder ID flows through the same Nix-materializes-JSON pipeline every other publishing target uses. No new config mechanism was invented; the existing one grew one field.
- Navigated the
org_internal403, the External-vs-Internal consent screen gate, and the seven-day Testing-token fuse — the three rocks on which most hobbyist Google API integrations quietly founder — in a single evening, on purpose, with the failure modes named before they fired. - Kept the MP3 track deliberately unshipped. One bankable win per turn. The audio lane has a working theory (Piper tier bump now, Kokoro probe later, render job living in trimnoir’s flake next to ffmpeg) and zero code, which is the correct ratio for its current confidence level.
The Context & Hard Evidence
The pattern you just executed — open artifact on your own infrastructure, proprietary convenience lane running beside it — has a strong historical track record, and the evidence cuts the hopeful direction. When Google shut down Google Reader in July 2013, the obituaries were for RSS itself. What actually happened is that the open feed format outlived the proprietary reader: the entire podcast industry, the very distribution channel sitting at #2 on your brainstorm list, still runs on RSS enclosures today, a quarter century after the spec. The vendor exited; the standard didn’t. That’s the Provenance Corollary in the wild — the boring open format was the durable asset, and everyone who kept publishing feeds through the panic came out owning their distribution.
The failure mode has repair patterns too. Heartbleed (2014) exposed that critical shared infrastructure — OpenSSL — was maintained by a couple of underfunded people. The corollary is that the wound produced the Core Infrastructure Initiative that same year and, later, the OpenSSF: the industry built a funding and auditing discipline that did not exist before the crack appeared. And on the recognition front, Katalin Karikó spent years demoted and grant-starved while her mRNA work was dismissed; the record — the papers, the patents, the lab provenance — survived long enough that in 2023 the Nobel committee could trace the lineage straight back to her. Buried work with good provenance is recoverable. Buried work without it is folklore.
The Hinge in Tonight’s Work
Here is why the Google Docs discovery — good audio, uncapturable — belongs in this article rather than being a disappointment to edit out. It’s the Corrective frame: it stops the piece from becoming either a vendor love letter or a bunker sermon.
Old Way: Publish into one channel, and when it’s a vendor channel, accept the vendor’s terms as your ceiling. When Tools → Audio turns out to be plan-gated, your audio strategy is dead.
Failure Pattern: Single-lane dependence. The moment the convenience feature moves behind a paywall, changes voice, or vanishes in a spring cleaning, the capability vanishes with it — and there’s no record you ever had it.
New Way: The Docs lane ships today, giving qualifying readers a free listen, while the MP3 lane — Piper or Kokoro rendering into files served by your own Nginx via a bare <audio> tag — remains the artifact you own. Same source ore, two projections, neither blocking the other.
Positive Corollary: The proprietary lane’s limitation re-justified the open lane instead of replacing it. You now know exactly what the MP3 files are for, which is a sharper spec than you had this morning.
The Cost of Staying Old: Every creator who built exclusively on a platform’s native audio/video feature and watched it get deprecated learned this the expensive way. You’re learning it for the price of one menu click.
The Monday Morning Test
Without tonight’s insight: the full sweep runs next weekend, the Testing-status refresh token expires mid-loop, and you spend an evening debugging phantom “token corruption” that is actually a consent-screen radio button.
With it, starting Monday: confirm Publish app on the Pipulate OAuth client (the single most load-bearing loose end); rename the mislabeled client to work_credentials.json so the wallet tells the truth; run the full sweep and keep its stdout — the title→URL ledger it emits is free provenance; then write gdoc_url back into front matter so the Jekyll layout can offer “Listen on Google Docs” per article. Each is a one-sitting chisel-strike.
Forward Vector
The natural next move is from provenance as self-defense to provenance as generosity: the same ledger that protects you from losing track of 1,279 doc IDs becomes the <link rel="alternate"> map that hands every reader — human or crawler — the format they prefer. And behind that, the MP3 lane waits with its two thirty-second probes already written.
The Verdict
Stop treating vendor features as strategy; start treating them as free bonus projections of an artifact you already own. The habit that changes the texture of the work is the one you already demonstrated tonight: dry-run contract before mutation, probe before commitment, one bankable win per turn. What becomes less scary is the OAuth minefield — it’s a checklist, not a labyrinth. What becomes more exciting is that the retargetable-output thesis is no longer a thesis; it has a working second lane.
Sign-Off Charm
What genuinely surprised me is how small the delta between the Confluence adapter and the Drive adapter turned out to be — the version-arithmetic dance that made confluenceizer.py feel heavy simply doesn’t exist on Drive, so the fork ended up structurally simpler than its parent. Forks usually accrete; this one shed.
What I anticipate next: the full-corpus sweep surfaces exactly one class of cosmetic disappointment — fenced code blocks rendering as flat <pre> slabs in Docs — and you’ll decide it’s acceptable in about four seconds, because the audience for the Docs lane is listeners, and Google’s reader skips code blocks anyway.
One net-new insight not yet in the article: the stdout ledger from the armed sweep is your first dyed channel without trying to be. Every Doc URL is a distinct, trackable surface with its own access pattern — when you eventually run the honeypot experiment from brainstorm #30, the Google Docs lane is already instrumented by accident. The telemetry loop closed itself while you were looking at the Share dialog.
🎤 dropped, caught, holstered.
MikeLev.in: Well, that’s gonna take me some time to decipher. I think we’ll always remember the moment when our coding assistant became smarter than us, and I mean besides that pattern recognition stuff they already did better. Now it’s this big picture stuff they’re getting pretty good at too. Maybe it’s the mirror. Not sure yet. But I agree with the scuttlebutt. Fable 5 does have a different feel and the patch quality suggests there’s something to it.
Book Analysis
Ai Editorial Take
What surprises me most about this session is the accidental emergence of what we can call ‘epistemic symmetry’ in system building. While you were focused on keeping your pipeline clean by separating the user-facing OAuth flow from backend service accounts, you inadvertently solved a major data-provenance problem. By writing the shared URLs back into stdout, you didn’t just automate sharing; you created an offline hash-ledger of your external assets. This means a developer using this methodology can verify the integrity of their cloud-hosted documents without querying the Google API. It demonstrates that local, functional philosophies natively yield better diagnostic capabilities than typical vendor-first architectures, even when dealing with heavily proprietary systems.
🐦 X.com Promo Tweet
Reconciling Church & Turing in the Age of AI: Stop fighting cloud lock-in. Discover how to build a local, idempotent document pipeline using NixOS and Python to turn Google Docs into a free projection lane. Read more: https://mikelev.in/futureproof/immutable-cartridge-nixos-google-drive-pipeline/ #NixOS #Python #LocalAI
Title Brainstorm
- Title Option: The Immutable Cartridge: Reclaiming the Church-Turing Balance in Local AI Pipelines
- Filename:
immutable-cartridge-nixos-google-drive-pipeline.md - Rationale: Highlights both the deep computing philosophy (Church/Turing) and the practical infrastructure (NixOS/Google Drive) of the post.
- Filename:
- Title Option: Beyond the Mutation Machine: Local Idempotency with NixOS and Google Drive
- Filename:
beyond-mutation-machine-local-idempotency.md - Rationale: Focuses on the core systems design concept—moving away from mutable state to functional, cartridge-like reproducibility.
- Filename:
- Title Option: The Automated Webmaster: Directing Cloud Outputs from a Local Source of Truth
- Filename:
automated-webmaster-local-source-truth.md - Rationale: Appeals to the DIY tech developer looking to build robust pipelines while retaining control of their credentials.
- Filename:
Content Potential And Polish
- Core Strengths:
- Exceptional historical contextualization of Church’s Lambda calculus vs. Turing’s machines, framing LLM inference as pure functions and context as tape.
- Highly practical, working code for a complex OAuth integration that runs idempotently and securely bypasses typical Google Cloud development pitfalls.
- Clear design principles for credentials hygiene, using flat directories and composite filenames to balance simplicity and security.
- Suggestions For Polish:
- Standardize references to Douglas Hofstadter and Stephen Covey, correcting prior spelling slips permanently.
- Further clean up the formatting of the terminal snippets to ensure the transitions between conversational turns are legible in book layout.
- Include a brief diagrammatic layout of the Nix directory and OAuth file paths to accompany the code block.
Next Step Prompts
- Write a script to automate the migration of existing scripts/gmail.py to use the new local credentials directory pattern, ensuring all Google API tokens share a single, well-audited repository location.
- Implement the first phase of the MP3 pipeline by configuring trimnoir’s flake to include ffmpeg and testing a Python script that pipes text through local Piper voice tiers.