Skip to content
Open
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
4 changes: 4 additions & 0 deletions lore-overlay/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
target/
node_modules/
dist/
*.log
129 changes: 129 additions & 0 deletions lore-overlay/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Lore Capture Overlay (MVP — provisional name)

> **Name is provisional.** "overlay" is a working title; the product name is
> still TBD. The corpus artifacts use `lore-overlay` / `lore-capture-overlay`;
> rename here and in `rac/designs/lore-capture-overlay.md` +
> `rac/roadmaps/future/lore-overlay.md` when the product name is settled.

A small, always-one-keystroke-away desktop app that lets someone capture a
decision or requirement **mid-task**: press a global hotkey, a modal appears over
whatever they're doing, they talk it through, and it becomes a **proposed**
artifact — a draft pull request — without leaving their current app, learning
Markdown, or touching git.

This is **Host B** of `rac/designs/lore-capture-surfaces.md`; the architecture is
`rac/designs/lore-capture-overlay.md` and the build plan is
`rac/roadmaps/future/lore-overlay.md`.

It is a `lore-*` product (ADR-068) developed here as a **staging directory** in
`rac-core`, to be extracted to its own `itsthelore/lore-overlay` repo later — the
same develop-in-repo-then-extract pattern used for the VS Code extension.

## Two surfaces: a verified brain and a scaffolded shell

```
lore-overlay/
core/ the platform-agnostic capture "brain" — Rust library, FULLY TESTED here
app/ the Tauri v2 desktop shell — authored, NOT built here (needs macOS)
```

**The skill is the brain; the host is the interface.** `core/` implements the
whole capture flow over three trait seams — the `rac` engine, the model gateway,
and the GitHub writer — so it is exercised offline with fakes and against the real
`rac`. `app/` is the thin desktop shell (global hotkey → modal → invoke the core).

### The two-gate write model (ADR-077)

- **Gate 1 — fidelity.** `CaptureFlow::propose` turns the author's words into a
proposal they confirm in the modal. No file is written, nothing is pushed.
- **Gate 2 — trust boundary.** `CaptureFlow::publish` writes the artifact,
validates it with `rac`, and opens a **draft** pull request. An independent
maintainer's merge is the trust boundary. The `Publisher` trait has **no
approve/merge method by construction**, and the flow refuses any non-draft PR —
so a host built on this core cannot self-approve.

### Bring-your-own gateway (ADR-035)

The model call goes through a configurable **OpenAI-compatible** endpoint
(`base_url` + key + model) — a self-hosted LiteLLM proxy, a cloud vendor, or a
local model. No AI runs in the engine (ADR-002/067); the key lives in the OS
secret store, not in any serialized config.

### Rendered body (Gate 1)

The review step shows the drafted body as **rendered CommonMark** by default, with
a one-click **Edit** toggle back to the raw source (the textarea stays the source
of truth for publish). Rendering uses **markdown-it** (`html: false`) and the
output is sanitized with **DOMPurify** before it touches `innerHTML` — artifact
content is untrusted input (ADR-065), so the render path must not become an
injection vector. These are the app's only frontend dependencies; `npm install`
pulls them, and the frontend needs a bundler (e.g. Vite) to resolve the
bare-module imports.

## What is verified vs not

This MVP was developed in a Linux container, which **cannot build, run, sign, or
notarize a macOS app**. So:

| Part | Status | How checked |
| --- | --- | --- |
| `core/` logic (flow, fill-keeping-frontmatter, id parse, draft-PR guard) | **Verified** | `cargo test` — 4 hermetic tests pass |
| `core/` ↔ real `rac` shell | **Verified** | `LORE_TEST_RAC=… cargo test` exercises `rac schema` |
| `core/` network clients (gateway + GitHub) | **Compiles** | `cargo check --features net` |
| `app/` Tauri desktop shell (hotkey, panel, build, sign) | **Not built / not run** | requires macOS; authored as a scaffold only |

## Build & run

### Core (works anywhere with Rust)

```bash
cd lore-overlay/core
cargo test # hermetic suite
cargo check --features net # compile the gateway + GitHub clients
# exercise the real rac shell:
LORE_TEST_RAC="rac" cargo test real_rac_client_reads_schema_when_configured
```

### Desktop app (macOS first; needs a Mac)

Prerequisites: Rust, Node + npm, the Tauri CLI (`cargo install tauri-cli`), and
Xcode command-line tools.

```bash
cd lore-overlay/app
npm install
cargo tauri dev # run the dev build
cargo tauri build # produce a .app / .dmg
```

Signing & notarization (required for distribution outside the App Store): sign
with a **Developer ID** certificate and notarize with `notarytool`. Windows
(fast-follow) uses **Authenticode** — practically **Azure Trusted Signing** — plus
the SmartScreen-reputation ramp and a bundled/bootstrapped **WebView2** runtime.

