From e4aedbf1fddfdbd29f0cb11850667f67e9f3ba60 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:11:27 +0000 Subject: [PATCH 1/4] Scale Ask Halen detail + store tag text with Dynamic Type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ask Halen plugin detail view and the Plugin Store's "External"/ "Example" tag used fixed point sizes for reading text (size 8–12), unlike the rest of the app which uses semantic fonts that respect Dynamic Type / Larger Accessibility Sizes. Increasing the system text size left these labels frozen and, in the tag's case, near-illegible at 8pt. - Ask Halen detail: convert hero/description/toggle/privacy text to .caption and .callout; fold the bespoke `cardHeader` into the shared `cardLabel` helper so section labels match every other detail view. - Plugin Store tag: size-8 → scalable .caption2. - Ask Halen palette: add a "Send (⏎)" tooltip on the send button to match the Close and Insert buttons' shortcut hints. --- .../Halen/Features/AskHalen/AskHalen.swift | 33 ++++++++----------- .../Features/AskHalen/AskHalenPalette.swift | 1 + .../Halen/Plugins/Store/PluginStoreView.swift | 2 +- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/Sources/Halen/Features/AskHalen/AskHalen.swift b/Sources/Halen/Features/AskHalen/AskHalen.swift index 60b5e1c..5f81f8b 100644 --- a/Sources/Halen/Features/AskHalen/AskHalen.swift +++ b/Sources/Halen/Features/AskHalen/AskHalen.swift @@ -492,7 +492,7 @@ private struct AskHalenDetailView: View { Text("Press ⌃H anywhere") .font(.system(.callout, weight: .medium)) Text("A floating palette opens with your focused app, selection, and clipboard in context. Terminals consume ⌃H as backspace, so the hotkey won't fire inside Terminal or iTerm.") - .font(.system(size: 11)) + .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) @@ -503,7 +503,7 @@ private struct AskHalenDetailView: View { private var modelCard: some View { card { - cardHeader("Model") + cardLabel("Model") Picker("", selection: $tierRaw) { Text("Small").tag(ModelTier.small.rawValue) Text("Medium").tag(ModelTier.medium.rawValue) @@ -515,17 +515,18 @@ private struct AskHalenDetailView: View { HStack(alignment: .firstTextBaseline) { Text("Temperature") - .font(.system(size: 11, weight: .medium)) + .font(.caption) + .fontWeight(.medium) .foregroundStyle(.secondary) Spacer() Text(String(format: "%.2f", temperature)) - .font(.system(size: 11, design: .monospaced)) + .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) } .padding(.top, 4) Slider(value: $temperature, in: 0.0...1.0, step: 0.05) Text("Lower is more literal. Higher is more creative. 0.40 reads as a balanced default for question answering.") - .font(.system(size: 10.5)) + .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } @@ -533,13 +534,13 @@ private struct AskHalenDetailView: View { private var contextCard: some View { card { - cardHeader("Context") + cardLabel("Context") Toggle(isOn: $includeParagraph) { VStack(alignment: .leading, spacing: 1) { Text("Include surrounding paragraph") - .font(.system(size: 12)) + .font(.callout) Text("The paragraph your cursor is in. Off makes Ask Halen a pure Q&A surface.") - .font(.system(size: 10.5)) + .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } @@ -552,9 +553,9 @@ private struct AskHalenDetailView: View { Toggle(isOn: $includeClipboard) { VStack(alignment: .leading, spacing: 1) { Text("Include clipboard contents") - .font(.system(size: 12)) + .font(.callout) Text("Recent clipboard text. Useful for \"summarise what I just copied,\" off if your clipboard has sensitive things you don't want a model to see.") - .font(.system(size: 10.5)) + .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } @@ -567,11 +568,11 @@ private struct AskHalenDetailView: View { private var privacyNote: some View { HStack(alignment: .top, spacing: 8) { Image(systemName: "lock.fill") - .font(.system(size: 11)) + .font(.caption) .foregroundStyle(.secondary) .padding(.top, 1) Text("Whatever you turn on here is sent to the local model only. Nothing leaves your Mac.") - .font(.system(size: 11)) + .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } @@ -594,12 +595,4 @@ private struct AskHalenDetailView: View { ) ) } - - private func cardHeader(_ text: String) -> some View { - Text(text) - .font(.system(size: 10.5, weight: .semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - .tracking(0.6) - } } diff --git a/Sources/Halen/Features/AskHalen/AskHalenPalette.swift b/Sources/Halen/Features/AskHalen/AskHalenPalette.swift index e68f9c1..ab6cd22 100644 --- a/Sources/Halen/Features/AskHalen/AskHalenPalette.swift +++ b/Sources/Halen/Features/AskHalen/AskHalenPalette.swift @@ -108,6 +108,7 @@ struct AskHalenPalette: View { .buttonStyle(.plain) .disabled(state.question.isEmpty) .keyboardShortcut(.return, modifiers: []) + .help("Send (⏎)") .accessibilityLabel("Send") .accessibilityHint("Sends your question to Halen.") } diff --git a/Sources/Halen/Plugins/Store/PluginStoreView.swift b/Sources/Halen/Plugins/Store/PluginStoreView.swift index 2a288e9..dc6a8f0 100644 --- a/Sources/Halen/Plugins/Store/PluginStoreView.swift +++ b/Sources/Halen/Plugins/Store/PluginStoreView.swift @@ -318,7 +318,7 @@ private struct InstalledPluginRow: View { private func tag(_ text: String) -> some View { Text(text.uppercased()) - .font(.system(size: 8, weight: .bold)) + .font(.caption2.weight(.bold)) .tracking(0.4) .foregroundStyle(.secondary) .padding(.horizontal, 5) From b3136d1f8aaf26eb75f886bbcaa26da153ff626e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:40:37 +0000 Subject: [PATCH 2/4] Website: accurate privacy page, demo polish, copy tightening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Privacy page was the priority — it claimed "only loopback connections" and "no auto-updater", but the app ships Sparkle (SUFeedURL = halen.dev/appcast.xml, automatic daily checks, signed DMGs from GitHub Releases). An inaccurate privacy page undermines the whole trust pitch, so this honestly discloses the one outbound connection: - New "Software updates" section documenting the Sparkle check: what's fetched (static appcast + signed DMG), EdDSA verification, that no content/identifiers are sent, and how to disable automatic checks. - Correct the "only loopback" / "no auto-updater" claims and the callout ("nothing you write leaves the machine"). - Add a "Last updated" line + meta description fix. Copy (4 one-word, high-leverage): - "Plugins for the cursor" → "Tools for the cursor" (less jargon) - "Halen sits in your menu bar" → "lives" (matches README voice) - "Apple Silicon only" → "Apple Silicon native" (reframe at the CTA) - "a small local classifier" → "model" (drop ML jargon) Demo: - Rail showed two identical "Word Replacements" chips; relabel the style path "Banned Words" so it reads as a distinct capability. - Polish the product-mockup model tags "gemma4:e4b" → "Gemma 4 E4B" (left the terminal-style swarm tags lowercase, where they belong); fix the swarm's "gemma4:26b" → "27b" to match the CTA model card. Remove interactions.js — a stale, unreferenced demo script (the live demo is the inline script in index.html). --- index.html | 22 ++--- interactions.js | 243 ------------------------------------------------ privacy.html | 26 ++++-- 3 files changed, 31 insertions(+), 260 deletions(-) delete mode 100644 interactions.js diff --git a/index.html b/index.html index 23cc614..80303d6 100644 --- a/index.html +++ b/index.html @@ -1348,7 +1348,7 @@

No accounts.

-

Halen sits in your menu bar, near your cursor. The models never leave your Mac.

+

Halen lives in your menu bar, near your cursor. The models never leave your Mac.

Your words stay yours.

@@ -1361,7 +1361,7 @@

-

Plugins for the cursor.

+

Tools for the cursor.

Pick one. Watch what it does.

@@ -1375,7 +1375,7 @@

Plugins for the cursor.

@@ -1465,7 +1465,7 @@

Plugins for the cursor.

HALEN · matched - expanding ;polite via gemma4:e4b + expanding ;polite via Gemma 4 E4B
@@ -1473,7 +1473,7 @@

Plugins for the cursor.

!
This reads as hostile - gemma4:e4b · 100% local · 118 ms + Gemma 4 E4B · 100% local · 118 ms
@@ -1612,7 +1612,7 @@

At your cursor.
All on-device.

macOS 14+ - Apple Silicon only + Apple Silicon native Free · MIT
@@ -1712,7 +1712,7 @@

Trusted by people who write all day on a Mac.

models: [ { text: 'gemma4:e4b', kind: 'dark' }, { text: 'gemma4:e2b', kind: 'dark' }, - { text: 'gemma4:26b', kind: 'dark' }, + { text: 'gemma4:27b', kind: 'dark' }, { text: 'ollama', kind: 'dark' }, { text: 'on disk', kind: 'tag' }, { text: 'localhost:11434', kind: 'tag' }, @@ -2125,7 +2125,7 @@

Trusted by people who write all day on a Mac.

prefix: '', rewritePrior: 'Hey Alex, just lock the scope already. We’ve redone this three times.', expanded: 'Hey Alex, could we lock the scope this week? I want to make sure we land it cleanly.', - pop: { l: 'HALEN · matched', v: 'softening prior paragraph via gemma4:e4b' }, delay: 950, + pop: { l: 'HALEN · matched', v: 'softening prior paragraph via Gemma 4 E4B' }, delay: 950, }, // Writing Coach (tone tab) — Qwen 2.5 0.5B classifier on each sentence // end. Hostile label triggers the guard card with Send-anyway and @@ -2339,7 +2339,7 @@

Trusted by people who write all day on a Mac.

if (userInterrupt) return; await wait(300); $popL.textContent = 'HALEN · writing coach'; - $popV.textContent = 'classifying tone · gemma4:e4b'; + $popV.textContent = 'classifying tone · Gemma 4 E4B'; $pop.classList.add('show'); await wait(900); $pop.classList.remove('show'); @@ -2348,7 +2348,7 @@

Trusted by people who write all day on a Mac.

await wait(3000); if (userInterrupt) return; $popL.textContent = 'HALEN · rephrasing'; - $popV.textContent = 'softening tone · gemma4:e4b'; + $popV.textContent = 'softening tone · Gemma 4 E4B'; $pop.classList.add('show'); $trigger.style.transition = 'opacity 0.4s'; $trigger.style.opacity = '0'; diff --git a/interactions.js b/interactions.js deleted file mode 100644 index d09f59a..0000000 --- a/interactions.js +++ /dev/null @@ -1,243 +0,0 @@ -(() => { - 'use strict'; - - const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - - /* ---------- Scroll reveal ---------- */ - const revealEls = document.querySelectorAll('.reveal'); - if ('IntersectionObserver' in window && !prefersReducedMotion) { - const io = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - entry.target.classList.add('is-visible'); - io.unobserve(entry.target); - } - }); - }, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' }); - revealEls.forEach((el) => io.observe(el)); - } else { - revealEls.forEach((el) => el.classList.add('is-visible')); - } - - /* ---------- Install snippet copy ---------- */ - const snippet = document.querySelector('.install-snippet'); - if (snippet) { - const copyBtn = snippet.querySelector('.snippet-copy'); - const label = snippet.querySelector('.snippet-copy-label'); - copyBtn?.addEventListener('click', async () => { - const cmd = snippet.dataset.copy || ''; - try { - await navigator.clipboard.writeText(cmd); - copyBtn.classList.add('copied'); - if (label) label.textContent = 'copied'; - setTimeout(() => { - copyBtn.classList.remove('copied'); - if (label) label.textContent = 'copy'; - }, 1600); - } catch (_) { - copyBtn.classList.add('copied'); - if (label) label.textContent = 'select & copy'; - } - }); - } - - /* ---------- Plugin toggle list ---------- */ - const pluginList = document.getElementById('pluginList'); - const pluginCount = document.getElementById('pluginCount'); - if (pluginList && pluginCount) { - const refreshCount = () => { - const on = pluginList.querySelectorAll('.plugin-row[data-on="true"]').length; - pluginCount.textContent = String(on); - }; - pluginList.querySelectorAll('.plugin-row').forEach((row) => { - row.addEventListener('click', () => { - const on = row.getAttribute('data-on') === 'true'; - row.setAttribute('data-on', on ? 'false' : 'true'); - row.setAttribute('aria-pressed', on ? 'false' : 'true'); - refreshCount(); - }); - }); - refreshCount(); - } - - /* ---------- Tone-classifier demo ---------- */ - const SAMPLES = { - hostile: { - text: "This is absolutely unacceptable. I'm furious about how you handled this and I expect better immediately.", - label: 'hostile', - confidence: '0.94', - cls: 'tone-hostile', - icon: '!', - popoverColor: 'rgba(255, 110, 110, 0.18)', - popoverText: '#FF8B8B', - }, - irritated: { - text: "I've already asked twice. Can someone actually take ownership of this before end of day?", - label: 'irritated', - confidence: '0.81', - cls: 'tone-irritated', - icon: '!', - popoverColor: 'rgba(255, 176, 91, 0.18)', - popoverText: '#FFB05B', - }, - passive: { - text: "Thanks so much for finally getting back to me. Really appreciate it being only a week.", - label: 'passive-aggressive', - confidence: '0.76', - cls: 'tone-passive', - icon: '~', - popoverColor: 'rgba(242, 216, 90, 0.18)', - popoverText: '#F2D85A', - }, - neutral: { - text: "Sharing the updated timeline below. Happy to walk through it on a call whenever works for you.", - label: 'neutral', - confidence: '0.92', - cls: 'tone-neutral', - icon: '✓', - popoverColor: 'rgba(37, 194, 110, 0.18)', - popoverText: '#88E2B1', - }, - }; - - const draftEl = document.getElementById('typingDraft'); - const toneLabel = document.getElementById('toneLabel'); - const toneConf = document.getElementById('toneConf'); - const popover = document.getElementById('tonePopover'); - const popoverLabel = document.getElementById('popoverLabel'); - const popoverIcon = document.getElementById('popoverIcon'); - const pills = document.querySelectorAll('.demo-pill'); - const approveBtn = document.getElementById('approveBtn'); - const rephraseBtn = document.getElementById('rephraseBtn'); - - let typingTimer = null; - let labelTimer = null; - let popoverTimer = null; - - const setLabel = (sample) => { - if (!toneLabel) return; - toneLabel.className = 't-label ' + sample.cls; - toneLabel.textContent = sample.label; - if (toneConf) toneConf.textContent = `confidence ${sample.confidence}`; - }; - - const showPopover = (sample) => { - if (!popover || !popoverLabel || !popoverIcon) return; - popoverLabel.textContent = sample.label; - popoverIcon.textContent = sample.icon; - popoverIcon.style.background = sample.popoverColor; - popoverIcon.style.color = sample.popoverText; - const labelSpan = popover.querySelector('.popover-text strong span'); - if (labelSpan) labelSpan.style.color = sample.popoverText; - popover.classList.add('is-open'); - }; - - const hidePopover = () => popover?.classList.remove('is-open'); - - const playSample = (key) => { - const sample = SAMPLES[key]; - if (!sample || !draftEl) return; - - if (typingTimer) clearInterval(typingTimer); - if (labelTimer) clearTimeout(labelTimer); - if (popoverTimer) clearTimeout(popoverTimer); - - draftEl.textContent = ''; - toneLabel.className = 't-label'; - toneLabel.textContent = '…'; - if (toneConf) toneConf.textContent = 'thinking'; - hidePopover(); - - let i = 0; - const speed = prefersReducedMotion ? 0 : 22; - if (prefersReducedMotion) { - draftEl.textContent = sample.text; - setLabel(sample); - if (key !== 'neutral') showPopover(sample); - return; - } - typingTimer = setInterval(() => { - i += 1; - draftEl.textContent = sample.text.slice(0, i); - if (i >= sample.text.length) { - clearInterval(typingTimer); - typingTimer = null; - labelTimer = setTimeout(() => setLabel(sample), 360); - if (key !== 'neutral') { - popoverTimer = setTimeout(() => showPopover(sample), 820); - } - } - }, speed); - }; - - pills.forEach((pill) => { - pill.addEventListener('click', () => { - pills.forEach((p) => p.classList.remove('is-active')); - pill.classList.add('is-active'); - playSample(pill.dataset.sample); - }); - }); - - approveBtn?.addEventListener('click', () => { - hidePopover(); - if (toneConf) toneConf.textContent = 'hash approved · stored locally'; - }); - rephraseBtn?.addEventListener('click', () => { - if (toneConf) toneConf.textContent = 'rewrite copied to clipboard'; - hidePopover(); - }); - - // Start with the hostile sample once the demo scrolls into view - if ('IntersectionObserver' in window) { - const demoSection = document.getElementById('demo'); - if (demoSection) { - let started = false; - const startObs = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting && !started) { - started = true; - const firstPill = document.querySelector('.demo-pill[data-sample="hostile"]'); - firstPill?.classList.add('is-active'); - playSample('hostile'); - startObs.disconnect(); - } - }); - }, { threshold: 0.35 }); - startObs.observe(demoSection); - } - } else { - playSample('hostile'); - } - - /* ---------- Stat counters ---------- */ - const statNums = document.querySelectorAll('.stat-num'); - const animateStat = (el) => { - const target = parseInt(el.dataset.target || '0', 10); - if (prefersReducedMotion || target === 0) { - el.textContent = String(target); - return; - } - const duration = 1100; - const start = performance.now(); - const tick = (now) => { - const t = Math.min(1, (now - start) / duration); - const eased = 1 - Math.pow(1 - t, 3); - el.textContent = Math.round(target * eased).toString(); - if (t < 1) requestAnimationFrame(tick); - }; - requestAnimationFrame(tick); - }; - if ('IntersectionObserver' in window && statNums.length) { - const so = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - animateStat(entry.target); - so.unobserve(entry.target); - } - }); - }, { threshold: 0.4 }); - statNums.forEach((el) => so.observe(el)); - } else { - statNums.forEach(animateStat); - } -})(); diff --git a/privacy.html b/privacy.html index 73caeec..6b6bafa 100644 --- a/privacy.html +++ b/privacy.html @@ -4,7 +4,7 @@ Privacy | Halen - + @@ -80,7 +80,11 @@ line-height: 1.08; margin: 0 0 8px; } .subtitle { - color: var(--muted); font-size: 16px; margin: 0 0 48px; + color: var(--muted); font-size: 16px; margin: 0 0 8px; +} +.updated { + color: var(--muted); font-size: 13px; margin: 0 0 44px; + font-family: var(--mono); } h2 { font-size: 22px; font-weight: 700; letter-spacing: -0.015em; @@ -181,10 +185,11 @@

Privacy

-

What Halen sees, what stays on your Mac, and the exact set of outbound connections (all loopback).

+

What Halen sees, what stays on your Mac, and the only connection that ever leaves it.

+

Last updated 9 June 2026 · applies to Halen v0.3.0

- Halen is built around one constraint: nothing leaves the machine. This page documents what data the app sees, where it’s processed, and the few egress points that do exist (all to localhost). + Halen is built around one constraint: nothing you write leaves the machine. Every model runs on-device. The single outbound connection is the signed software-update check documented below — never your text.

What Halen sees

@@ -217,14 +222,23 @@

What stays local

Network traffic

-

Halen makes only loopback connections. Three possible paths, all to 127.0.0.1:

+

No inference ever leaves your Mac. The text-processing paths are loopback only — three of them, all to 127.0.0.1:

  • Apple Foundation Models: runs in-process via Apple’s on-device API (macOS 26+). No socket at all.
  • Bundled llama.cpp + Gemma 4 E4B: also in-process. No socket.
  • Ollama (optional): HTTP POST to http://localhost:11434/api/chat. The base URL is set in code; there is no setting that would let a process change it to a remote host. Ollama itself binds to 127.0.0.1 by default.

That traffic does not leave the loopback interface unless you have separately configured Ollama to bind to a non-localhost address.

-

There is no other outbound network code in the project. No analytics SDK, no remote logging endpoint, no auto-updater, no crash reporter. The optional WebSocket bridge on 127.0.0.1:50765 only accepts inbound connections from the local browser extension; it never dials out.

+

Aside from the software-update check described next, there is no other outbound network code in the project. No analytics SDK, no remote logging endpoint, no crash reporter. The optional WebSocket bridge on 127.0.0.1:50765 only accepts inbound connections from the local browser extension; it never dials out.

+ +

Software updates

+

Halen keeps itself up to date with Sparkle, the standard open-source updater for Mac apps. This is the one connection that does leave your Mac, and it carries none of your content:

+
    +
  • The check: a plain HTTPS GET of https://halen.dev/appcast.xml — a static, public file listing the latest version. It runs once a day and whenever you click Check for Updates in Settings.
  • +
  • The download: if a newer version exists, the signed .dmg is fetched from GitHub Releases (github.com). Every update is verified against a pinned EdDSA public key before it’s installed, so a tampered or man-in-the-middle payload is rejected.
  • +
  • What’s sent: nothing beyond an ordinary web request. No account, no device identifier, no usage data — just the standard headers (including your IP, as with any HTTPS request) needed to fetch a file. The request body is empty.
  • +
+

Automatic checks are on by default. You can turn them off from the “Automatically check for updates” toggle in Sparkle’s update dialog, after which Halen makes zero non-loopback connections.

Apple on-device speech recognition

Voice Dictation uses SFSpeechRecognizer with:

From f6ef310d53f03c1202ea86eedd970636979f4d73 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:53:18 +0000 Subject: [PATCH 3/4] Docs: fix privacy accuracy, stale stats, model-name consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wiki + README accuracy pass. Privacy (docs/wiki/privacy.md) — same fix already applied to the website: the page claimed egress "all hit localhost" and "no auto-updater", but Halen ships Sparkle (daily check of halen.dev/appcast.xml, signed DMGs from GitHub Releases). - Intro now scopes the promise to "nothing you write leaves the machine" and names the two content-free internet requests. - New "Software-update check" bullet documenting Sparkle (static appcast, EdDSA-verified DMG, empty request body, how to disable). - Drop the false "no auto-updater" claim. - Notifications row: add Ask Halen (clipboard-fallback alerts), which the table omitted. README: - "small bundled model" → "small local model … downloaded once on first use", reconciling the contradiction with the existing "Model weights aren't bundled; they download from Hugging Face". architecture.md: - Stale stats: ~13k → ~20k lines, 168 → 169 tests (verified). - Clarify that the ten in-process plugin classes back the six marketplace entries after the v0.3 merges. getting-started.md: - Notifications section was titled "Meeting Prep only"; Ask Halen and the ;reply drafter also post fallback notifications. index.html: - Revert last commit's swarm tag "gemma4:27b" → "gemma4:26b" to match the code-true Ollama large-tier tag (OllamaInferenceClient maps large → gemma4:26b; getting-started's `ollama pull` uses 26b too). --- README.md | 2 +- docs/wiki/architecture.md | 11 ++++++-- docs/wiki/getting-started.md | 6 +++-- docs/wiki/privacy.md | 52 +++++++++++++++++++++++------------- index.html | 2 +- 5 files changed, 48 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e9e586e..5507976 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Cloud writing tools see everything you type. Halen doesn't. Three reasons that m Halen picks the best available model on your Mac, automatically: - **Apple Intelligence** if you have it (macOS 26+, supported Macs). -- A small bundled model (Gemma 4 E4B + Qwen 2.5 for classification) if you don't. +- A small local model — Gemma 4 E4B, plus Qwen 2.5 for classification — downloaded once on first use, if you don't. - Your own [Ollama](https://ollama.com) daemon, if you've installed one. Nothing to configure. The model picker in Settings is there if you want to. diff --git a/docs/wiki/architecture.md b/docs/wiki/architecture.md index 865fe7b..d42b765 100644 --- a/docs/wiki/architecture.md +++ b/docs/wiki/architecture.md @@ -4,8 +4,8 @@ One AX pipeline. One event bus. One inference router. Many plugins. That's the whole shape. A single Swift Package menubar app (`LSUIElement = true`) hosts the first-party plugins in-process and -spawns third-party plugins as JSON-RPC subprocesses over stdio. ~13k -lines of Swift, 168 unit tests under `Tests/HalenTests/`. Everything +spawns third-party plugins as JSON-RPC subprocesses over stdio. ~20k +lines of Swift, 169 unit tests under `Tests/HalenTests/`. Everything flows through three seams: AX → events → inference. Plugins plug into the middle one. @@ -102,6 +102,13 @@ Ten in-process plugins ship as Swift classes wired into `stop()` on toggle. Default-off plugins (Voice, Autocomplete, StyleGuide, EmailReply, ToneProfiles) opt in via onboarding. +These ten classes back the **six** entries you see in the marketplace — +the v0.3 merges fold `TypoFixer` + `StyleGuide` into Word Replacements, +`SentimentGuard` + `ClarityChecker` into Writing Coach, and `EmailReply` +into Snippet Expander; `ToneProfiles` moved out of the marketplace into +Settings. The class names (and the on-disk plugin ids) keep their +pre-merge form so existing user data carries over without migration. + Out-of-process plugins — `BurnoutCopilot` and `MeetingPrep` ship in this repo under `plugins/`; users can also drop their own into `~/Library/Application Support/Halen/Plugins/` — are registered alongside diff --git a/docs/wiki/getting-started.md b/docs/wiki/getting-started.md index 05c01af..45a9666 100644 --- a/docs/wiki/getting-started.md +++ b/docs/wiki/getting-started.md @@ -149,11 +149,13 @@ Usage strings (`NSCalendarsUsageDescription`, `NSCalendarsFullAccessUsageDescrip Burnout Copilot also writes a single 10-minute "🌿 Halen break" event when you accept its suggestion — that requires the full-access scope. -### 5. Notifications (Meeting Prep only) +### 5. Notifications (Meeting Prep, Ask Halen, Email Reply) `UNUserNotificationCenter.requestAuthorization(options: [.alert, .sound])`. Meeting Prep posts one notification 1 second after a briefing lands on your -clipboard. If you deny, the clipboard part still works. +clipboard; Ask Halen and the `;reply` email drafter post one when a result +falls back to the clipboard because the caret target couldn't be written. If +you deny, the clipboard copy still happens silently. ### 6. Input Monitoring (Ask Halen + Snippet Expander rephrase) diff --git a/docs/wiki/privacy.md b/docs/wiki/privacy.md index 63d6e04..e03eb0e 100644 --- a/docs/wiki/privacy.md +++ b/docs/wiki/privacy.md @@ -1,15 +1,18 @@ # Privacy -One constraint. Nothing leaves the machine. +One constraint. Nothing you write leaves the machine. Not the text near your cursor. Not your drafts. Not your voice. Not a hashed fingerprint of any of it. Not an anonymous telemetry ping. Nothing. -The few egress points that do exist all hit localhost — the local -inference backend you chose, or your local Ollama daemon if you -opted in. This page documents every one of them, the per-plugin -boundaries, and the on-disk files Halen writes. +Inference runs on localhost — the backend you chose, or your local +Ollama daemon if you opted in. Exactly two requests ever reach the +public internet, and neither carries any of your content: a +once-a-day software-update check (Sparkle), and a one-time model +download if you opt into the bundled Gemma model. This page documents +every one of them, the per-plugin boundaries, and the on-disk files +Halen writes. ## What Halen sees @@ -96,21 +99,32 @@ a remote process; only you can point it elsewhere. The request body contains: With the default endpoint, that traffic does not leave the loopback interface unless you have configured Ollama itself to bind to a non-localhost address. -Two more outbound paths exist, both user-initiated and neither carrying any of -your text: - -- **Bundled-model download.** If you choose to download the bundled Gemma 4 - model from Settings → Inference (instead of using Apple Intelligence or a - `BUNDLE_MODEL=1` build), `ModelDownloader` fetches a single GGUF file from - Hugging Face (`huggingface.co/unsloth/gemma-4-E4B-it-GGUF`). This is a - one-time file transfer, not telemetry. +A few more connections exist. **None of them carries any of your text, voice, +or calendar data:** + +- **Software-update check.** Halen updates itself with + [Sparkle](https://sparkle-project.org), the standard open-source updater for + Mac apps. Once a day — and whenever you click *Check for Updates* in Settings + — it fetches the static appcast at `https://halen.dev/appcast.xml`. If a newer + build exists, the signed `.dmg` is downloaded from GitHub Releases and verified + against a pinned **EdDSA public key** before it installs. The request body is + empty: no account, no device id, no usage data — just the standard headers + (including your IP, as with any HTTPS request) needed to fetch a file. You can + turn automatic checks off from the "Automatically check for updates" toggle in + Sparkle's update dialog. +- **Bundled-model download.** If you choose to download the Gemma 4 model from + Settings → Inference (instead of using Apple Intelligence or a `BUNDLE_MODEL=1` + build), `ModelDownloader` fetches a single GGUF file from Hugging Face + (`huggingface.co/unsloth/gemma-4-E4B-it-GGUF`). A one-time file transfer, not + telemetry. - **Browser extension bridge.** If you enable the WebSocket bridge for the - optional browser extension, Halen listens on `127.0.0.1:50765` (loopback - only) so the extension can forward typing events from browser text fields. + optional browser extension, Halen listens on `127.0.0.1:50765` (loopback only) + so the extension can forward typing events from browser text fields. It only + *accepts* inbound connections; it never dials out. -There is **no analytics SDK, no remote logging endpoint, no auto-updater, and -no crash reporter** anywhere in the project. Nothing about your text, voice, -or calendar is ever uploaded. +Apart from the update check above, there is **no other outbound network code** +in the project: no analytics SDK, no remote logging endpoint, no crash reporter. +Nothing about your text, voice, or calendar is ever uploaded. ## Apple on-device speech recognition @@ -163,7 +177,7 @@ Nothing is uploaded. | Microphone | Voice Dictation | Capture audio for SFSpeechRecognizer | | Speech Recognition | Voice Dictation | Convert audio to text **on-device** | | Calendars (full access) | Burnout Copilot, Meeting Prep | Read events; Burnout writes the `🌿 Halen break` event | -| Notifications | Meeting Prep | Post the "briefing ready" alert | +| Notifications | Meeting Prep, Ask Halen | Briefing-ready alerts, and clipboard-fallback notices when a result can't be inserted at the caret | | Input Monitoring | Ask Halen, Snippet Expander | Match the ⌃H and ⌃⌥R hotkeys system-wide — only those hotkeys, no other keystrokes | You can deny any of these and the host continues to run. The dependent diff --git a/index.html b/index.html index 80303d6..706c498 100644 --- a/index.html +++ b/index.html @@ -1712,7 +1712,7 @@

Trusted by people who write all day on a Mac.

models: [ { text: 'gemma4:e4b', kind: 'dark' }, { text: 'gemma4:e2b', kind: 'dark' }, - { text: 'gemma4:27b', kind: 'dark' }, + { text: 'gemma4:26b', kind: 'dark' }, { text: 'ollama', kind: 'dark' }, { text: 'on disk', kind: 'tag' }, { text: 'localhost:11434', kind: 'tag' }, From 2c89b18e4ced6e9d4e6b210ef8e54f29928461b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:57:40 +0000 Subject: [PATCH 4/4] =?UTF-8?q?Website:=20Gemma=204=20"27B"=20=E2=86=92=20?= =?UTF-8?q?"26B"=20to=20match=20the=20code=20and=20the=20real=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CTA model card listed "Gemma 4 27B" — the only 27B reference in the repo. Verified against Google's Gemma 4 release (E2B / E4B / 26B MoE / 31B Dense): there is no 27B. The code's large tier is the 26B MoE (OllamaInferenceClient → gemma4:26b; ModelTier → google/gemma-4-26B-A4B-it, the variant that activates ~4B params per token). Aligns the card with the code and every other surface, all of which already say 26b. --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 706c498..43dad3f 100644 --- a/index.html +++ b/index.html @@ -1534,7 +1534,7 @@

Tools for the cursor.

Gemma 4 E2B
Gemma 4 E4B
-
Gemma 4 27B
+
Gemma 4 26B