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
9 changes: 4 additions & 5 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ Lode is **right ⌘** (configurable in `~/.config/lodestar/lodestar.json`).
| `lode ;` / `lode ⇧;` | Click hints on the focused window — `⇧;` chains clicks (sticky) |
| `lode /` | Select text by typing it — lowercase searches, `⇧letter` anchors |
| `lode [` / `lode ]` | Move the focused window to the prev/next display (`⇧` = arrive beside) |
| `lode Z` / `lode ⇧Z` | Undo / redo the layout (summons, besides, flips, breaths) |
| `lode X` / `lode ⇧X` | Back / forward — walk the attention timeline (previous destinations) |
| `lode ←` / `lode →` | Undo / redo the layout (summons, besides, flips, breaths) |
| `lode ⇧1…9` | Slide the focused window to that position (insert-and-shift, 9 = last) |
| `lode 0` / `lode ⇧0` | The focused window fills the display, rest parked — `⇧0` joins beside |
| `⌫` inside breaths | Arm delete — the next path typed is deleted, not visited |
Expand All @@ -36,7 +35,7 @@ Lode is **right ⌘** (configurable in `~/.config/lodestar/lodestar.json`).

## Chains are sticky, on purpose

Once a traversal starts (`lode W`, `` lode ` ``, …) it waits **indefinitely** — the gesture is identical whether you finish it in 80ms or after a phone call. The glass guide panel stays on screen the whole time showing your prefix and every legal continuation (with app icons); a wrong letter keeps you in place with a note rather than ejecting you. Only a completion or `esc` ends the chain. While a chain is active, stray keystrokes are swallowed (they never leak into the focused app). And holding lode by itself for half a second peeks the top-level guide — the system teaches its own map.
Once a traversal starts (`lode W`, `lode '`, …) it waits **indefinitely** — the gesture is identical whether you finish it in 80ms or after a phone call. The glass guide panel stays on screen the whole time showing your prefix and every legal continuation (with app icons); a wrong letter keeps you in place with a note rather than ejecting you. Only a completion or `esc` ends the chain. While a chain is active, stray keystrokes are swallowed (they never leak into the focused app). And holding lode by itself for half a second peeks the top-level guide — the system teaches its own map.

## Launcher ranking & teaching

Expand All @@ -58,7 +57,7 @@ Results are ranked by fuzzy match quality **plus frecency** — every summon (gr

**They are left alone.** A window born outside Lodestar — a launcher, the Dock, `⌘N`, a certificate prompt, a file reveal — floats untouched above your layout, exactly as macOS would have it, and never hides what you were reading. System and accessibility floaters (notch apps, Control Center, overlay slivers) belong to accessory processes the model never even tracks.

**`lode 0` makes the focused window fill the display** when you want it managed: the summon treatment on demand, everything already in the layout parked, `⇧0` to join beside instead. It also **enrols** the window, so from then on it answers to `lode 1…9`, breaths, orientation flips, and `lode Z` like anything Lodestar summoned. That is the only way an unmanaged window becomes managed — Lodestar never decides on its own that a new window is a destination, and nothing is ever parked except what a placement displaced.
**`lode 0` makes the focused window fill the display** when you want it managed: the summon treatment on demand, everything already in the layout parked, `⇧0` to join beside instead. It also **enrols** the window, so from then on it answers to `lode 1…9`, breaths, orientation flips, and `lode ` like anything Lodestar summoned. That is the only way an unmanaged window becomes managed — Lodestar never decides on its own that a new window is a destination, and nothing is ever parked except what a placement displaced.

## The clipboard

Expand Down Expand Up @@ -203,6 +202,6 @@ it. A yaml that fails to parse is never converted.
- **`lodestar --check`** — full validation from the CLI, exit code included: `~/Applications/lodestar.app/Contents/MacOS/lodestar --check`.
- **`keys:`** overlays the built-in ANSI keycode table for non-ANSI layouts (`keycode: name`).
- **Crash self-healing**: the LaunchAgent restarts Lodestar after a crash (10s throttle) but never after a clean Quit.
- **Back (`lode X`)** pairs with undo: Z rewinds arrangements, X rewinds attention. History records every focus change (however it happened), back-jumps summon with the standard placement rules, a fresh navigation truncates the forward branch, and dead windows are skipped.
- **Every letter belongs to the graph.** As of 0.17 no letter is reserved: orientation moved to `\` in 0.14.2, the attention timeline was retired, and layout undo moved to `lode ←` / `lode →`, where walking a history is what the arrows already mean. A verb that sits on a letter costs an address forever, and an address is the scarcer thing.
- Menu harvesting and scroll-pane discovery run off the main thread — a hung app can no longer freeze Lodestar's UI; the panel opens instantly and rows arrive when ready.
- **Logs are logfmt** (`15:04 INFO summon target=Slack candidates=[8688]`) — greppable and machine-parseable — rotating at 5MB with two predecessors kept (`.1`, `.2`), bounded ~15MB, history preserved.
111 changes: 90 additions & 21 deletions Sources/LodestarCore/Analysis/Advisor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import Foundation
/// chip actionable: the coach's verb is exactly one line, and anything
/// that cannot be one line stays in the report.
public enum ConfigEdit: Codable, Equatable {
/// Bind an app at a chain (bind and shorten both land here).
case bindApp(chain: [String], app: String)
/// Bind a graph target at a chain (bind and shorten both land here).
/// `target` is what the config line writes — an app name, or
/// `brave:xonar` for a browser profile. Never a display label.
case bindTarget(chain: [String], target: String)
/// Remove a binding (retire).
case removeChain(chain: [String])
/// One route line: pattern → profile registry key.
Expand Down Expand Up @@ -46,28 +48,53 @@ public struct Recommendation: Codable, Equatable {
/// Posterior probability the change saves net time over the horizon.
public var probability: Double
public var evidence: [String]
/// What to call the target on screen, when that differs from what the
/// edit writes: a browser profile shows as "Brave (Xonar)" and commits
/// "brave:xonar". Nil means the edit's own target already reads as a
/// name a person would recognise.
public var display: String?
/// The write this recommendation proposes, when it is one config line.
/// Nil means report-only: the chip never offers what it cannot commit.
public var edit: ConfigEdit?

public init(kind: Kind, target: String, detail: String, secondsPerWeek: Double,
probability: Double, evidence: [String], edit: ConfigEdit? = nil) {
probability: Double, evidence: [String], display: String? = nil,
edit: ConfigEdit? = nil) {
self.kind = kind
self.target = target
self.detail = detail
self.secondsPerWeek = secondsPerWeek
self.probability = probability
self.evidence = evidence
self.display = display
self.edit = edit
}
}

public enum Advisor {
/// One bound address, as the advisor needs to see it.
public struct Leaf {
public let chain: [String]
/// What a person calls it — mnemonic letters and chip text come
/// from here.
public let label: String
/// What a config line writes to bind the same target. For a browser
/// profile the two differ ("Brave (Xonar)" against "brave:xonar"),
/// and an edit that commits the label silently binds plain Brave.
public let value: String

public init(chain: [String], label: String, value: String) {
self.chain = chain
self.label = label
self.value = value
}
}

public struct Context {
public var observations: Observations
public var events: [ObservationEvent]
/// Bound chains and their target labels, from the live graph.
public var leaves: [(chain: [String], label: String)]
/// Bound chains and their targets, from the live graph.
public var leaves: [Leaf]
/// The web route table, so an already-covered host stays quiet.
public var webRoutes: [String: String]
/// Observed profile identity ("brave:Work") → registry key ("work"),
Expand All @@ -76,7 +103,7 @@ public enum Advisor {
public var now: Date

public init(observations: Observations, events: [ObservationEvent],
leaves: [(chain: [String], label: String)],
leaves: [Leaf],
webRoutes: [String: String] = [:],
profileKeys: [String: String] = [:], now: Date = Date()) {
self.observations = observations
Expand All @@ -96,6 +123,21 @@ public enum Advisor {
static let horizonHalfLife = 12.0
static let probabilityGate = 0.9

/// A stable seed for the Monte Carlo. `hashValue` cannot do this job:
/// Swift randomises it per process, so seeding from it made every run
/// draw a different sample — the precise opposite of what `Random`
/// promises below, and enough to make a candidate sitting near the
/// gate appear and vanish between runs. FNV-1a over the bytes is
/// stable, and total (`abs()` on `Int.min` would have trapped).
static func seed(_ text: String) -> UInt64 {
var hash: UInt64 = 0xcbf2_9ce4_8422_2325
for byte in text.utf8 {
hash ^= UInt64(byte)
hash = hash &* 0x0000_0100_0000_01B3
}
return hash
}

/// Deterministic LCG so the Monte Carlo is replayable in tests.
struct Random {
private var state: UInt64
Expand Down Expand Up @@ -138,13 +180,11 @@ public enum Advisor {

// MARK: - Shared

/// Letters the grammar itself owns: X walks the timeline, Z undoes.
/// (O flipped orientation until 0.14.2, when the verb moved to \ and
/// the letter went back to being an address.) A graph slot here would
/// be shadowed by the verb, and the coach must never offer what the
/// grammar reserves — found the hard way when Zoom's mnemonic Z
/// nearly cleared the gates.
static let reservedLetters: Set<String> = ["x", "z"]
/// Letters the grammar itself owns — the coach must never offer what
/// the grammar reserves, found the hard way when Zoom's mnemonic Z
/// nearly cleared the gates. Read from `Gestures`, never copied: this
/// constant drifted out of date behind two separate key moves.
static let reservedLetters: Set<String> = Gestures.reservedLetters

static func freeLetters(_ context: Context) -> Set<String> {
let taken = Set(context.leaves.compactMap { $0.chain.first })
Expand All @@ -156,12 +196,21 @@ public enum Advisor {
/// then what the user actually types into the launcher to find it.
static func mnemonicLetters(app: String, record: Observations.AppRecord?) -> [String] {
var letters: [String] = []
for word in app.lowercased().split(separator: " ") {
if let first = word.first, first.isLetter { letters.append(String(first)) }
// Split on anything that is not a letter or digit, and take each
// word's first *letter* rather than its first character. Splitting
// on spaces alone dropped every parenthesised word — and Lodestar
// writes those itself (`Graph.Target.label` renders a browser
// profile as "Brave (Xonar)"), so the distinguishing half of every
// profile name was invisible here. The same slip hid "1Password"
// behind its digit.
for word in app.lowercased().split(whereSeparator: { !$0.isLetter && !$0.isNumber }) {
if let first = word.first(where: { $0.isLetter }) { letters.append(String(first)) }
}
if let prefixes = record?.prefixes {
for (prefix, _) in prefixes.sorted(by: { $0.value > $1.value }) {
if let first = prefix.first, first.isLetter { letters.append(String(first)) }
if let first = prefix.first(where: { $0.isLetter }) {
letters.append(String(first))
}
}
}
var seen = Set<String>()
Expand Down Expand Up @@ -217,7 +266,7 @@ public enum Advisor {
perUseSavedSD: max(0.1, launcherSD),
usesPerWeek: demand.perWeek, usesPerWeekSE: demand.se,
oneOffCost: learningCost,
seed: UInt64(abs(app.hashValue)))
seed: seed(app))
guard value.probability >= probabilityGate else { continue }
out.append((Recommendation(
kind: .bind, target: app,
Expand All @@ -229,7 +278,7 @@ public enum Advisor {
String(format: "launcher costs %.2fs; lode %@ predicted %.2fs",
launcherSeconds, slot.uppercased(), chainSeconds),
String(format: "learning bill ≈ %.0fs, from your own curves", learningCost),
], edit: .bindApp(chain: [slot], app: app)), 1 - value.probability))
], edit: .bindTarget(chain: [slot], target: app)), 1 - value.probability))
}
return out
}
Expand Down Expand Up @@ -289,10 +338,29 @@ public enum Advisor {
let proposed = latency.chainSeconds([slot])
let saved = current - proposed
guard saved > 0.05 else { continue }
let value = netBenefit(perUseSavedMean: saved, perUseSavedSD: latency.residualSD * saved,
// The saving is a difference of two *means*, so what matters is
// how well each mean is known — not how much a single keystroke
// varies. Pricing it as `residualSD * saved` made
// P(saves time) = Φ(1 / residualSD) for every candidate alike:
// a constant, unmoved by the size of the saving or by the
// evidence behind it, and at this user's spread it sat at 0.85
// against a 0.9 gate, so no shorten could ever be offered.
// The chain being replaced has been completed this many times,
// so its mean tightens as √n; the proposed chain has never been
// typed at all, so it keeps the full population spread.
//
// Count *completions*, not `latency.fluency[key]?.n`: that is one
// sample per keystroke gap, so a two-letter chain would claim √2
// more evidence than it has — and the gaps inside one completion
// are correlated anyway, so they are not independent draws.
let currentSD = current * latency.residualSD
/ Double(max(1, record.completions)).squareRoot()
let proposedSD = proposed * latency.residualSD
let savedSD = (currentSD * currentSD + proposedSD * proposedSD).squareRoot()
let value = netBenefit(perUseSavedMean: saved, perUseSavedSD: savedSD,
usesPerWeek: weekly, usesPerWeekSE: weekly.squareRoot(),
oneOffCost: learningCost,
seed: UInt64(abs(key.hashValue)))
seed: seed(key))
guard value.probability >= probabilityGate else { continue }
let shown = "lode " + leaf.chain.map { $0.uppercased() }.joined(separator: " ")
out.append((Recommendation(
Expand All @@ -302,7 +370,8 @@ public enum Advisor {
secondsPerWeek: value.secondsPerWeek, probability: value.probability,
evidence: [String(format: "%.2fs now vs %.2fs shortened, %d completions",
current, proposed, record.completions)],
edit: .bindApp(chain: [slot], app: leaf.label)),
display: leaf.label,
edit: .bindTarget(chain: [slot], target: leaf.value)),
1 - value.probability))
}
return out
Expand Down
11 changes: 8 additions & 3 deletions Sources/LodestarCore/Analysis/Coach.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ public enum Coach {
switch rec.kind {
case .bind, .nudge: return .app(rec.target)
case .shorten:
if case .bindApp(_, let app)? = rec.edit { return .app(app.lowercased()) }
if case .bindTarget(_, let target)? = rec.edit {
return .app((rec.display ?? target).lowercased())
}
return nil
case .route: return .host(rec.target)
case .rebind, .retire, .breath: return nil
Expand All @@ -145,9 +147,12 @@ public enum Coach {
var evidence = rec.detail
switch rec.kind {
case .bind, .shorten:
if case .bindApp(let chain, let app)? = rec.edit {
if case .bindTarget(let chain, let target)? = rec.edit {
let shown = chain.map { $0.uppercased() }.joined(separator: " ")
headline = "lode \(shown) → \(app)"
// The label, never the config value: a chip saying
// "lode X → brave:xonar" quotes the machinery at someone
// who only ever asked for their browser.
headline = "lode \(shown) → \(rec.display ?? target)"
} else {
headline = rec.target
}
Expand Down
8 changes: 4 additions & 4 deletions Sources/LodestarCore/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,10 @@ public struct Config {
/// rolls back still finds its config. Retired at 1.0.
public static let yamlFile = directory.appendingPathComponent("lodestar.yaml")

/// Top-level chain letters the primitives own; the graph may not use them.
/// First letters the grammar keeps for verbs: Z undo, X timeline.
/// O rejoined the graph in 0.14.2 when orientation moved to \.
public static let reservedTopLevel: Set<String> = ["z", "x"]
/// Top-level chain letters the primitives own; the graph may not use
/// them. Defined once in `Gestures` so a key move cannot leave a stale
/// copy here — which is exactly what happened twice.
public static let reservedTopLevel: Set<String> = Gestures.reservedLetters

/// The schema: one table driving reload validation and the JSON Schema
/// editors read. Keep in lockstep with `parse` below.
Expand Down
7 changes: 7 additions & 0 deletions Sources/LodestarCore/ConfigDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ public enum ConfigDefaults {
if gestures["launcher"] == nil { gestures["launcher"] = searcher }
out["gestures"] = .table(gestures)
}
// The attention timeline was retired in 0.17. Every config written
// before then carries its switch, and a verb that no longer exists
// must not make an otherwise-valid file report an unknown key.
if case .table(var gestures)? = out["gestures"],
gestures.removeValue(forKey: "back-forward") != nil {
out["gestures"] = .table(gestures)
}
return out
}

Expand Down
10 changes: 4 additions & 6 deletions Sources/LodestarCore/Engine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,6 @@ public enum EngineEffect: Equatable {
case flipOrientation
case undoLayout
case redoLayout
case goBack
case goForward
case indexJump(Int)
case reorder(Int)
case moveDisplay(direction: Int, beside: Bool)
Expand Down Expand Up @@ -318,10 +316,10 @@ public struct EngineCore {
// The key wearing the vertical bar flips the layout — moved
// off O so the letter can go back to being an address.
effects.append(.flipOrientation)
case "z":
effects.append(shift ? .redoLayout : .undoLayout)
case "x":
effects.append(shift ? .goForward : .goBack)
case "left":
effects.append(.undoLayout)
case "right":
effects.append(.redoLayout)
case "[":
effects.append(.moveDisplay(direction: -1, beside: shift))
case "]":
Expand Down
Loading
Loading