## Configuration

`core::Config` holds everything the app needs:

- `gateway` — `base_url`, `model`, and an `api_key` (read from the OS secret
store at runtime; never serialized).
- `repo` — `owner`, `repo`, `base_branch` (default `main`).
- `hotkey` — Tauri accelerator (default `CmdOrCtrl+Shift+L`).
- `rac_command` — how to invoke the engine (bundled, on `PATH`, or a wrapper).

## Open questions (from the design)

- **Desktop GitHub-App auth** — the OAuth **device flow** to install the App and
obtain an installation token, and where that token is cached on-device. The
core takes a bearer token; acquiring it is the shell's job.
- **Embed vs shell `rac`** — bundle the CLI with the app, require it on `PATH`, or
consume the TypeScript SDK once published. Thin-client either way (ADR-063).
- **Offline** — capture-and-queue when there's no network; open the draft PR on
reconnect.

## Corpus pointers

- Design (the *how*): `rac/designs/lore-capture-overlay.md`
- Build plan: `rac/roadmaps/future/lore-overlay.md`
- Trust model: `rac/decisions/adr-077-two-gate-capture-write-model.md`
- Shared pipeline it reuses: `rac/designs/lore-slack-capture-flow.md`
18 changes: 18 additions & 0 deletions lore-overlay/app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "lore-capture-overlay-app",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "Frontend for the Lore capture overlay (provisional name). Authored scaffold; add a bundler (Vite) before running.",
"scripts": {
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"dompurify": "^3",
"markdown-it": "^14"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
}
22 changes: 22 additions & 0 deletions lore-overlay/app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Tauri v2 desktop shell. NOTE: not built or run in this repo's CI — it targets
# macOS and was developed in a Linux container. The capture logic it depends on
# (`lore-capture-core`) is fully tested. See ../../README.md.
[package]
name = "lore-capture-overlay"
version = "0.0.0"
edition = "2021"
description = "Desktop capture overlay for Lore (provisional name)."

[lib]
name = "lore_capture_overlay_lib"
crate-type = ["staticlib", "cdylib", "rlib"]

[build-dependencies]
tauri-build = { version = "2", features = [] }

[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-global-shortcut = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
lore-capture-core = { path = "../../core", features = ["net"] }
3 changes: 3 additions & 0 deletions lore-overlay/app/src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
214 changes: 214 additions & 0 deletions lore-overlay/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
//! Tauri v2 desktop shell for the Lore capture overlay (provisional name).
//!
//! **Not built or run in this repo.** It targets macOS (a non-activating
//! `NSPanel`, Developer ID signing) and was developed in a Linux container, so
//! the exact plugin API calls below are an authored scaffold, not a compiled
//! surface. The capture logic it calls — [`lore_capture_core`] — is fully tested.
//!
//! The shell's only job is the desktop affordance: a global hotkey summons a
//! modal, which runs the two-gate capture flow via the [`propose`] and [`publish`]
//! commands. All model and git work happens in the core, behind a
//! bring-your-own gateway.

use lore_capture_core::{
CaptureFlow, Config, GatewayConfig, GithubPublisher, OpenAiGateway, Proposal, RacClient,
RepoConfig, WriteMode,
};
use tauri::{Manager, WebviewWindow};

// --- configuration / secrets (scaffold) -------------------------------------

/// Load configuration. In the shipped app this reads persisted settings plus the
/// gateway key and a GitHub installation token from the **OS secret store**; here
/// it reads env vars so the wiring type-checks. TODO: real settings + keychain.
fn load_config() -> Config {
Config {
gateway: GatewayConfig {
base_url: env("LORE_GATEWAY_URL"),
model: env("LORE_GATEWAY_MODEL"),
api_key: env("LORE_GATEWAY_KEY"),
},
repo: RepoConfig {
owner: env("LORE_REPO_OWNER"),
repo: env("LORE_REPO_NAME"),
base_branch: std::env::var("LORE_REPO_BASE").unwrap_or_else(|_| "main".into()),
},
hotkey: std::env::var("LORE_HOTKEY").unwrap_or_else(|_| "CmdOrCtrl+Shift+L".into()),
rac_command: std::env::var("LORE_RAC").unwrap_or_else(|_| "rac".into()),
write_mode: match std::env::var("LORE_WRITE_MODE").as_deref() {
Ok("per-capture") => WriteMode::PerCapture,
Ok("direct") => WriteMode::Direct,
_ => WriteMode::Rolling, // default: batch draft PR
},
capture_branch: std::env::var("LORE_CAPTURE_BRANCH")
.unwrap_or_else(|_| "capture/inbox".into()),
}
}

/// The branch a capture in the current mode targets (for `PerCapture`, the slug
/// is appended at publish time, so this is a label).
fn target_branch(cfg: &Config, slug: Option<&str>) -> String {
match cfg.write_mode {
WriteMode::PerCapture => match slug {
Some(s) => format!("capture/{s}"),
None => "capture/<per-capture>".to_string(),
},
WriteMode::Rolling | WriteMode::Direct => cfg.capture_branch.clone(),
}
}

fn mode_label(mode: WriteMode) -> &'static str {
match mode {
WriteMode::PerCapture => "draft PR per capture",
WriteMode::Rolling => "batch draft PR",
WriteMode::Direct => "commit only",
}
}

