Compare commits

..
Author SHA1 Message Date
James Maes 7c4bb248d5 docs: replace remaining Mermaid text diagrams with PlantUML PNG images 2026-07-23 17:09:16 -05:00
James Maes 29b6cc9d01 docs: replace Mermaid text diagram with PlantUML PNG image 2026-07-23 17:03:30 -05:00
james.maesandClaude Opus 4.7 034a115009 docs(superpowers): local-first LLM routing design spec (#197)
* docs(superpowers): local-first LLM routing design spec

Flip default chat-turn provider from Anthropic to local Ollama with
reactive escalation to Opus on detectable failure. Per-turn escalation
budget, clean-restart UX with visible badge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(superpowers): local-first LLM routing implementation plan

10-task plan with TDD steps, exact file paths, and code blocks for each
step. Self-reviewed for spec coverage and type consistency. Ready for
subagent-driven execution.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 16:18:54 -05:00
james.maesandClaude Opus 4.7 5ebe510627 docs: scrub Phase-7 + JarvisBusWorld doc drift (#19, #45) (#195)
* docs(memory): scrub Phase-7 hedge language from MemoryStore (#19)

Audit 2026-05-12 flagged five doc-comment references that mis-described
the implementation state. The cited plans (07-02, 07-03, 07-06) shipped
months ago — the bounded extraction channel, the hybrid FTS5 + vec0 read
path, the ReplayEvent emission, the regression corpus test seams, and
`installMemory`'s replay-sink wiring are all live, not "wires" or
"will replace" promises.

Five touchpoints rewritten to describe the code as it is:

- `MemoryReplaySink` doc: drop the "Plan 07-06" plan reference; the seam
  is just used at startup.
- `MemoryStore` actor doc: replace the "Plan 07-02 wires / Plan 07-03
  wires" sentence with the actual contract (single writer; hybrid search
  through `MemoryReadStore`; `recordRetrieval` emits `memoryRetrieval`).
- Read-path MARK: drop the "Plan 07-03" plan label; keep the
  requirement-ID list (MEM-07 / TEXT-03 / D-02 / D-05) since those are
  still useful for traceability to the audit corpus.
- `recordRetrieval` doc: replace "Mirrors recordMemoryMutation from 07-02"
  with "Mirrors recordMemoryMutation on the write path."
- Test-seams MARK: drop "Plan 07-06" prefix; describe by the test target
  (JARVIS_REAL_MODELS=1 corpus suite) instead.

The 2026-05-11 D-5/D-6 markers and the "Boot policy (changed 2026-05-11)"
doc are preserved unchanged — they document current behaviour, not
historical hedges.

`swift build --package-path packages/Memory` clean.

Closes #19

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(bus): scrub JarvisBusWorld drift + add grep gate (#45)

`fb41c5f` (2026-05-02) unified `Injection.js` and the HUD bundle into the
default `WKContentWorld.page` and retired the prior isolated
`JarvisBusWorld` named world. The doc-comments and test fixtures across
20+ lines kept describing the bug-era topology — pre-existing-bus attach
"because cross-world isolation", message-handler-only-visible-in-named-
world, etc. — for months afterward. Result: a new reader following the
docs would conclude the bus uses two worlds when it actually uses one.

Five rationale rewrites:

- `WebviewBridge.swift` class doc — handler is on the page world, not an
  isolated named world. Page scripts in the loaded HUD bundle address it
  via `window.webkit.messageHandlers.jarvisBus`.
- `Injection.js` header — names the page world explicitly; one-line nod to
  `fb41c5f` for the historical context.
- `bridge.ts` JSDoc + `isJarvisBusShape` rationale — recast the H-02
  attach-to-existing path as a "defensive double-install guard" inside
  ONE page world. Injection.js fires first at document-start; the bundle
  installer fires later as a top-level `main.tsx` side-effect; the guard
  prevents the bundle from clobbering Injection.js's queued early
  messages and captured handshake state. The duck-type `isJarvisBusShape`
  CODE is unchanged — only the comment narrative was misleading.
- `webview/packages/hud/src/main.tsx` — drop the cross-world-isolation
  framing; describe the single-world reality.
- `App/AppDelegate.swift` install comment — page world, not isolated.

Test-side updates (same scrub, plus one latent test bug surfaced):

- `WebviewBridgeTests.test_scriptMessageHandlerIsRegisteredUnderConfigured-
  Name`: was removing from `WKContentWorld.world(name: "JarvisBusWorld")`
  while the bridge registered against `.page` — silent no-op since
  `removeScriptMessageHandler` doesn't throw on absent. Now removes from
  `.page` (the world the bridge actually uses), making the
  "registration-is-evidence-of-success" assertion meaningful again.
- `WebviewBridgeOutboundTests`: comment-only.
- `RealWKWebViewIntegrationTests`: comment-only (forensic narrative kept,
  literal world name replaced with "prior isolated named content world").
- `webview/packages/bus/tests/bridge.test.ts`: H-02 describe-block + one
  it-name rewritten to match the new defensive-double-install rationale.

Infrastructure:

- `scripts/check-no-jarvis-bus-world-references.sh`: new boundary gate.
  Greps for the literal `JarvisBusWorld` in tracked files; excludes
  `.planning/`, `docs/`, `webview/packages/*/dist/`, and this script
  itself. Wired into `CLAUDE.md`'s gate listing; the CI
  `boundary-gates` job auto-discovers all `scripts/check-*.sh`, so no
  workflow edit is needed.
- `webview/packages/bus/package.json`: 0.0.0 → 0.0.1 (per spec —
  comment-only changes still bump patch so consumers can pin).
- `webview/packages/bus/dist/bridge.{js,d.ts}`: rebuilt via `pnpm --filter
  @jarvis/bus build`; NOT committed because `dist/` is not tracked (the
  build phase regenerates on each xcodebuild invocation).

Verified locally:
- `swift build --package-path packages/Bus` clean
- `pnpm --filter @jarvis/bus test` — 37/37 pass
- `bash scripts/check-no-jarvis-bus-world-references.sh` — PASS
- `bash scripts/check-bus-protocol-version.sh` — PASS (v2.3.0)
- `bash scripts/check-bus-harness-parity.sh` — PASS

Closes #45

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:51:03 -05:00
james.maesandClaude Opus 4.7 a121d79174 fix: STT backend constant casing + audioLevel NaN guard (#37, #47) (#196)
* docs(voice): pin STT backend constant identity contract (#37)

The runtime mismatch flagged by #37 was already fixed at the call sites
in `App/AppDelegate.swift:1631-1642` and `:2562-2575` — both routes now
reference `STTBackendSelector.backendSpeechAnalyzer` /
`backendWhisperKit` (snake_case) instead of the pre-2026-05-12 camelCase
string literals. The regression test
`STTBackendSwitchTests.testB5_canonicalConstantsRoundTripThroughMake`
already locks both halves of the contract:

  1. The snake_case constants round-trip through `make(backend:)` without
     hitting the unknown-backend default branch.
  2. The pre-fix camelCase shape (`"whisperKit"`) DOES land in the
     default branch and DOES emit an unknown-backend warning.

So the #37 close action is just to make that contract self-documenting
at the declaration site rather than relying on readers finding the test.
Add a doc-comment to the two `public static let backend*` constants that
explains the wire shape, names the regression test, references Issues
#29 + #37, and warns against renaming without updating `PerTurnSnapshot`
and the test in lockstep.

No code change; `swift test --package-path packages/Voice` continues to
pass (26 tests).

Closes #37

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(bus): clamp non-finite audioLevel rms to 0.0 at encode (#47)

`BusOutbound.audioLevel` carries a `Float rms` that `AudioLevelEmitter`
produces per audio frame (~100 Hz once the consumer is wired up).
Foundation's `JSONEncoder.NonConformingFloatEncodingStrategy` defaults
to `.throw`, so a single NaN — easy to land via a CoreAudio
over-/underflow or an all-zero buffer fed through certain RMS shapes —
would throw inside `WebviewBridge.sendRaw` and tear down the per-frame
bus path. The HUD-side consumer in `webview/packages/hud/src/bus/
client.ts:135` is currently a no-op (the ring shader is still on the
synthetic-sine animation from Plan 03-03), so the bug is LATENT, not
actively firing — but the clamp is cheap, defensive, and pins the
contract before the consumer activates.

Inline clamp at the encode site (`BusOutbound.encode(to:)` switch arm
for `.audioLevel`): replace non-finite floats with `0.0` via
`rms.isFinite ? rms : 0.0` immediately before `container.encode(_:)`.
No refactor of `AudioLevelEmitter` — the per-spec call here is
defensive at the serialization boundary, where every encoder-level
overflow path converges.

Two new unit tests in `CodableRoundTripTests`:
- `test_audioLevel_nanIsClampedToZero` — encodes `.audioLevel(rms: .nan)`,
  decodes, asserts `rms == 0.0` and `rms.isFinite`.
- `test_audioLevel_infinityIsClampedToZero` — companion for ±∞ so a
  future refactor cannot narrow the guard to NaN-only.

`swift test --package-path packages/Bus --filter CodableRoundTripTests`
passes (30 tests, +2 from this commit).

Closes #47

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:50:50 -05:00
james.maes 31f0a8289a docs(handoff): multi-runner shipped + v0.2 wave plan (#192)
Captures the multi-runner expansion (Grogu-jarvis, -2, -3 live),
auto-Release flow proof (v0.1.0-dev.64), Superpowers process
established, and Memory test flake disposition. Lists v0.2 Wave 1
candidates (#19, #45, #46, #37, #178, #47) for next session pickup.

Sibling to docs/HANDOFF-2026-05-13-v0.1-shipped.md.

Refs #180 #188
2026-05-14 09:48:26 -05:00
james.maesandClaude Opus 4.7 439e1ee2f5 ci: build hygiene — webview cleanup + OpenSSF Scorecard (#46, #178) (#194)
* ci(build-webview): sweep stale hashed chunks before rsync (#46)

Vite emits content-hashed asset names (e.g. `index-CXPb0I5z.js`), so the
active bundle filename rotates on every source/dependency edit. The
existing rsync runs without `--delete` (to preserve `bus-harness.html`
and `.gitignore`), so old chunks accumulated in
`App/Resources/webview/assets/` — observed 9 stale `index-*.js` /
`.js.map` pairs locally before this fix.

Add an explicit `rm -f` of the asset glob (JS + JS map + CSS + CSS map)
before the rsync drops the fresh bundle. The preserved files
(`bus-harness.html`, `index.html`, `.gitignore`) live one level up in
`$DEST`, not in `$DEST/assets`, so the rm cannot reach them.

Verified locally: `bash scripts/build-webview.sh` now leaves exactly one
`index-*.js` + `.js.map` in `assets/` (matching the reference in
`index.html`).

Refs #46

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ci(scorecard): add OpenSSF Scorecard workflow + README badge (#178)

Adds `.github/workflows/scorecard.yml` running `ossf/scorecard-action@v2.4.0`
on:
- Weekly cron (Mondays 06:00 UTC) for posture drift detection
- Push to develop for prompt regression visibility
- workflow_dispatch for ad-hoc runs

Permissions follow the canonical Scorecard set (`id-token: write`,
`contents: read`, `security-events: write`) with top-level `read-all` and
job-scoped writes. Results are published to the OpenSSF public dataset
(powers the README badge URL) and uploaded as SARIF to code-scanning.

The workflow consumes no untrusted user inputs in `run:` blocks, so it is
not exposed to workflow-injection patterns documented in
github.blog/security/.../how-to-catch-github-actions-workflow-injections.

README.md gains the standard Scorecard badge next to the existing
license/platform/swift badges.

Refs #178

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:29:48 -05:00
12 changed files with 1467 additions and 97 deletions

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
# OpenSSF Scorecard — security supply-chain posture for KofTwentyTwo/Jarvis.
#
# Issue #178. Scorecard scores 18 security checks (Branch-Protection, Code-Review,
# Pinned-Dependencies, SAST, Token-Permissions, etc.) and publishes results as
# SARIF to GitHub code-scanning. A weekly schedule keeps the score fresh; we
# also re-run on every push to develop so significant posture regressions show
# up promptly. The badge in README.md links back to the public scorecard page.
#
# Permissions are the canonical Scorecard set:
# - id-token: write OIDC for publish-results
# - contents: read read repo tree
# - security-events: write upload SARIF to code-scanning
#
# This workflow runs on github-hosted ubuntu-latest. Self-hosted Grogu is not
# used here -- Scorecard is short, network-bound, and benefits from a clean
# ephemeral environment.
#
# Workflow consumes no untrusted user inputs (no github.event.* interpolation
# in run: blocks), so it is safe from workflow-injection patterns.
name: OpenSSF Scorecard
on:
schedule:
# Mondays at 06:00 UTC. Off-peak relative to weekday push spikes.
- cron: "0 6 * * 1"
push:
branches: [develop]
workflow_dispatch:
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Run Scorecard
uses: ossf/scorecard-action@v2.4.0
with:
results_file: results.sarif
results_format: sarif
# publish_results: true uploads anonymized scores to the OpenSSF
# public dataset, which is what powers the README badge URL
# (api.scorecard.dev). Safe for a public repo; would need
# reconsidering if the repo were ever made private.
publish_results: true
- name: Upload SARIF to code-scanning
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
+5 -96
View File
@@ -8,6 +8,7 @@
[![Issues](https://img.shields.io/github/issues/KofTwentyTwo/Jarvis)](https://github.com/KofTwentyTwo/Jarvis/issues)
[![Platform: macOS 26](https://img.shields.io/badge/platform-macOS%2026%20Tahoe-blue)](https://www.apple.com/macos/)
[![Swift 6](https://img.shields.io/badge/swift-6.0-orange)](https://swift.org)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/KofTwentyTwo/Jarvis/badge)](https://scorecard.dev/viewer/?uri=github.com/KofTwentyTwo/Jarvis)
Jarvis is a hybrid native + embedded-web macOS app that runs as an ambient
menu-bar presence. A Swift host owns the operating system (mic, camera,
@@ -53,46 +54,7 @@ view, the Bus is the only legal way they talk. Subsystems live in
single-purpose SPM packages; **18 boundary-gate scripts** keep the
isolation honest.
```mermaid
flowchart TB
User[User<br/>voice / text / hotkey]
subgraph Swift["Swift host (AppKit + SwiftUI)"]
AppDel[AppDelegate<br/>install DAG + lifecycle]
Orch[AgentOrchestrator<br/>tool-loop + streaming]
Bridge[WebviewBridge<br/>typed JSON Bus]
Voice[Voice<br/>wake / STT / TTS]
Vision[Vision<br/>camera + frame-attach]
Memory[Memory<br/>SQLite + vec + mem0]
MCP[MCPRuntime<br/>helpers + in-proc tools]
Replay[Replay<br/>turn-by-turn audit log]
Boot[BootHealth<br/>NOT FAKED probes]
end
LLM["LLMProvider<br/>Anthropic Opus 4.7 / Ollama"]
subgraph Web["WKWebView (React + R3F)"]
Ring[Particle ring]
Chat[Chat panel]
DevOv[Dev Overlay]
end
User --> Voice
User --> Bridge
AppDel --> Vision
AppDel --> Orch
AppDel --> Voice
AppDel --> Boot
Voice --> Orch
Orch <--> LLM
Orch --> MCP
Orch --> Memory
Orch --> Vision
Orch --> Replay
Orch --> Bridge
Boot --> Bridge
Bridge <-->|Bus protocol| Web
```
![Jarvis 2-Process Architecture Topology](docs/architecture.png)
The deep architecture doc is [`ARCHITECTURE.md`](ARCHITECTURE.md) (subsystem
deep-dives, anti-patterns, navigation cheat sheet). The v1.0 typed-API
@@ -135,21 +97,7 @@ asserts the spawn-line ordering AND that `voiceInstallTask` awaits
`agentInstallTask?.value` (a strictly stronger guarantee than spawn-order
alone).
```mermaid
flowchart LR
Boot([applicationWillFinishLaunching]) --> Vision[installVision]
Boot --> Memory[installMemory]
Boot --> MCP[installMCP]
Boot --> Replay[installReplay]
Vision --> Agent[installAgent]
Memory --> Agent
MCP --> Agent
Replay --> Agent
Agent --> Voice[installVoice]
Voice --> Probes[registerBootHealthProbes]
Probes --> Bridge[Webview handshake]
Bridge --> Ready([HUD ready])
```
![Jarvis Install Order Sequence](docs/install_order.png)
## Build & run
@@ -215,21 +163,7 @@ subscribers that need it. If a subsystem is degraded, the orchestrator
**injects a system-prompt preamble** so the agent doesn't promise to
remember things the memory store can't actually persist.
```mermaid
flowchart LR
In[User input<br/>text / voice transcript] --> Orch[AgentOrchestrator]
Health[BootHealth.rollup] -->|degradation preamble| Orch
Orch --> Provider[LLMProvider.stream]
Provider --> Events[(streaming events<br/>token / thinking / tool-use / stop)]
Events --> Bcast[Broadcaster<br/>single consumer]
Bcast --> Tx[Transcript]
Bcast --> Mem[Memory extractor]
Bcast --> Dev[DevOverlay]
Bcast --> Voice[Voice / TTS]
Bcast --> Bus[WebviewBridge → HUD]
Orch --> Tools[MCP tool calls]
Tools --> Provider
```
![Jarvis Agent Turn Lifecycle](docs/turn_lifecycle.png)
## Boot-health probes
@@ -240,32 +174,7 @@ failures turn the menu-bar icon **red**, raise a non-dismissible HUD
banner, and inject the degradation preamble into the agent's system
prompt.
```mermaid
flowchart LR
M[MemoryBootHealthProbe<br/>vec0 + FTS5 reachable]
A[AnthropicBootHealthProbe<br/>keychain has API key]
O[OllamaBootHealthProbe<br/>service reachable + model loaded]
Vc[VoiceBootHealthProbe<br/>audio graph alive]
Vi[VisionBootHealthProbe<br/>AVCapture authorized]
Mc[MCPBootHealthProbe<br/>helper apps + in-proc tools]
R[ReplayBootHealthProbe<br/>WAL writable]
W[WebviewBootHealthProbe<br/>handshake completed]
Roll[BootHealthOrchestrator.rollup]
Icon[Menu-bar icon<br/>red on critical]
Banner[Non-dismissible banner]
Pre[Agent system-prompt<br/>degradation preamble]
M --> Roll
A --> Roll
O --> Roll
Vc --> Roll
Vi --> Roll
Mc --> Roll
R --> Roll
W --> Roll
Roll --> Icon
Roll --> Banner
Roll --> Pre
```
![Jarvis Boot-Health Probe Ladder](docs/boot_health.png)
## Project culture
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,151 @@
# Local-first LLM routing — design
**Status:** draft 2026-05-14, awaiting user approval
**Authors:** James + Claude (Opus 4.7)
**Scope:** flip the chat-turn provider default from Anthropic to local Ollama, with reactive escalation to Claude Opus on detectable Ollama failure.
## Goal
Default every conversation turn to local Ollama (`qwen2.5-coder:32b-instruct-q8_0`). Escalate to Anthropic Claude Opus 4.7 only when local fails in a way the runtime can detect. The user sees one coherent reply per turn, with a small badge if escalation fired.
## Why now
The codebase is already structurally ready:
- `LLMProvider` protocol is symmetric between Anthropic + Ollama (same `AsyncThrowingStream<LLMEvent>` shape, same `ToolChoice.none` semantics).
- `AgentOrchestrator` is already config-driven via `providerFactory(perTurn.resolvedProvider)` (line 311).
- A mid-conversation escalation hook already exists — line 793 retries on `streamTruncated` by calling `providerFactory(updated.resolvedProvider)`.
- Memory extraction is wired to Ollama separately and is not affected by chat-turn routing.
This is mostly a policy + UX change, not a new abstraction.
## Approach
Reactive fallback only. No predictive routing, no router-model classifier, no self-reported escalation tool. The orchestrator tries Ollama first; if it fails in one of an enumerated set of detectable ways, it retries the turn once on Anthropic, emits an escalation event for the HUD, and surfaces the answer to the user.
User picked this approach because:
1. The existing escalation hook is reactive — extending what's there beats inventing a new layer.
2. Reactive fallback produces evidence — every escalation is a real-world data point for any future predictive routing.
3. Zero added latency on the happy path; no extra LLM call to classify the turn.
## Spec
### 1. Default provider flip
- `default-config.json:9` changes from `"anthropic"` to `"ollama"`.
- `PerTurnSnapshot.provider` already exists as the policy carrier.
- Ollama target model is `qwen2.5-coder:32b-instruct-q8_0` (the tool-calling baseline pinned by `ModelID.qwen25coder32b`).
- Anthropic remains a first-class provider and remains selectable.
### 2. Escalation trigger taxonomy
The runtime fires escalation on, and only on, these detectable failure modes (closed enum `OllamaFailureKind`):
| Trigger | Detection |
|---|---|
| `streamTruncated` | Existing — already wired at `AgentOrchestrator.swift:793`. |
| `malformedToolCall` | `tool_calls` payload fails to decode against `ToolUseRequest`. Known Qwen 3.x footgun per CLAUDE.md. |
| `unknownTool` | Model invokes a tool name not in the registry. |
| `refusal` | `stopReason: .refusal` event arrives. |
| `connectionFailure` | URLSession error before any tokens received (Ollama down, port refused). |
| `emptyResponse` | `messageStop` with zero `textDelta` and zero `toolUseRequested`. |
Explicit non-triggers (do not escalate):
- Partial answer that "feels weak"
- Semantic hallucinations the runtime can't detect
- Tool that returned an error (tool failure ≠ model failure)
- Slow streaming (no SLA-based escalation)
These are quality problems the runtime cannot judge. Out of scope for this design.
### 3. Escalation contract
```swift
struct EscalationDecision: Sendable, Codable {
let from: ProviderSelection // .ollama
let to: ProviderSelection // .anthropic
let reason: OllamaFailureKind
let firedAt: Date
}
```
`AgentOrchestrator` enforces **at most one escalation per turn**. If the post-escalation Anthropic call also fails, the orchestrator surfaces the error to the user — it does not fall back further. (A second failure means the cloud provider is also down; pretending otherwise is dishonest.)
### 4. Bus event for the HUD badge
New outbound event:
```swift
case escalated(EscalationDecision)
```
Emitted once when fallback fires. HUD renders an unobtrusive badge inline with the affected message: "Escalated to Opus — Ollama returned malformed tool call" (or whichever `OllamaFailureKind` fired). The badge stays with that specific message; subsequent messages are visually clean.
The badge is HUD-side only — it does not affect the chat message content, persistence, or replay log shape.
### 5. Per-turn vs sticky
**Per-turn.** Next user message starts fresh on Ollama. Reasoning: one Ollama failure on a Python question doesn't mean the next "what time is it" should go to Opus.
User can manually pin a conversation to Anthropic via Settings. Manual pin overrides escalation logic; in that mode, no escalation badges fire because no escalation can fire.
### 6. Settings UX
Settings → Provider section gets one new control:
- **Provider preference:** `Local-first (recommended)` / `Anthropic only` / `Ollama only`
- `Local-first` is the new default; corresponds to `provider: "ollama"` plus escalation enabled.
- `Anthropic only` pins to Anthropic, escalation moot.
- `Ollama only` pins to Ollama AND disables escalation — Ollama failures surface as errors. Useful for hard-offline / privacy-strict mode.
The existing `default-config.json` `provider:` field continues to drive the runtime; new logic decides whether escalation is wired based on a sibling `escalationEnabled` field that defaults to `true` only when `provider == "ollama"`.
### 7. Testing
Three eval lanes added to the Harness package:
- **Reactive fallback fires correctly** — fixture: Ollama returns `malformedToolCall`; assert orchestrator switches to Anthropic mid-turn AND emits one `escalated` bus event.
- **Escalation budget honored** — fixture: Ollama fails AND Anthropic also fails on the same turn; assert orchestrator surfaces a structured error, does not loop, emits exactly one `escalated` event.
- **Pinned mode disables escalation** — config: `provider=ollama, escalationEnabled=false` + Ollama failure → surface error directly, zero `escalated` events.
No new fixture corpus needed; leverages existing `FixtureKind` cases in `MockLLMProvider`.
### 8. Non-goals
- No predictive routing.
- No "stream Anthropic continuing Ollama's partial output" — clean restart only.
- No automatic re-Ollama after Anthropic finishes — once escalated, the rest of the turn stays on Anthropic.
- No memory extraction changes; that pipeline is independent and stays on Ollama.
- No changes to non-chat surfaces (TTS, MCP, voice loop) — they have their own provider stories.
- No new GitHub issue label or milestone; this is a v0.5+ migration item that the user wants brought forward.
### 9. Effort estimate
Single PR, ~3-4 hours:
| Subtask | Estimate |
|---|---|
| `default-config.json` flip + Settings UI control | 30 min |
| Orchestrator escalation logic extension + budget tracking | 60 min |
| Bus event + HUD badge component | 60 min |
| Harness tests (3 lanes) | 60 min |
| Docs + comments | 15 min |
## Success criteria
- Default fresh-launch experience uses Ollama for chat turns.
- A turn that fails on Ollama via any `OllamaFailureKind` automatically retries on Anthropic and the user sees a single coherent reply with a badge.
- A turn that fails on both providers shows a structured error, not an infinite retry.
- A user with `Ollama only` mode sees the Ollama error directly with no escalation.
- All existing tests pass; three new escalation tests pass.
- Memory extraction is unaffected.
- The chat surface still streams tokens at acceptable latency (no perceptible regression).
## Open questions for the user
None blocking — design is concrete enough to implement. If anything below is wrong, push back before the spec is committed.
- Should the badge wording differ per `OllamaFailureKind`, or just say "Escalated to Opus"? Current design: differentiate, because the reason is small additional information that helps the user understand when Ollama is struggling.
- Should the orchestrator emit a single `bus.escalated` per turn, or stream per-attempt diagnostics? Current design: one per turn, keeps the bus contract simple.
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+14 -1
View File
@@ -159,7 +159,20 @@ extension BusOutbound: Codable {
try container.encode(text, forKey: .text)
case .audioLevel(let rms):
try container.encode(Discriminator.audioLevel, forKey: .type)
try container.encode(rms, forKey: .rms)
// Issue #47: JSON has no canonical encoding for `Float.nan` or
// `±Float.infinity`. Foundation's `JSONEncoder` defaults to
// throwing for non-finite floats (`JSONEncoder.NonConformingFloat-
// EncodingStrategy.throw`), which would crash the per-frame bus
// path the moment `AudioLevelEmitter` produces a NaN — easy to
// hit on a CoreAudio over-/underflow or a buffer of all-zero
// samples. Clamp non-finite to 0.0 inline at the encode site so
// the rms float in the wire JSON is always a finite, ring-shader-
// safe number. The HUD-side consumer (`webview/packages/hud/src/
// bus/client.ts`) currently no-ops on audioLevel; the clamp is
// defensive against the bug becoming active once the consumer is
// wired up.
let safe = rms.isFinite ? rms : 0.0
try container.encode(safe, forKey: .rms)
case .toolCallStart(let id, let name, let argsPreview):
try container.encode(Discriminator.toolCallStart, forKey: .type)
try container.encode(id.uuidString.lowercased(), forKey: .id)
@@ -123,6 +123,44 @@ final class CodableRoundTripTests: XCTestCase {
)
}
/// Issue #47: `BusOutbound.audioLevel` encodes a `Float`, and Foundation's
/// default `JSONEncoder.NonConformingFloatEncodingStrategy` is `.throw`.
/// A non-finite `rms` (NaN from a CoreAudio over-/underflow, or ±∞ from a
/// pathological emitter buffer) would crash the per-frame bus path inside
/// `WebviewBridge.sendRaw`. The encode site in `BusOutbound.swift` clamps
/// non-finite floats to `0.0`; this test pins the contract so the clamp
/// can't silently regress.
func test_audioLevel_nanIsClampedToZero() throws {
let encoded = try BusCoder.makeEncoder().encode(BusOutbound.audioLevel(rms: .nan))
let decoded = try BusCoder.makeDecoder().decode(BusOutbound.self, from: encoded)
guard case .audioLevel(let rms) = decoded else {
XCTFail("expected .audioLevel after round-trip, got \(decoded)")
return
}
XCTAssertEqual(rms, 0.0, "NaN rms must be clamped to 0.0 at encode time (#47)")
XCTAssertTrue(rms.isFinite, "post-clamp rms must be finite")
}
/// Companion to `test_audioLevel_nanIsClampedToZero` — covers the ±∞
/// edge cases. CoreAudio drivers don't normally emit infinities, but the
/// encode-time clamp is sourced from `Float.isFinite`, which covers both
/// NaN and ±∞ via one check; locking the contract here prevents a future
/// refactor from narrowing the guard to NaN-only.
func test_audioLevel_infinityIsClampedToZero() throws {
let encoder = BusCoder.makeEncoder()
let decoder = BusCoder.makeDecoder()
for value in [Float.infinity, -Float.infinity] {
let encoded = try encoder.encode(BusOutbound.audioLevel(rms: value))
let decoded = try decoder.decode(BusOutbound.self, from: encoded)
guard case .audioLevel(let rms) = decoded else {
XCTFail("expected .audioLevel for \(value)")
continue
}
XCTAssertEqual(rms, 0.0, "±∞ rms (\(value)) must be clamped to 0.0 (#47)")
}
}
func test_roundTrip_toolCallStart() throws {
let id = UUID(uuidString: "550e8400-e29b-41d4-a716-446655440000")!
try assertOutboundRoundTrips(
@@ -28,6 +28,16 @@ public protocol STTWarningLogger: Sendable {
public enum STTBackendSelector {
// MARK: - Valid backend strings
//
// Canonical backend identifiers. Snake_case is the wire shape stored in
// `PerTurnSnapshot.stt.backend`. Call sites (`AppDelegate.sttBackend`)
// SHOULD reference these constants rather than the bare string literals;
// `STTBackendSwitchTests.testB5_canonicalConstantsRoundTripThroughMake`
// locks the round-trip contract by asserting both that the constants
// map to the right `STTProvider` AND that camelCase string literals
// (the pre-2026-05-12 anti-pattern from Issue #29 / #37) fall through
// to the default branch with a warning. Do not rename without updating
// `PerTurnSnapshot` and the regression test in lockstep.
public static let backendSpeechAnalyzer = "speech_analyzer"
public static let backendWhisperKit = "whisperkit"
+11
View File
@@ -77,6 +77,16 @@ pnpm --filter @jarvis/hud build
DEST="$REPO_ROOT/App/Resources/webview"
mkdir -p "$DEST"
mkdir -p "$DEST/assets"
# Issue #46: Vite emits content-hashed asset names (e.g. `index-CXPb0I5z.js`),
# so the active bundle filename changes on every dependency or source edit.
# Without explicit cleanup, stale chunks accumulate in `assets/` because we
# don't pass `--delete` to rsync below. Sweep the asset glob (JS + maps + CSS)
# before the rsync drops the fresh bundle. `bus-harness.html`, `index.html`,
# and the directory's `.gitignore` live one level up (in $DEST), not in
# $DEST/assets, so they are not at risk from this rm.
rm -f "$DEST"/assets/*.js "$DEST"/assets/*.js.map "$DEST"/assets/*.css "$DEST"/assets/*.css.map
echo "[build-webview] rsync dist/ -> $DEST"
# --exclude bus-harness.html: keep 02-03's committed copy intact (parity
@@ -85,6 +95,7 @@ echo "[build-webview] rsync dist/ -> $DEST"
# --exclude .gitignore: preserve the App/Resources/webview/.gitignore that
# lets index.html stay source-committed while assets/ regenerates.
# We do NOT pass --delete: other vendored assets coexist in this dir.
# (The explicit `rm -f` above handles stale `assets/` chunks; see #46.)
rsync -a \
--exclude=bus-harness.html \
--exclude=.gitignore \