humla: Instructions file for Claude Code

CLAUDE.md

humla CLAUDE.md is an instructions file for Claude Code from michaelwilhelmsen/humla. It costs 31,965 tokens per session, scanned A, original, MIT.

Project instructions for Humla, a private macOS meeting-notes app that records audio, transcribes it, and combines the transcript with written notes into an AI summary. They describe its local storage, audio streams, and speech-to-text setup.

In plain words
What is it for?
Use them when developing or debugging recording, transcription, speaker labeling, note merging, local SQLite storage, or integrations with speech-to-text providers.
Why use it?
They explain how meeting recordings and summaries flow through the app, which helps avoid breaking its local-first design or confusing microphone audio with system audio.

Instructions file for Claude Code

Written for Claude Code: the file is CLAUDE.md. Also seen: mentions Claude Code; mentions Codex.

This is michaelwilhelmsen/humla's own configuration. It tells Claude Code how to work on humla itself, so it is not a mod to install elsewhere. Copy it as a starting point and replace the rules that are about this project. Everything humla configures →

Reuse

Borrowing it

Nothing to install: this file belongs to michaelwilhelmsen/humla. Take a copy, put it at the same path in your own repository, and replace the rules that are about this project with yours.

Copy the file
curl -O https://raw.githubusercontent.com/michaelwilhelmsen/humla/main/CLAUDE.md
Clone the repo
git clone --depth 1 https://github.com/michaelwilhelmsen/humla

Made for: Claude Code.

Wrote this? Show the measurements

A badge with what this costs and how it scanned, read live from this page, so it follows the numbers instead of freezing them. Markdown for a README, HTML for a documentation site or a project page.

agentmods badge for humla CLAUDE.md