/// The GitHub bearer token — a GitHub App installation token (obtained via the
/// device flow) in the shipped app; a PAT works for development.
fn github_token() -> String {
env("LORE_GITHUB_TOKEN")
}

fn env(key: &str) -> String {
std::env::var(key).unwrap_or_default()
}

fn build_flow(cfg: &Config) -> CaptureFlow<RacClient, OpenAiGateway, GithubPublisher> {
CaptureFlow::new(
RacClient::new(cfg.rac_command.clone()),
OpenAiGateway::new(&cfg.gateway),
GithubPublisher::new(cfg.repo.owner.clone(), cfg.repo.repo.clone(), github_token()),
cfg.repo.clone(),
)
}

// --- the two gates, as Tauri commands ---------------------------------------

#[derive(serde::Serialize)]
struct ProposalView {
artifact_type: String,
title: String,
body: String,
}

/// Gate 1 — fidelity: draft a proposal from the author's words. No file written.
#[tauri::command]
fn propose(artifact_type: String, intent: String) -> Result<ProposalView, String> {
let cfg = load_config();
let flow = build_flow(&cfg);
let p = flow
.propose(&artifact_type, &intent)
.map_err(|e| e.to_string())?;
Ok(ProposalView {
artifact_type: p.artifact_type,
title: p.title,
body: p.body,
})
}

#[derive(serde::Serialize)]
struct OutcomeView {
path: String,
minted_id: String,
branch: String,
mode: String,
pr_url: String,
}

/// A read-only view of where the next capture will land, for the modal's footer.
#[derive(serde::Serialize)]
struct TargetView {
repo: String,
branch: String,
mode: String,
opens_pr: bool,
}

/// Where a capture lands under the current config — repo, branch, and mode.
#[tauri::command]
fn capture_target() -> TargetView {
let cfg = load_config();
TargetView {
repo: format!("{}/{}", cfg.repo.owner, cfg.repo.repo),
branch: target_branch(&cfg, None),
mode: mode_label(cfg.write_mode).to_string(),
opens_pr: cfg.write_mode != WriteMode::Direct,
}
}

/// Gate 2 prep: after the author confirms, write + validate + commit, and (unless
/// the mode is `Direct`) ensure a DRAFT pull request. The core refuses any
/// non-draft PR; the independent merge is Gate 2. `slug` derives the per-capture
/// branch; it is ignored in rolling/direct modes.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
fn publish(
artifact_type: String,
title: String,
body: String,
dest_path: String,
slug: String,
coauthor: Option<String>,
) -> Result<OutcomeView, String> {
let cfg = load_config();
let flow = build_flow(&cfg);
let proposal = Proposal {
artifact_type,
title,
body,
};
let branch = target_branch(&cfg, Some(&slug));
let outcome = flow
.publish(&proposal, &dest_path, &branch, cfg.write_mode, coauthor.as_deref())
.map_err(|e| e.to_string())?;
Ok(OutcomeView {
path: outcome.path,
minted_id: outcome.minted_id,
branch: outcome.branch,
mode: mode_label(outcome.mode).to_string(),
pr_url: outcome.pr.map(|p| p.url).unwrap_or_default(),
})
}

// --- desktop shell: a global hotkey toggles the capture modal ----------------

fn toggle(window: &WebviewWindow) {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
}

pub fn run() {
use tauri_plugin_global_shortcut::{
Builder as ShortcutBuilder, GlobalShortcutExt, ShortcutState,
};

tauri::Builder::default()
.plugin(
ShortcutBuilder::new()
.with_handler(|app, _shortcut, event| {
if event.state() == ShortcutState::Pressed {
if let Some(win) = app.get_webview_window("capture") {
toggle(&win);
}
}
})
.build(),
)
.setup(|app| {
let hotkey = load_config().hotkey;
app.global_shortcut().register(hotkey.as_str())?;
// macOS enhancement (TODO): promote the "capture" window to a
// non-activating NSPanel that joins all Spaces, so the overlay never
// steals focus from the app the author is working in.
Ok(())
})
.invoke_handler(tauri::generate_handler![propose, publish, capture_target])
.run(tauri::generate_context!())
.expect("error while running the Lore capture overlay");
}
Loading
Loading