Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 13 additions & 20 deletions Sources/Halen/Features/AskHalen/AskHalen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -515,31 +515,32 @@ 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)
}
}

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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
}
1 change: 1 addition & 0 deletions Sources/Halen/Features/AskHalen/AskHalenPalette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Halen/Plugins/Store/PluginStoreView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions docs/wiki/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/wiki/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
52 changes: 33 additions & 19 deletions docs/wiki/privacy.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
22 changes: 11 additions & 11 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1348,7 +1348,7 @@ <h1 aria-label="Writing AI that never leaves your Mac.">
<p class="st">No accounts.</p>
</div>

<p class="st">Halen sits in your menu bar, near your <span class="kw" data-swarm="caret">cursor</span>. The models never leave your Mac.</p>
<p class="st">Halen lives in your menu bar, near your <span class="kw" data-swarm="caret">cursor</span>. The models never leave your Mac.</p>

<div class="stanza">
<p class="st">Your words stay yours.</p>
Expand All @@ -1361,7 +1361,7 @@ <h1 aria-label="Writing AI that never leaves your Mac.">
<!-- ============ DEMO ============ -->
<section class="demo" id="demo">
<div class="demo-head">
<h2>Plugins for the cursor.</h2>
<h2>Tools for the cursor.</h2>
<p>Pick one. Watch what it does.</p>
</div>

Expand All @@ -1375,7 +1375,7 @@ <h2>Plugins for the cursor.</h2>
<span class="bar"></span>
</button>
<button class="snip" data-demo="style">
<div class="row1"><span class="key sty">Word Replacements</span><span class="ms">instant</span></div>
<div class="row1"><span class="key sty">Banned Words</span><span class="ms">instant</span></div>
<div class="label">Your banned words, swapped</div>
<div class="desc">Add &ldquo;utilize&rdquo; once. Halen offers &ldquo;use&rdquo; every time after. No model call.</div>
<span class="bar"></span>
Expand All @@ -1389,7 +1389,7 @@ <h2>Plugins for the cursor.</h2>
<button class="snip" data-demo="sentiment">
<div class="row1"><span class="key sg">Writing Coach</span><span class="ms">~120 ms</span></div>
<div class="label">Read for tone before you send</div>
<div class="desc">A small local classifier checks each sentence. Pops up if the tone trips a rule.</div>
<div class="desc">A small local model checks each sentence. Pops up if the tone trips a rule.</div>
<span class="bar"></span>
</button>
</div>
Expand Down Expand Up @@ -1465,15 +1465,15 @@ <h2>Plugins for the cursor.</h2>
<img class="mark" src="assets/halen-icon.svg" alt="" />
<div class="msg">
<span class="l" id="popL">HALEN · matched</span>
<span class="v" id="popV">expanding ;polite via gemma4:e4b</span>
<span class="v" id="popV">expanding ;polite via Gemma 4 E4B</span>
</div>
<span class="kbd">⏎</span>
</div>
<div class="guard-card" id="guard">
<span class="icon">!</span>
<div class="txt">
<strong>This reads as <span>hostile</span></strong>
<em>gemma4:e4b · 100% local · 118 ms</em>
<em>Gemma 4 E4B · 100% local · 118 ms</em>
</div>
<div class="actions">
<button id="guardSend">Send anyway</button>
Expand Down Expand Up @@ -1534,7 +1534,7 @@ <h2>Plugins for the cursor.</h2>
<div class="cc-radio">
<div class="active"><span class="dot"></span>Gemma 4 E2B</div>
<div><span class="dot"></span>Gemma 4 E4B</div>
<div><span class="dot"></span>Gemma 4 27B</div>
<div><span class="dot"></span>Gemma 4 26B</div>
</div>
</div>
</div>
Expand Down Expand Up @@ -1612,7 +1612,7 @@ <h2>At your cursor.<br/><span class="quiet">All on-device.</span></h2>
</a>
<span class="dl-specs">
<span class="chip">macOS 14+</span>
<span class="chip">Apple Silicon only</span>
<span class="chip">Apple Silicon native</span>
<span class="chip">Free · MIT</span>
</span>
</div>
Expand Down Expand Up @@ -2125,7 +2125,7 @@ <h2>Trusted by people who write all day on a Mac.</h2>
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
Expand Down Expand Up @@ -2339,7 +2339,7 @@ <h2>Trusted by people who write all day on a Mac.</h2>
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');
Expand All @@ -2348,7 +2348,7 @@ <h2>Trusted by people who write all day on a Mac.</h2>
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';
Expand Down
Loading
Loading