README.md
[![agentmods](https://agentmods.dev/badge/instructions/michaelwilhelmsen/humla/claude-md/github.svg)](https://agentmods.dev/instructions/michaelwilhelmsen/humla/claude-md)
Your own site
<a href="https://agentmods.dev/instructions/michaelwilhelmsen/humla/claude-md"><img src="https://agentmods.dev/badge/instructions/michaelwilhelmsen/humla/claude-md/github.svg" alt="Measured on agentmods" height="20"></a>

Or the 80×15 button, for a site that already has a row of RSS and ATOM ones. Only the verdict fits; the numbers stay here.

agentmods 80×15 button for humla CLAUDE.md

Your own site · 80×15
<a href="https://agentmods.dev/instructions/michaelwilhelmsen/humla/claude-md"><img src="https://agentmods.dev/badge/instructions/michaelwilhelmsen/humla/claude-md.svg" alt="Reviewed on agentmods" width="80" height="20"></a>
Per session 31,965 This file is loaded in full into every session.
When invoked 31,965 The same file — it is already loaded in full.
Security scan A 0 findings. A grade says what 26 rules found in the file — not that it is safe.
Origin original No closer match found in the catalogue.
Token cost

What it costs to keep this loaded

Counted locally with the o200k_base tokenizer, which is exact for GPT models; Claude uses its own tokenizer and its counts differ. Treat this as one consistent yardstick across the catalogue rather than a bill. Prices are per million input tokens.

ModelPer sessionOnce invoked
Fable 5.1 $0.31965 $0.31965
Opus 5.5 $0.12786 $0.12786
Sonnet 5.5 $0.06393 $0.06393
Haiku 4.5 $0.03197 $0.03197

Measured 7d ago against content hash 2913f786c702, method: parsed. Prices are Anthropic first-party input rates as of 2026-10-07, from the pricing page.

Security

Grade A, and why

humla CLAUDE.md scanned grade A with 0 findings against 26 rules in 11 categories — prompt injection, anti-refusal, data exfiltration, privilege escalation, supply chain, agent snooping, system-prompt leakage, SSRF and excessive agency — measured 7d ago.

A static scan of the body, not an audit. Every finding is printed with the line that produced it so you can judge whether it matters here. A mod is markdown that instructs an agent; that is exactly why what it instructs is worth reading.

Nothing flagged

None of the 26 patterns this scan looks for appear in this file: no shell pipes, no recursive deletes, no credential paths, no hidden text, no instruction-override or anti-refusal phrasing, no agent-config snooping. That is not a guarantee, it is the absence of the things that are checkable.

CLAUDE.md · 328 lines

How it starts

The opening of the file, as written. The whole thing — 328 lines — stays where its author put it; the contents beside it link to each section on GitHub.

Humla — project notes

What this app is

Humla is a personal macOS meeting-notes app inspired by Granola. You take freeform notes during a meeting; in parallel the app records mic + system audio, transcribes it, and produces an AI summary that fuses your notes with the transcript. Personal/small-team, not SaaS — your data, your API keys, local SQLite, no backend.

The name is Norwegian for "bumblebee".

Core capabilities

  • Hybrid capture (parallel streams) — mic + macOS system audio recorded simultaneously via a Swift sidecar, kept as two separate streams end-to-end (no mixdown). Each gets its own VAD-bounded chunk WAVs, its own full.wav, its own Whisper invocations with its own prior_context trail. In-person meetings produce only mic chunks (system stays silent → no chunks emitted) and the diarizer runs on the mic stream so multiple humans in the same room get distinct labels. Remote calls produce both, and both get diarized — the mic is never assumed to hold a single person just because the system stream has content. You is earned, not assigned: it survives only when the mic diarize resolves to exactly one voice.
  • Four STT providers — OpenAI (whisper-1 / gpt-4o-transcribe / mini / diarize), on-device Whisper via Metal, Deepgram (nova-3, nova-2, base), and Groq (whisper-large-v3-turbo). All slot into the stt::BatchSttAdapter trait so the dispatch path is provider-agnostic.
  • Per-language routing — transcribe_config (typed JSON, single source of truth) is { default: ProviderConfig, per_language: BTreeMap<String, ProviderConfig> }. Resolution at chunk time: per-note language → per-language override → default. E.g. Norwegian → local NB Whisper, English → Deepgram Nova-3, default → OpenAI whisper-1.
  • Whisper quality preset — Fast (greedy) / Balanced (beam=3) / Quality (beam=5, low no_speech threshold) for the local provider; bundles sampling strategy + confidence thresholds together so the user picks one knob.
  • Per-note transcription language — global Settings → Language is the default; each note has its own language chip that overrides for that note.
  • Offline diarization on stop — speaker-diarize Swift sidecar runs after recording_stop. Two engines (#193, ADR-0005): Nemotron 3 (FluidAudio's Nemotron3Diarizer — NVIDIA's end-to-end diarizer, up to 8 speakers, counts them itself), the default for new installs, and Community-1 (FluidAudio's OfflineDiarizerManager — community-1 segmentation + VBx clustering with PLDA), which takes the note's speaker count. The engine is chosen per note (diarize::choose_engine, the same answer for every take and the unify pass): a note set above 8 speakers goes to community-1 with the hint, keeping the hybrid split of that total; anything else to the engine diarize_model selects; and a missing model falls back to the other engine when it is downloaded, rather than writing unlabelled takes — including above 8, where Nemotron's labels are capped at 8 but beat none. A run that fails is retried with the other engine when that one is downloaded (diarize_and_maybe_clean, the one entry every diarize pass goes through): #203's macOS 27 regression failed every Nemotron run on a model marked ready, and with keep_audio off nothing could re-diarize those takes later. The pass's engine is a &mut its streams share, so once a retry succeeds the pass's remaining streams run on the fallback; a stream already diarized keeps its labels, which is harmless because each stream's voices are numbered in their own namespace off build_hybrid_labels' shared counter. The engine that failed is demoted only when the fallback succeeded on the same audio — both failing is the audio's doing — and only for the launch: choose_engine skips it, which keeps the unify pass, every later take of a replay (each chooses afresh) and later notes on the engine that worked, and a successful download from Settings (diarize_download) clears it. A demoted Nemotron 3 is also re-checked in the background (diarize::recheck, the download's warm-up inference, under the same lock as a download); only when preparing it fails does its warm marker go, every identity's — the sidecar's .humla-warm-failed- record stays, which is what keeps the launch re-warm from retrying it — so a restart doesn't pay the failure again on its first note and Settings offers to prepare it. Both toasts come from diarize.rs (fell_back_message, recheck_failed_message), and their engine names from Engine::label, mirrored by DIARIZE_ENGINE_LABEL in src/lib/diarizeEngine.ts (pinned by a test reading it). The note's speaker chip (and the import dialog) offers Auto and 1–12 from one list, src/lib/speakerCount.ts, because a count above 8 is what selects community-1. DEFAULT_DIARIZE_MODEL / Engine::from_setting / NEMOTRON_MAX_SPEAKERS are mirrored in TS by DEFAULT_DIARIZE_ENGINE / selectedDiarizeEngine / NEMOTRON_MAX_SPEAKERS in src/lib/diarizeEngine.ts (pinned by its test reading the Rust files), and enginesToDownload there fetches what engine_preference falls back to — change both. An install that ran before #193 was pinned to community-1 on upgrade (db::migrate_diarize_engine, keyed on whether notes.sqlite existed before this launch) and is offered the switch by a card on Home (nemotron_offer), because switching costs a 193 MB download and a one-time Neural Engine compile. Sortformer is removed: a stored sortformer reads as community-1 and its model folder is deleted once (diarize::remove_retired_models). Branches on which streams produced content: mic-only diarizes mic_full.wav and emits Speaker 1: / Speaker 2:; when both streams have content, both are diarized and numbered off one shared counter so a mic speaker and a system speaker can never collide on a number (build_hybrid_labels). The old shortcut — hard-label every mic chunk You: and diarize only sys_full.wav — meant a single stray system chunk (a notification chime, a few seconds of video) collapsed an entire in-person meeting onto one You: line, since presence of any sys chunk routed the recording into remote-call mode. You: is now applied only when the mic diarize resolves to exactly one voice. recording_status describes the live capture and nothing else (#187): it is a single global slot that also drives the tray icon and its Start/Stop items, so only the post-stop chain of the capture that just finished may write to it. ChainProgress is the one type that owns the channel — its bracket and its named steps, since the two are one answer and a pair of types could disagree. One variant per surface: PostStop the global phase, Rediarize the per-note diarize_status event, Replay the per-note transcribe_status (where a deferred run's audio position already rides), Silent nothing at all. It is passed in the way StopClock already is, so diarize_and_apply, rediarize_apply_to_chunks and unify_note_speakers — each shared by all four — never choose the channel; LabelFallback rides beside it and is orthogonal, describing what a pass does when labels can't be produced rather than where it reports. Re-diarize on the global phase meant re-diarizing note B while note A recorded ended on an Idle carrying no note id, so A's bar, timer, stop button and tray all read idle with the capture still live.
  • Speaker echo never becomes a voice (#196) — when a call plays through the speakers the mic records every remote voice again, and a mic diarize counts that echo as people: end-to-end engines found 3–5 voices on a mic with one person at it, and in a hybrid meeting each reads as someone in the room who isn't there. Wherever the mic is diarized beside a system stream — diarize_and_apply's hybrid branch, rediarize_apply_to_chunks' (the two mirror each other), and unify_apply per take — echo.rs runs first, as the named step removing_echo: the take's own lag from echo-probe's GCC-PHAT track (a window counts only when its neighbours find the same lag, since voiced speech gives chance peaks; none → neither part runs), the echo cancelled out of the mic by echo-probe's MDF canceller and suppressor, and the mic diarize reads that cancelled mic from a humla-echo-<uuid>.wav in the system temp dir, deleted once the diarize has read it — never under recordings/ or diagnostics/, where it would sit outside keep_audio. Then every mic voice that reads as echo is dropped before the voices are numbered, so its words fall to the nearest remaining voice: a voice is echo when its share of frames in (lag-shifted, energy-detected) sys silence is under half the take's, or the linear canceller removed ≥ 4.5 dB of its energy — the energy only ever adds to the ratio, never overrules it. Guards: under 5 s of sys silence judges nothing, and a take never loses every voice, which is what keeps Community-1's merged voice. The unify pass judges each hybrid take inside its own span of the joined mic, so a voice is dropped only where it was echo — and does so before the system stream's speaker-count hint is taken from the mic's voice count, or every echo voice would cost the remote side one. The thresholds were fitted on two podcast takes from one Mac, so the diarize dump (echo, and <engine>-unify.json for the unify pass) keeps each take's lag and echo removed and each mic voice's seconds, ratio, energy removed and verdict, to be revisited on real meetings; echo_ms / unify_echo_ms time it. An hour of audio takes about 7–8 s and 470 MB on an M1 Max (#200): the lag track's windows run on threads, both streams stay the WAVs' own 16-bit samples, and the suppressor and report run block by block inside the canceller (aec::cancel_threaded), so the cancelled mic is the only whole-take buffer it adds. A take of up to ten minutes is cancelled sample for sample as two whole passes would cancel it, which is what the check's thresholds were fitted on. A longer one converges on its last three minutes of far-end signal, ending on the last block where the far end sounds — converging on through a silent tail decays the filter's power estimate while its coefficients stay, and every range starts from that filter — then is split into ranges of at least three minutes on up to eight threads, each started from it 20 s ahead of its range. Past ten minutes the output is no longer two whole passes' bit for bit, and the diarizers flip on borderline 7–15 s fragments even between equally valid canceller variants, so judge any change there downstream against several baselines, never one. echo-probe's cancel keeps the two whole passes, for the CLI. The transcript keeps its text dedup: live chunks transcribe before the full streams exist.
  • Speaker rename + colour-coded pills — each unique speaker gets one of four design-token colours (interactive blue, success green, warning gold, accent red, cycling for 5+). A chip strip above the transcript lets the user click any speaker to rename inline; rename is a regex line-anchored rewrite of the transcript text — no separate metadata table.
  • Two-source summaries — model gets [Notater] (typed notes) and [Transkripsjon] (transcript) as two labelled blocks in one user message. Preset prompts are deliberately minimal (kind of summary + language only); the only source-trust signal is the parenthetical tags [Notater] (user-written) / [Transkripsjon] (auto) — the old explicit "favour notes for intent, transcript for facts" rule was dropped. An empty side is sent as (ingen) rather than omitted, so thinking models don't second-guess a missing block.
  • Per-note presets — Meeting / 1:1 / Lecture / Interview / Brainstorm / Voice memo, each with its own summary prompt. Custom prompts also supported (rows in summary_prompts table, referenced as custom:<id>).
  • Custom vocabulary — per-user list of names and tech terms biasing decoding. Threaded through Whisper-shaped providers as initial_prompt, Deepgram as keyterm (Nova-3) or keywords (other models) query params.
  • Trailing transcript context — every chunk's transcription receives the last ~150 committed words as prior_context (Whisper's initial_prompt slot for OpenAI/Local/Groq; Deepgram ignores it because its keywords is a per-token boost, not a continuation primer). Single biggest mitigation against silence-driven hallucinations and proper-noun drift.
  • VAD-bounded chunks — sidecar rotates each chunk at natural speech pauses (min 1.0 s / max 15 s / 500 ms silence trigger) instead of a fixed timer.
  • Reasoning-model temperature handling — gpt-5 and later / o-series reject temperature; openai::summarize detects via is_reasoning_model() and omits.
  • Local-LLM summary tuning — Ollama path goes through the native /api/chat endpoint (not the OpenAI-compat shim at /v1) so think is a real toggle instead of being template-stripped. num_ctx is sized adaptively from the actual prompt length + num_predict budget, rounded up to powers of two, bounded [8192, 65536] — a flat 65K KV cache was OOMing the model runner on tighter Macs (the cache is multi-GB on top of model weights). keep_alive: 0 releases the model immediately after each summary so RAM frees up. Sampling is model-family-aware (sampling_profile(model, think)): Qwen models get the loop-breaker profile (presence_penalty=1.5, frequency_penalty=0.5, repeat_penalty=1.0) that breaks Qwen 3.5's thinking-phase loop class; every other family (e.g. the recommended gemma4:12b-mlx) gets neutral defaults (presence/frequency=0, repeat_penalty=1.1) rather than penalties tuned for Qwen. Only ollama_chat_stream (the summary path) carries this; the agentic tool-calling step (ollama_chat_step) uses neutral temperature=0. Cloud OpenAI summary streams and shares post_chat with the chat path, which retries transient Kind::Request errors twice (500 ms / 1.5 s backoff) — covering the case where reqwest tries to reuse a pooled HTTP/2 connection that OpenAI's edge has half-closed. Retries can't rescue a connection reaped while the model reasons, which is why the path streams at all; see the openai.rs row.
  • Your notes over MCP (#172) — Humla ships its own Model Context Protocol server, so Claude Code, Codex or any other MCP client can search and read the library from wherever the user works. Six read-only tools (search_notes, get_note, get_transcript, list_notes, list_folders, list_clients), off until explicitly enabled in Settings → General → Integrations, which also offers a ready-to-paste config snippet per client. Read-only in this version, and no tool returns or references an audio file — keep_audio stays the single absolute gate on audio (#24) and this is not an exception above it. The workspace is resolved, never accepted as an argument, so Personal and workspace notes can't reach each other however a client asks. Search is keyword-only on purpose: an agentic client substitutes repeated queries for vector recall, which buys no API key, no per-query cost, no Keychain prompt and no network. The gap that does bite is cross-language — the index is lexical, so a query in the wrong language returns nothing, and the fix chosen is to carry each note's language as data (lang: on every hit and row, language as a filter) rather than to name any language in a tool description. Shipping it first-party is a maintenance decision as much as a feature: Granola never did, so its users wrote nine servers against its local store, and the schema became a public API maintained by strangers.
  • Send a note to your own webhook (#213) — the push half of what MCP does as a pull: ⋯ → Send to webhook… POSTs one note as JSON to a URL the user chose, so a knowledge base, an agent or an automation can pick it up. Manual, every time — no automatic send on stop or on summary: what leaves is a whole meeting, other people's words included, so each send is a confirmed choice, made after the user has fixed speakers and edits. It lives in the ⋯ menu beside Export…, not in the toolbar — it is opt-in, so a bar button would cost every user the toolbar's measured width, and the menu is already hidden on read-only notes. The confirmation (SendToWebhookModal) names the receiver's host only, never its path or query, which often carry a token. Opt-in twice: webhook_enabled (SQLite, an ordinary setting so the menu can ask without the Keychain) and an endpoint in the Keychain (webhook_config, read through crate::keychain), saved by its own button in Settings → General → Integrations, where the form isn't even loaded until the switch is on. Header values are write-only, like API keys: webhook_get_endpoint returns header names, and a header saved with no value keeps the stored one (merge). The backend sends the note from the database, so the view awaits flushEdits first (sendNoteToWebhook, src/lib/webhook.ts); there is no frontend snapshot of the note to drift from it. The payload (build_payload) is a contract the user's receiver is written against: an explicit allowlist under schema_version: 1, documented in docs/webhooks.md and pinned by a test listing its keys — fields may be added, existing ones keep their meaning. Folder and client go as { id, name } (an id alone means nothing to a receiver), language through summary::note_language (the rule the summary and title use), and recording covers takes with index > 0 only (the "Earlier transcript" repair session is not a take), with totals null unless every take's time is known. No audio and no path to any; keep_audio stays the single gate on audio (#24). One request, no retries, no redirects, 30 s, and no error ever carries the URL or the receiver's response.
  • Save a note's audio (#224) — ⋯ → Save audio…, beside Export…, writes the note's stored audio to wherever the user picks, because it lives at recordings/<note id>/<take id>/playback.wav and nobody can be expected to find that. It saves one file per note: every take's playback.wav (the mixed file the player plays, and the one every take keeps whether it was captured, imported or synced) joined in take order by wav::concat_pcm16_mono_16k. That copies the samples through without decoding them, refuses a WAV that isn't 16 kHz mono 16-bit (rather than gluing two formats into noise), and builds in a temp dir, copying over the destination only once the file is whole. Index 0, the "Earlier transcript" session, was never a take and isn't counted. M4A is the default — AAC through the /usr/bin/afconvert every Mac ships, because an hour of that WAV is ~115 MB — and WAV the alternative. It is its own sheet (SaveAudioModal) rather than a checkbox in Export, which writes one document through one save panel. The sheet lists what it would save before it asks where — each take with its length, Missing for one whose audio is gone, and a total (note_audio_export_info, one TakeAudio per take) — since a file quietly missing two of three takes reads as a bug, and a list shows which one at a glance where a sentence has to be read and counted. A playback.wav the join would refuse lists as missing too, so the list and the saved file can't disagree. A note with none still gets the sheet, with the reason, rather than a greyed menu item, which has no room for one. Disabled while a capture or a replay on the note may still be writing a take. This writes no audio Humla keeps — it copies out what keep_audio already let the device store, at the user's request — so #24's gate is unchanged.
  • GitHub Copilot through the user's own subscription (#227) — a summary and chat provider (copilot) that bills the user's Copilot seat instead of an API key. Humla signs in with its own OAuth app (device flow, copilot::auth, client id in CLIENT_ID; the app is registered under the Screenpartner-AS GitHub org) and keeps the GitHub token, refresh token and login in the Keychain (copilot_credentials); tokens last 8 h and refresh without a secret because they came from the device flow, and a refresh GitHub rejects signs the user out. Every call goes through the Copilot CLI driven by github-copilot-sdk (runtime feature only — no bundled 180 MB CLI, and .cargo/config.toml sets COPILOT_SKIP_CLI_DOWNLOAD or its build script downloads one). That is not a preference: GitHub refuses the copilot_internal token exchange to apps it hasn't allow-listed, and the GitHub token sent straight to the Copilot API sees only gpt-3.5/4o-mini/4o, whatever the org enabled; through the CLI the same token gets the org's whole catalogue. Each call is its own SDK session with Humla's system prompt in replace mode and available_tools limited to the tools Humla declares — the CLI's agent prompt and tools cost ~29k input tokens a call otherwise. Chat's tools are declared without handlers, so a step ends at the assistant.message carrying toolRequests and chat::run_chat drives the loop as for every provider. One session serves a whole chat turn (ChatTurnSession, held by the per-turn CopilotChatAdapter): the next step hands the tool results back into it (handle_pending_tool_call), which Copilot reports as initiator: "agent" — continuations rather than new user prompts, the distinction premium-request billing turns on — where a session per step made every step a user prompt. continuation decides whether a step can continue; the forced wrap-up adds a system turn a session can't take, so that step alone starts a fresh session with the history written out as one transcript (render_prompt). Each model call logs one [copilot] model= initiator= input= output= cost= line, never its content. A CLI that fails to start keeps its error on Runtime; copilot_status returns it as cliError and the Settings card offers the supported download when the failing CLI is another version. Finding the CLI (copilot::cli): COPILOT_CLI_PATH, Humla's own download, the usual roots, then one login-shell call for its PATH and npm prefix -g — npm's global bin is often on no PATH at all. npm's copilot is a Node loader; Humla runs the platform package's native binary beside it, so node is never needed. When none is found, Settings downloads the version the SDK was built against (cli::VERSION) from npm, checks it against a pinned sha512 and GitHub's code signature, and keeps it under Application Support. The CLI is started with --no-auto-update (it would otherwise swap itself for a newer download the SDK's protocol check may refuse), --disable-builtin-mcps and --disable-client-telemetry. Models are copilot_model (summary, title) and copilot_chat_model (chat), default auto — not the shared chat_model, because Copilot's ids overlap Anthropic's and OpenAI's without meaning the same thing. No embeddings, so chat retrieval is keyword-only, as on Anthropic. cargo test --lib copilot_live -- --ignored (with HUMLA_COPILOT_TOKEN) and copilot_download_live run the real thing and spend real requests.
  • Your own OpenAI-compatible endpoint (#173) — a custom provider for summaries, chat and transcription: one base URL (custom_base_url) used exactly as typed — Humla appends only the route, so every error names the full URL it asked — an optional key in the Keychain (custom_api_key; none means no Authorization header at all), and a model per surface (custom_model, custom_chat_model, and the transcribe_config entry's own model). local keeps its stays-on-your-Mac promise; a hosted endpoint never goes through it. Nothing is guessed about the server: chat and summary send model, messages, stream (and chat's tools) and nothing else — no temperature, token limit or reasoning field, no Ollama port detection, no /v1/responses fallback, whatever the URL or model name looks like (openai::Endpoint::Custom). Transcription asks for verbose_json word timings and, on a 400, asks that chunk again in plain json and remembers the (URL, model) for the launch (stt/custom.rs) — that model's chunks then carry no per-word highlighting. Chat on it searches by keyword only, as on Anthropic and Copilot. The model listing (custom_list_models) only helps fill the field, since a gateway may serve no /models, and Settings' Test (custom_endpoint::check) reports the listing and a one-line completion separately. Personal-only: workspace chat runs server-side.
  • Two-factor authentication, and one session that survives a restart (#221; server contract in humla-cloud/docs/two-factor.md). commands/cloud_auth.rs owns the session: its token is kept in the Keychain (cloud_credentials, beside the email) and renewed with auth-refresh — renew is the rule, tested against a fake AuthServer. The password is only a fallback, and is dropped the first time the server answers it with two_factor_required: each password sign-in of a 2FA account leaves a challenge on the server, and no background task can supply the code. So an account with 2FA on stores no password at all (enable drops it, disable keeps it again), and a session that ends asks the user back (signed_out_email) instead of retrying. The sync worker never signs in: cloud_sync::Config carries a Session the app lends it (AppSession in cloud_worker.rs), a refused token goes back through rejected, and the worker reports signed_out / two_factor_required rather than error. Sign-in is two-step on every surface through one hook and one form (useCloudSignIn, TwoFactorCodeForm): cloud_login answers { step: "two_factor" } and keeps the challenge in Rust, cloud_verify_two_factor finishes it, and challenge_expired sends the form back to the password. The 2FA commands reject with { reason, message } (CloudError, read in TS by cloudError), worded by two_factor_error. A workspace that requires 2FA is hidden from a member without it; cloud_status reports it as locked_workspaces (and locked_current_workspace when it is the selected one), the switcher shows it locked, leading to Settings → Account. A change made there while locked is never dropped (requires_two_factor in the worker): a push it refuses is the rule, not the payload, and a deletion that finds nothing to tombstone found a hidden record, not a missing one. A server without the 2FA routes may answer them with its web page, which reads as no requirement. Setup's QR code is drawn in Rust (qr_code, qrcodegen) as an SVG path, so the page injects no markup; recovery codes are saved through cloud_save_recovery_codes, which refuses anything that isn't one.
  • A Note names itself (#90) — a Note whose Title Humla owns gets a short phrase derived from its own content — the head of the typed notes, the transcript sampled across its whole length (sample_transcript: whole up to 3,000 chars, otherwise six non-overlapping windows from the middle of six equal stretches, so whatever took most of the meeting fills most of the sample and the small talk at either end gets at most a window's share), and the summary when one exists. Every trigger reads the Note through the one title_input, so a rule about what a title sees can't differ between them. menubar::is_replaceable_title is the single answer to "may this Title be replaced?": empty, a Recording 19 Aug 14:32 timestamp, or the Imported audio import fallback. A real filename stem is the name the user gave the file and is never touched. Two triggers, both content-settled: the post-stop recording chain, spawned concurrently with the diarize pass rather than after it — the transcript is already final by then (recording_stop drains every in-flight transcribe before it snapshots), and what diarization adds is Speaker N: prefixes a title has no use for, so waiting cost minutes on a long meeting for nothing — and a debounced body-settled checkpoint in the Note view for a Note that was typed and never recorded. It goes after record_detected_language, which resolves auto to what was actually spoken, because the title's language directive reads it. Deliberately not on summary completion — summarising is a button the user presses, so that trigger would leave the motivating case (a menu-bar recorder who never opens the Note) on its timestamp forever — and deliberately not during a recording, where it would spend GPU exactly when local Whisper needs it. The call reuses the summary path's resolve_provider, forces think: false, and substitutes a small non-reasoning model when the configured summary model reasons; it does not stream, so the summary panel never sees it. Failure is silent on the automatic path (a user who never asked for a Title must not be told one failed) and loud on ⋯ → Regenerate title, which bypasses eligibility because the user asked. That menu item is disabled while the Note has nothing to read (no notes, transcript or summary — the backend says so in words rather than reporting a model it never asked as having answered nothing), while a capture or a deferred transcription is still changing the transcript, and while a title is already being written. One-shot by construction: once written, a Title is no longer replaceable, so a Note recorded in several takes keeps the first take's Title and regenerate is the escape hatch. The call is bracketed by a title_status event (per-note, like summary_status), which the open Note view answers by standing a shimmer in for the title box — the text there is about to be replaced, so dimming it would leave the wrong answer on screen while the right one loads. is_replaceable_title is mirrored in TS as isReplaceableTitle (src/lib/noteSync.ts) — change both.
  • Transcribe on your terms (#146) — Transcribe manually (Settings → Recording → Audio retention) trades the live transcript for a quiet Mac: the meeting is captured and its per-source streams retained, and nothing reaches a provider until the user presses Transcribe on the note. Gated twice, deliberately. keep_audio is the outer gate and stays so — deferring on a device that stores no audio would discard the meeting, and force-retaining behind the user's back is the exact class of exception #24 exists to remove (sessions::defer_transcription owns the rule; Settings shows the toggle only once retention is on, so the UI and the backend agree). And it applies only to on-device Whisper, resolved through the note's own language: what the setting buys is a Mac that isn't running Metal inference for an hour, and a cloud provider costs the machine nothing, so a per-language override routing one note to Deepgram keeps its live transcript while a Norwegian note defers. The decision is read once, at recording_start — flipping the setting mid-meeting leaves the running capture alone, since half a transcript is worse than either regime. Each take carries a persisted transcribed flag in sessions.json (defaulting true, so every take in every existing library reads back as transcribed rather than offering to re-transcribe the world), and it is independent of the setting's current value: turning Transcribe manually off later still leaves old takes transcribable. Pressing Transcribe replays each pending take's retained audio through the same decode → VAD chunk → transcribe → diarize pipeline a file import uses, oldest first — because each take's timeline is numbered off the takes before it. Three things make it not-a-recording, each load-bearing: it never takes the single live-capture slot, so replaying an hour of audio doesn't stop the user recording their next meeting; it reports on its own per-note transcribe_status channel rather than recording_status, so it can't blank the bar of a recording running on another note (ChainProgress::Replay is what routes the shared diarize path off the global channel, and it stays that way — #187's rule is that only the live capture's own chain writes to recording_status, so the replay's steps are announced on its own per-note channel) — and that channel carries how far the run has got and which half of the take it is in, since a take is replayed and then diarized and the two are minutes apart on a long meeting (#188: 1m48s of diarize sat at 100% under Transcribing…). The measure is audio position (ReplayProgress: each take's duration times the streams it replays, monotonic and clamped in Rust) rather than chunks, whose total isn't known until the run ends; the step beside it (#188's transcribing | diarizing, widened by #189) is what the label and the track follow, because done_ms reaching the total and the diarize starting are the same reading — every step past transcribing keeps the position it reached (it is where the next take resumes) and goes indeterminate, since the sidecar emits no progress and nothing may invent a percentage. The note's own bar and the app-wide compact pill draw all of it through the one CaptureIndicator, in the same two places a stop reports — progress has one position whatever kind of work it is, so the Transcript panel keeps its shimmer and draws no second copy. Identifying speakers in take 2 of 3… is the widest label the bar ever carries, which is why ROW_STEPS gained a sixth step (counter): it is the only part of a progress pill that can go, and it fires before the busy pill's word — the one inversion of that ladder's cost order, on the grounds that the take clause is the only one of the two a sighted user can still read afterwards, from the pill's title. Every step of a run is also instrumented the way a stop's is (ReplayTimings / ReplayTakeTimings — per take the transcribe, the diarize and the playback_ms inside it, plus the run's own unify_ms; one stderr line per run plus diagnostics/<note_id>/replay-timings.json — a sibling file, not a merge into the take's diarize diagnostics, because that one is keyed <engine>-<source> and every take of a run would write it); the ratio between the halves inverts against a stop, which did its Whisper work during the meeting. And its chunks never stream into note.transcript — the text arrives once, from rebuild_note_transcript over every session's timeline, so a replay that dies half-way leaves nothing behind that no timeline accounts for (ADR-0004), and a newly transcribed take lands at its own position in the note even when a later take was already transcribed (which combine_with_snapshot, appending to the end, could not). A diarize that can't run is not a failure here (LabelFallback::Unlabelled): the text lands without Speaker N: prefixes rather than being thrown away with the labels. A take that transcribed nothing is deliberately not marked transcribed — silence and "every chunk failed" are indistinguishable from there, and the second has to stay retryable. note_sessions answers both halves of "would Transcribe do anything" as canTranscribe (untranscribed and the raw streams still on disk — session_has_replayable_audio, not session_has_audio, since a take reduced to its mixed playback.wav still has audio and still can't be replayed), so a take swept away by "Delete stored audio" never offers a button that can only fail.
    • The same machinery re-transcribes, from a RefreshCw icon button in the Transcript panel beside Copy — mirroring the Summary panel's regenerate, because the two panels' actions shouldn't read as different kinds of control. sessions::TranscribeScope is the difference and the whole of it (mirrored by the "pending" | "all" union on ipc.transcribeNote — change both): Pending finishes what a deferred capture left waiting, All re-runs every take whose raw streams survive (canRetranscribe), which is the fix for a recording that came back off the wrong language or the wrong model. takes_to_transcribe is the single answer both surfaces mirror, and it filters on replayable audio under both scopes so a run can never be offered that would silently skip every take. All reaches the legacy flat take that Pending can't (it reads back transcribed: true) — deliberately, since a note recorded before per-session storage is exactly the kind whose language needs correcting. And because All replaces text the note already had while db::set_transcript keeps no history, the command snapshots a note revision first, so the run is undoable from the note's existing revision list; Pending needs none, since it only adds takes that had no text. A take that fails mid-run stops the run without discarding what the takes before it committed — their timelines are written and their text is in the note, so the checkpoints and the asset push still happen, and the failure becomes the outcome only after.
    • A session's scalar metadata stays immutable, duration_ms included. cloud-sync's push_session derives a session's last-write-wins key from its own started_at on the stated grounds that "index / started_at / duration / streams never change", so a duration_ms corrected after the fact re-pushes under a byte-identical key and converges only because the server's hook compares < rather than <=. That is why a deferred take's length is taken from the sidecar's full_recording event at capture time (CaptureSink::captured_duration_ms → finalize_session, which prefers the timeline and falls back to this) rather than backfilled once its timeline exists. record_transcribed_take therefore writes the flag and nothing else.
  • Every step of the stop and the replay has a name (#189) — one Step vocabulary in recording.rs that both chains draw from (transcribing · saving_audio · removing_echo · diarizing · writing_playback · matching_speakers), because the replay's second half is the stop's post-stop chain: the label and the counter come from one place and the client maps one type. A step earns a name only if it can run long enough to be worth one — record_detected_language, finalize_session, the manifest writes and the temp-dir cleanup are all reliably sub-100 ms, and a label that flashes is worse than none. The two that mattered most were both invisible before: unify_note_speakers, a second full diarize over every take's concatenated audio for any note with 2+ takes and plausibly the longest single step either chain runs, and the per-stream diarize count, where a hybrid capture's two sequential diarize passes are a real discrete fraction. A discrete counter is TEXT, never the track's value. Every step emits its counter before that unit's work, so a fraction off index / count reads 100% with stream 2 of 2 not started and retreats to 50% at the next step's boundary — and (index - 1) / count only trades that for a bar sitting at zero through the whole first unit, which reads as stuck. So StepCounter renders as 1/2 beside the label (returning None below two units — "1 of 1" is a number about nothing) and the track stays indeterminate for it. The track is determinate for exactly two things, both genuinely continuous: the stop's drain (chunks done over chunks pending) and the replay's audio position. discreteCounter in RecordingBar.tsx is the single place that rule lives — it returns the counter text and value: null together, so no call site can pair a counter with a fraction. Payloads stay additive and mirrored field-for-field: step / index / count ride the Diarizing phase on recording_status and the run's own measure on transcribe_status, and every bracket serializes byte-identically to before. The pill shows one counter, the coarsest it has — which take of the run beats which stream of the take — and it is the same slice ROW_STEPS.counter hides at the narrowest widths, so a new label cost the row nothing (tightest slack over the swept recbar-* scenarios is unchanged at 14px). Instrumentation is the existing one: StopTimings already attributed keep_audio_ms, the per-stream diarize_mic_ms / diarize_sys_ms, playback_assets_ms and unify_ms; ReplayTimings gained the matching unify_ms and a per-take playback_ms, so "which step is slow" stays answerable from data rather than from a second mechanism. stop_steps / replay_steps are the executable statement of each chain's sequence — test-only, since the chains announce as they go rather than walking a list, so the pair is kept in step with the emitting code by hand. Both they and the live emitter (StreamSteps, over the two optional stream WAVs) derive index and count from recording::stream_reports, so a per-stream step counts what is actually present: a stream that isn't there is not counted, a third source is one more flag, and no call site writes 1, 2 by hand.
  • A note can be private inside a workspace (#191) — notes.private, shown and changed from a lock chip in the note meta bar beside the workspace chip, and repeated as a lock on the note's card. Rendered only for a note that has a workspace: a Personal note is private by definition, and a second word for it there would only invite the question of what the difference is. Unlike speakers and detected_language this is not derived — it syncs, and it bumps updated_at, because it records a decision and has to beat a stale copy on another device. The default is shared, and sticky: the last visibility the user chose in that workspace becomes the next note's default (note_private_default:<workspace_id>), per workspace because the honest answer in a client's workspace is not the answer in your own; the menu-bar recorder reads the same rule. Only the author may change it — every member can edit a shared note, so without that guard a colleague could withdraw someone else's note from the whole team by flipping one boolean.
    • Tightening the server rule stops future reads and nothing else. A record that leaves your view produces no pull event at all, so a teammate who already synced the note keeps a complete local copy, offline, forever. note_revocations (member-readable, an id and a timestamp, no content) is the only signal that reaches them; note_withdrawn on the SyncObserver is what carries the transition rather than the new value, since only the moment of the flip says to delete anything. Applying one is the single pull path that destroys rather than converges, so it is guarded three ways — same workspace, a non-empty owner (a note that never synced is ours by construction), and an owner that is not us — and it takes the derived chunks with it, for the reason db::purge_note does (ADR-0002), and every file the note left on disk through the same purge_note_assets a Trash purge runs: the crate hands the id back on its purged callback rather than knowing which directories a note keeps.
    • The chat index is the other half, and it is the one a correct rule set still leaks through. humla-cloud's indexer reads with a service token across every workspace, which no collection rule constrains, so private and owner ride on every chunk and IndexStore.viewerSql is composed by every retrieval path — search, the match count, and both speaker listings, a name being as much of a leak as a passage. The viewer is optional in the type and fail-closed in effect: omitted, it matches only public chunks, so a call site that forgets it loses the caller's own private notes rather than showing them someone else's.
  • The note views are a card grid — All notes and Folder render NoteCard in a .nd-notegrid, with no date headings: the card carries its own full date (formatNoteDate) because there is nothing above it to say when. Each card is date, title, a four-line excerpt, then state · client · folder. The excerpt prefers the summary's opening line (noteExcerpt) — the distilled version of everything else on the note — falling back to typed body, then to the transcript with speaker labels stripped. "Opening line" is gated, not literal: horizontal rules, model preamble ("Her er …", anything ending in :) and fragments under 20 chars are stepped over — the first of them kept only when the summary holds nothing stronger — and a fragment body likewise yields to the transcript. Both grid views filter on four axes — status, Client, who recorded it, and whether it is filed anywhere. Each axis is its own control because they are independent, and the folder axis is a single No folder toggle rather than a picker: the sidebar already navigates to a folder, so the only reach a folder axis adds is the notes filed nowhere — which is why a Folder view renders the same bar with that toggle off (showUnfiled), every note there being filed already. A folder's filter is cleared when the folder changes: the route's params move under the same component, so without that it silently narrows the next folder and reads as an empty one. The status axis asks what a note HAS, so its rungs overlap — a summarized meeting matches Recorded, Transcribed and Summarized alike (hasStatus). The card's dot cannot work that way and doesn't: noteState is an exclusive ladder naming the furthest rung a note reached, which is why the two carry separate label maps (NOTE_STATE_LABEL / NOTE_FILTER_LABEL). Reading the filter off the ladder instead was tried and is wrong: under it Recorded means "recorded and nothing since", which on the default settings — where every take transcribes as it records — is a filter that matches nothing at all, and "show me my meetings" becomes inexpressible. recorded is the one rung a note row can't answer, and it comes from notes_with_recordings, one sweep of the recordings root asking resolve_sessions whether a manifest entry exists — a manifest entry, not the audio beside it, so a take swept by "Delete stored audio" is still a meeting that happened. The set lives on useNotesStore.recordedNoteIds because both grid views draw the dot from it, and it is refreshed on a capture reaching idle and on a replay finishing, either of which can happen from the tray while a grid is the view on screen. The owner axis renders only in a workspace with more than one member, and "Me" claims a note whose owner is empty — a note recorded here before it ever synced is still yours, and hiding it would drop your newest meetings out of your own filter. Multi-select is unchanged from the old list row (#19): the same Cmd/Shift-click, Space, Esc and checkbox rules, with the checkbox moved to the card's top-right corner.
    • A borderless card needs a recessed ground. .nd-well darkens the scroll region a few percent, because the card's edge is entirely tone against what it sits on — and graphite declares --color-canvas and --color-surface both as #ffffff, which leaves a white card on a white page with nothing but a shadow. Darkening is the one move that works in every theme and both modes; in dark the surface is already the lighter of the pair, so pushing the ground down widens the same gap. For the same reason selection cannot be a fill alone — --color-accent-soft is tuned against the canvas, and graphite's #efefef lands darker than the well, so a picked card would recede. The accent ring is what reads everywhere. Everything else the card owns comes from the theme — radius from --radius-card, shadow from --shadow-card — so it is the third inset card in fact and not only in description. Under prefers-contrast: more the card takes a real border and drops the shadow: tone-as-edge is exactly what that preference asks us to stop doing.
    • .nd-notegrid's track floor is minmax(min(var(--card-min, 300px), 100%), 1fr). The min() is load-bearing — a bare minmax(300px, 1fr) cannot shrink past its floor and overflows the view sideways once the body column is narrower than one card, which it is at the layout's own minimum width.
  • Folders — flat folder list, per-note assignment; sidebar search matches note titles/bodies/transcripts and renders a flat result list annotated with each note's folder name.
  • Chat over your notes — an agentic retrieval loop (chat/), surfaced as the Chat tab of the Note right panel. The assistant searches and reads notes with three tools (search_notes over FTS5 keyword + semantic embeddings, get_note, list_notes), streams its answer, and cites the notes it drew from as chips that navigate to the source. Two per-conversation retrieval filters live on the conversation row and bind every turn regardless of what the model asks for: breadth (note | folder | all — what is in reach) and the authorship pin (owner_filter, a user id — whose notes are in reach, workspace-only, surfaced as the "Created by me" toggle beside the breadth picker). Multiple conversations per note, with history; a conversation can be renamed or deleted (hard, with a confirm — there is no Trash for chat) from the sidebar row's right-click menu, the /chat app bar's ⋯, or the note pane's history popover. Runs on a chat provider configured separately from transcription and summary (Settings → Chat; OpenAI or a local server). The local provider is still stored as "ollama" but is not Ollama-only (#179): the adapter picks Ollama's native /api/chat on :11434 and plain OpenAI-compat everywhere else, the same call summarize_with_base makes — mirrored in TS as isOllamaUrl, which decides only what advice to print. The embedder for semantic retrieval has its own address and name (embed_base_url / embed_model, both blank = the chat server and embeddinggemma), because the chat server is frequently not the one that can embed: mlx_lm.server serves no /v1/embeddings route at all, llama-server wants its own process, and LM Studio names the same model differently. Settings probes it with a real one-word embedding rather than reading the chat server's model listing — a listing cannot tell you a route is missing. A reachable embedder and an embedded corpus are two facts, since chunk_embeddings is keyed (text_hash, model) and search filters on the model: naming a different embedder leaves every existing vector unreachable, so chat_unembedded_note_count says how many notes that is and chat_embed_missing runs the same backfill the next launch would. localChatHint is the one readiness ladder behind both the Settings tab and the Note pane's setup prompt — the two answered the same question in two copies, and only one of them got fixed the first time. In a workspace, turns route to humla-cloud and retrieval happens server-side. Reachable inside a note, at its own library-wide /chat route, and — scoped to one folder — at /folder/:id/chat ("Chat about this folder" on the folder row's context menu, #110).
    • A conversation's scope is (tenant, scope, scope_id) — note (the note id), folder (the folder id, #110) or global (a fixed sentinel). The three populations never leak into each other, locally or server-side. Breadth is a live filter within a thread, not its identity: a Note's pane can widen to its folder or the library, but a note-less target is pinned to its own reach (all for /chat, folder for a folder chat) because with no anchor the target's identity is its reach — pinned_breadth / targetPinsScope own that rule on each side. Deleting a folder hard-deletes its conversations (they have no Trash and their whole reach was that folder); its notes only reparent, so the Sidebar confirms only when threads would actually be lost. ChatTarget::from_ids takes the note and folder ids as alternatives — both at once is an error, and an empty id is an error under either.
    • Opening a pane drafts or resumes, by target (resumes_on_open in Rust, targetResumesOnOpen in TS — mirrored, change both). /chat drafts: it opens on an unsaved conversation with the prompt cards showing, persisting nothing until the first turn, so an abandoned thread leaves no row and "+" is a local reset that costs no IPC. A note's Chat tab resumes that note's most-recent thread, deliberately — a note is an anchor, so continuing the same line of thinking is the plausible default there. A drafting pane's breadth and authorship pin live in the pane and ride in on chat_send (DraftSettings), which is what preserves the #61/#103 guarantee without the lazy row that used to be hidden from the list yet still resolved to by the next send. Conversation lists hide zero-message rows for drafting targets only (in a note, an empty thread is the draft being resumed) — in SQL for Personal so paging stays honest, and off the server's message_count for a workspace, whose messages never land in the local table.
  • Every word of the transcript has a timeline behind it (#169, ADR-0004) — the styled reader renders from the merged timelines, so text note.transcript carries and no timeline accounts for is invisible there, and the first rebuild (rebuild_note_transcript, reached by cycling a speaker label, deleting a chunk, re-diarizing or unifying) deletes it outright — out of the summary, chat retrieval and embeddings with it. It arose because combine_with_snapshot prepends the prior take's transcript while serialize_timeline sees the current session's chunks alone, so any recording that landed text but wrote no session assets left an orphaned prefix. Three defences, in order:
    • Prevention. diarize_and_apply no longer returns early when the diarize model is missing; it serializes the chunks with an empty label — and the label alone, since word timings are orthogonal to who spoke — so the take still writes a session — which also gives a later re-diarize somewhere to attach to.
    • Repair on open. note_timeline_repair compares comparable_words of the transcript against the timelines' projection; orphaned_prefix recovers the leading lines the projection misses and synthesize_orphan_timeline writes them as a session at manifest index 0 (named "Earlier transcript", never "Recording 0" — it was never a take). Idempotent, and it runs after the workspace session pull so a still-arriving timeline can't be duplicated. A note with no timeline at all is left alone — it renders the textarea and hides nothing, and a synthesized session would take its free-text editing away.
    • Render-time guard. When coversTranscript comes back false — a timeline present but short because malformed lines were skipped, an asset that never downloaded — the note falls back to TranscriptView over the whole string. The comparison is the backend's; the client never re-derives the grouping rule (Rust groups on label alone, groupTimeline on label and session, deliberately), and comparisons normalize word sequences, never line counts. One more mirrored pair to change together: split_label (Rust) and parseTranscriptLines (Note.tsx) are the same "Label: prefix" rule, and the repair splits lines by the first while the reader draws them by the second. Separately, commit_rebuilt_transcript refuses to commit an empty projection over a non-empty transcript, which protects pre-sessions notes that have a transcript and no timeline at all from being blanked by one click.
  • Transcript editing routes through the timeline (#170) — on a note that has a timeline, the styled reader renders from it and note.transcript is a projection of it, so editing is per turn: a hover pencil opens that turn in place, and committing calls note_timeline_set_chunk_text with the whole run of chunk indices the turn spans (TimelineGroup.indices), which writes the text into the lowest index, empties the rest, and re-derives the transcript from every session's timeline — the same path note_timeline_set_chunk_label and note_timeline_delete_chunk already take. Word timings on an edited turn are dropped (they describe words that are gone); its bounds are kept, so it still highlights during playback and only per-word karaoke is lost, on that turn. The old panel-wide textarea wrote the derived copy and touched no timeline, so the edit was invisible in the reader — permanently — while summary, chat and embeddings read the edited string. A note with no timeline keeps the whole-transcript textarea (TranscriptEditor): with no timeline there is no second copy to orphan. Both are locked while a recording is in flight.

Read the full file on GitHub · 328 lines

Changes

What this file has done since we first saw it

Hashed on every crawl. A supply-chain change to an agent config is a question of when, not whether, so the history is kept rather than the latest state alone.

  1. 7d ago Changed · +2 lines · +449 tokens per session 2913f786c702
  2. 9d ago Changed · +2 lines · +1,022 tokens per session 8fc22bdb4c5a
  3. 13d ago Changed · +1 lines · +466 tokens per session f313b81df352
  4. 19d ago Changed · +1 lines · +624 tokens per session f18c1b8a9aa6
  5. 21d ago Changed · +1 lines · +112 tokens per session 7995692fcbce
  6. 23d ago Changed · +2 lines · +917 tokens per session fc31d42fd80a
  7. 1mo ago Changed · +1,124 tokens per session 720f7e32c6b6
  8. 1mo ago Changed · +1 lines · +103 tokens per session 75d637d00c1e
  9. 1mo ago Changed · +3 lines · +2,479 tokens per session 66f39340daef
  10. 1mo ago First seen · 315 lines · 24,669 tokens per session scan A 5cf917e71917

Subscribe to this mod's changes

humla CLAUDE.md is an instructions file published in the GitHub repository michaelwilhelmsen/humla (339 stars, last pushed 5d ago), licensed MIT. It adds 31,965 tokens to every session, about $0.1279 per session on Opus 5.5. A static security scan graded it A with 0 findings. No closer match exists in the catalogue, so it is treated as the original; first seen 2026-08-30.

Related

humla CLAUDE.md compared with instructions from other repositories

humla CLAUDE.md next to 6 instructions from other repositories
ModTypeStarsLast pushLicenceScanTokensWorks with
humla CLAUDE.mdmichaelwilhelmsen/humlaInstructions file3395d agoMITA31,965Claude Code
vscode buildNext.instructions.mdmicrosoft/vscodeInstructions file195kyesterdayMITA9,719GitHub Copilot
next.js AGENTS.mdvercel/next.jsInstructions file143ktodayMITA7,461Codex, OpenCode
langchain AGENTS.mdlangchain-ai/langchainInstructions file149kyesterdayMITA4,680Codex, OpenCode
vscode oss-third-party-notices.instructions.mdmicrosoft/vscodeInstructions file195kyesterdayMITA5,516GitHub Copilot
vscode copilot-instructions.mdmicrosoft/vscodeInstructions file195kyesterdayMITA3,093GitHub Copilot
deepseek-harness AGENTS.mddeepseek-ai/deepseek-harnessInstructions file274k2d agoMITA4,313Codex, OpenCode