From e873ae0133099be22b553a6b16d8a0b0e3099812 Mon Sep 17 00:00:00 2001
From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com>
Date: Sun, 18 Oct 2026 20:41:41 +0530
Subject: [PATCH 01/13] feat(evals): measure AI work and edit isolation (#34)
---
docs/evals.md | 6 +++
packages/evals/src/cli.ts | 2 +-
packages/evals/src/matchers.ts | 12 ++++++
packages/evals/src/report.test.ts | 23 +++++++++++
packages/evals/src/report.ts | 26 +++++++++++-
packages/evals/src/run.test.ts | 10 +++++
packages/evals/src/run.ts | 61 +++++++++++++++++++++++++++--
packages/llm/src/advisor.ts | 5 ++-
packages/llm/src/client.test.ts | 18 ++++++++-
packages/llm/src/client.ts | 40 +++++++++++++++++--
packages/mock-lmstudio/src/index.ts | 13 +++++-
11 files changed, 203 insertions(+), 13 deletions(-)
diff --git a/docs/evals.md b/docs/evals.md
index 7047853..6abaa99 100644
--- a/docs/evals.md
+++ b/docs/evals.md
@@ -14,6 +14,12 @@ The model must be loaded first (`lms load qwen/qwen3.8-27b --context-length 3276
app). A run writes `packages/evals/reports/-/results.json` and `report.md` (pass rates per category against the
bars, and every failure with what was said, what was expected and what the model wrote). `--strict` exits 1 below a bar.
+Each trial also records its provider, model, lane, time to first token, total time, model and repair call counts, final outcome,
+affected blocks, and unrelated blocks changed. Token totals use provider-reported usage when available; otherwise Lucid stores
+character-based estimates and marks them `estimated` rather than presenting them as exact. This measurement data contains
+counts and identifiers only—never API keys, full prompts, or document text. Failure examples shown by the eval reporter come
+only from this repository's synthetic fixtures and scripted utterances.
+
## The bars
Overall 90%. Each category 80%, and 95% for `non_destructive` and `mode_separation` (a mistake there loses the person's
diff --git a/packages/evals/src/cli.ts b/packages/evals/src/cli.ts
index f55005f..8065dff 100644
--- a/packages/evals/src/cli.ts
+++ b/packages/evals/src/cli.ts
@@ -95,7 +95,7 @@ export async function main(args: string[], options: MainOptions = {}): Promise 0 ? `#${String(variant)}` : ""} ${(t.ms / 1000).toFixed(1)}s${t.pass ? "" : ` ${t.failures[0] ?? ""}`}`,
+ `[${String(i + 1)}/${String(plan.length)}] ${t.pass ? "PASS" : "FAIL"} ${c.category}/${c.id}${variant > 0 ? `#${String(variant)}` : ""} ${(t.ms / 1000).toFixed(1)}s · ${String(t.promptTokens)}→${String(t.outputTokens)} tok${t.tokenSource === "estimated" ? " est." : ""} · ${String(t.repairCalls)} repair${t.repairCalls === 1 ? "" : "s"}${t.pass ? "" : ` ${t.failures[0] ?? ""}`}`,
);
}
diff --git a/packages/evals/src/matchers.ts b/packages/evals/src/matchers.ts
index 8df1026..b413eda 100644
--- a/packages/evals/src/matchers.ts
+++ b/packages/evals/src/matchers.ts
@@ -334,6 +334,18 @@ function idsOf(doc: LucidDoc, sel: Sel): string[] {
return top.slice(from, to + 1).map((b) => b.id);
}
+/** Every block except the named ones is exactly as it was. */
+export function unrelatedChangedCount(o: Observed, sels: readonly Sel[]): number {
+ const allowed = new Set(sels.flatMap((s) => idsOf(o.before, s)));
+ let count = 0;
+ for (const b of o.before.blocks) {
+ if (allowed.has(b.id)) continue;
+ const now = o.after.blocks.find((x) => x.id === b.id);
+ if (!now || JSON.stringify(now) !== JSON.stringify(b)) count += 1;
+ }
+ return count;
+}
+
/** Every block except the named ones is exactly as it was. */
function checkTouchedOnly(o: Observed, sels: readonly Sel[]): string[] {
const allowed = new Set(sels.flatMap((s) => idsOf(o.before, s)));
diff --git a/packages/evals/src/report.test.ts b/packages/evals/src/report.test.ts
index 8629c1a..c001793 100644
--- a/packages/evals/src/report.test.ts
+++ b/packages/evals/src/report.test.ts
@@ -16,9 +16,21 @@ const trial = (
pass,
failures: pass ? [] : ["missing component CDN"],
outcome: pass ? "edit" : "edit",
+ lane: "command",
+ cancelled: false,
ms: 1000,
+ firstTokenMs: 250,
promptTokens: 100,
outputTokens: 50,
+ tokenSource: "provider",
+ requestChars: 400,
+ responseChars: 200,
+ modelCalls: 1,
+ repairCalls: 0,
+ affectedBlocks: 1,
+ unrelatedBlocksModified: 0,
+ provider: "lmstudio",
+ model: "m",
raw: "{}",
...over,
});
@@ -48,6 +60,15 @@ describe("summarise", () => {
threshold: 0.95,
ok: false,
});
+ expect(s).toMatchObject({
+ p50FirstTokenMs: 250,
+ promptTokens: 1200,
+ outputTokens: 600,
+ modelCalls: 12,
+ affectedBlocks: 12,
+ unrelatedBlocksModified: 0,
+ cancellations: 0,
+ });
});
it("counts pipeline errors and leaves out categories that were not run", () => {
@@ -67,6 +88,8 @@ describe("toMarkdown", () => {
);
expect(md).toContain("# Eval report: v1");
expect(md).toContain("| diagram_edit | 1/2 | 50% | 80% | **below** |");
+ expect(md).toContain("2 calls (0 repairs), 200 input tokens and 100 output tokens");
+ expect(md).toContain("2 affected blocks; 0 unrelated blocks modified");
expect(md).toContain("### b (variant 1) · diagram_edit");
expect(md).toContain("- missing component CDN");
expect(md).toContain('{"kind":"edit"}');
diff --git a/packages/evals/src/report.ts b/packages/evals/src/report.ts
index 001aab0..36a94f9 100644
--- a/packages/evals/src/report.ts
+++ b/packages/evals/src/report.ts
@@ -43,7 +43,16 @@ export interface Summary {
categories: (Rate & { category: Category; threshold: number; ok: boolean; p50Ms: number })[];
/** Trials where the pipeline itself failed (the model could not be reached, bad JSON after repair...). */
errors: number;
+ cancellations: number;
p50Ms: number;
+ p50FirstTokenMs: number;
+ promptTokens: number;
+ outputTokens: number;
+ estimatedTokenTrials: number;
+ modelCalls: number;
+ repairCalls: number;
+ affectedBlocks: number;
+ unrelatedBlocksModified: number;
}
export function summarise(results: Results): Summary {
@@ -62,7 +71,18 @@ export function summarise(results: Results): Summary {
overall: { ...overall, ok: overall.rate >= OVERALL_THRESHOLD },
categories,
errors: trials.filter((t) => t.outcome === "error").length,
+ cancellations: trials.filter((t) => t.cancelled).length,
p50Ms: median(trials.map((t) => t.ms)),
+ p50FirstTokenMs: median(
+ trials.flatMap((t) => (t.firstTokenMs === null ? [] : [t.firstTokenMs])),
+ ),
+ promptTokens: trials.reduce((n, t) => n + t.promptTokens, 0),
+ outputTokens: trials.reduce((n, t) => n + t.outputTokens, 0),
+ estimatedTokenTrials: trials.filter((t) => t.tokenSource === "estimated").length,
+ modelCalls: trials.reduce((n, t) => n + t.modelCalls, 0),
+ repairCalls: trials.reduce((n, t) => n + t.repairCalls, 0),
+ affectedBlocks: trials.reduce((n, t) => n + t.affectedBlocks, 0),
+ unrelatedBlocksModified: trials.reduce((n, t) => n + (t.unrelatedBlocksModified ?? 0), 0),
};
}
@@ -77,7 +97,11 @@ export function toMarkdown(results: Results): string {
"",
`Model \`${results.meta.model}\`, ${results.meta.date}, ${String(results.trials.length)} trials.`,
"",
- `**Overall ${pct(s.overall.rate)}** (${String(s.overall.pass)} of ${String(s.overall.total)}), needs ${pct(OVERALL_THRESHOLD)}: ${s.overall.ok ? "met" : "NOT met"}. Median ${secs(s.p50Ms)} per trial. Pipeline errors: ${String(s.errors)}.`,
+ `**Overall ${pct(s.overall.rate)}** (${String(s.overall.pass)} of ${String(s.overall.total)}), needs ${pct(OVERALL_THRESHOLD)}: ${s.overall.ok ? "met" : "NOT met"}. Median ${secs(s.p50Ms)} per trial. Pipeline errors: ${String(s.errors)}; cancellations: ${String(s.cancellations)}.`,
+ "",
+ `Model work: ${String(s.modelCalls)} calls (${String(s.repairCalls)} repairs), ${String(s.promptTokens)} input tokens and ${String(s.outputTokens)} output tokens${s.estimatedTokenTrials > 0 ? `; ${String(s.estimatedTokenTrials)} trial(s) used clearly marked estimates` : ""}. Median first token ${secs(s.p50FirstTokenMs)}.`,
+ "",
+ `Edit isolation: ${String(s.affectedBlocks)} affected blocks; ${String(s.unrelatedBlocksModified)} unrelated blocks modified in cases that define an allowed change set.`,
"",
"| Category | Pass | Rate | Needs | | Median |",
"|---|---|---|---|---|---|",
diff --git a/packages/evals/src/run.test.ts b/packages/evals/src/run.test.ts
index 0e8541e..fcb0f30 100644
--- a/packages/evals/src/run.test.ts
+++ b/packages/evals/src/run.test.ts
@@ -72,6 +72,14 @@ describe("runTrial", () => {
expect(t).toMatchObject({ pass: true, outcome: "applied", failures: [], variant: 0 });
expect(t.raw).toContain("Load Balancer");
expect(t.outputTokens).toBeGreaterThan(0);
+ expect(t).toMatchObject({
+ tokenSource: "provider",
+ modelCalls: 1,
+ repairCalls: 0,
+ affectedBlocks: 3,
+ provider: "lmstudio",
+ });
+ expect(t.firstTokenMs).not.toBeNull();
});
it("fails with the reasons when the notes are not what was expected", async () => {
@@ -100,6 +108,7 @@ describe("runTrial", () => {
),
]);
expect(ok).toMatchObject({ pass: true, outcome: "edit", summary: "Added a CDN" });
+ expect(ok).toMatchObject({ affectedBlocks: 1, unrelatedBlocksModified: 0 });
const body = mock?.requests[0]?.body;
const last = (body?.["messages"] as { content: string }[]).at(-1)?.content ?? "";
expect(last).toContain("FOCUS");
@@ -168,6 +177,7 @@ describe("runTrial, advisor and format pages", () => {
),
]);
expect(good).toMatchObject({ pass: true, outcome: "advice" });
+ expect(good).toMatchObject({ modelCalls: 1, tokenSource: "provider" });
const bad = await trial(pick, [
reply(JSON.stringify({ formatId: "system-design", title: "x", reason: "y" })),
]);
diff --git a/packages/evals/src/run.ts b/packages/evals/src/run.ts
index b13117c..626fb2c 100644
--- a/packages/evals/src/run.ts
+++ b/packages/evals/src/run.ts
@@ -1,4 +1,4 @@
-import { applyPatch, type LucidDoc } from "@lucid/core";
+import { applyPatch, patchedIds, type LucidDoc } from "@lucid/core";
import {
DEFAULT_LANES,
runAdvisor,
@@ -9,7 +9,7 @@ import {
type LmStudioClient,
} from "@lucid/llm";
import { fixture } from "./fixtures";
-import { evaluate, findBlock, type Observed } from "./matchers";
+import { evaluate, findBlock, unrelatedChangedCount, type Observed } from "./matchers";
import type { Case } from "./types";
export interface Trial {
@@ -22,9 +22,21 @@ export interface Trial {
failures: string[];
/** What the pipeline came to (applied, command, unprocessed, edit, proposal, clarify, none, failed, error). */
outcome: string;
+ lane: Case["mode"];
+ cancelled: boolean;
ms: number;
+ firstTokenMs: number | null;
promptTokens: number;
outputTokens: number;
+ tokenSource: "provider" | "estimated" | "none";
+ requestChars: number;
+ responseChars: number;
+ modelCalls: number;
+ repairCalls: number;
+ affectedBlocks: number;
+ unrelatedBlocksModified: number | null;
+ provider: string;
+ model: string;
/** The model's last answer, as it wrote it. */
raw: string;
/** What Lucid said it did, for a change. */
@@ -53,17 +65,19 @@ export async function runTrial(
const found = findBlock(before, sel);
return found ? [{ blockId: found.id, note: "added a note" }] : [];
});
- const base = { caseId: c.id, category: c.category, variant, utterance };
+ const base = { caseId: c.id, category: c.category, variant, utterance, lane: c.mode };
const started = Date.now();
try {
let observed: Observed;
- let calls: readonly ChatResult[] = [];
+ let calls: ChatResult[] = [];
let raw = "";
let summary: string | undefined;
+ let affectedBlocks = 0;
if (c.mode === "advisor") {
const advice = await runAdvisor(utterance, {
client: deps.client,
lane: deps.lanes?.advisor ?? DEFAULT_LANES.advisor,
+ onResult: (result) => calls.push(result),
});
raw = JSON.stringify(advice);
observed = { before, after: before, outcome: "advice", utterance, formatId: advice.formatId };
@@ -82,6 +96,7 @@ export async function runTrial(
calls = out.calls;
raw = out.raw;
const after = out.kind === "applied" ? applyPatch(before, out.patch) : before;
+ if (out.kind === "applied") affectedBlocks = patchedIds(out.patch).length;
observed = { before, after, outcome: out.kind, utterance };
} else {
const out = await runCommand(
@@ -100,6 +115,7 @@ export async function runTrial(
raw = out.raw;
const after = out.kind === "edit" || out.kind === "proposal" ? out.next : before;
if (out.kind === "edit" || out.kind === "proposal") summary = out.summary;
+ if (out.kind === "edit" || out.kind === "proposal") affectedBlocks = out.affected.length;
observed = {
before,
after,
@@ -111,14 +127,37 @@ export async function runTrial(
}
const outputTokens = sum(calls, (x) => x.usage.completion);
const failures = evaluate(c.mode, c.expect, { ...observed, outputTokens });
+ const firstCall = calls[0];
+ const firstTokenMs = firstCall?.timings.firstTokenAt;
return {
...base,
pass: failures.length === 0,
failures,
outcome: observed.outcome,
+ cancelled: false,
ms: Date.now() - started,
+ firstTokenMs:
+ firstCall && firstTokenMs !== null && firstTokenMs !== undefined
+ ? firstTokenMs - firstCall.timings.sentAt
+ : null,
promptTokens: sum(calls, (x) => x.usage.prompt),
outputTokens,
+ tokenSource:
+ calls.length === 0
+ ? "none"
+ : calls.every((call) => call.usage.source === "provider")
+ ? "provider"
+ : "estimated",
+ requestChars: sum(calls, (x) => x.characters.request),
+ responseChars: sum(calls, (x) => x.characters.response),
+ modelCalls: calls.length,
+ repairCalls: Math.max(0, calls.length - 1) + sum(calls, (call) => call.repairAttempts),
+ affectedBlocks,
+ unrelatedBlocksModified: c.expect.touchedOnly
+ ? unrelatedChangedCount(observed, c.expect.touchedOnly)
+ : null,
+ provider: calls.at(-1)?.provider ?? "none",
+ model: calls.at(-1)?.model ?? "none",
raw,
summary,
};
@@ -129,9 +168,23 @@ export async function runTrial(
pass: false,
failures: [`the pipeline failed: ${message}`],
outcome: "error",
+ cancelled:
+ error instanceof Error &&
+ "code" in error &&
+ (error as { code?: unknown }).code === "Aborted",
ms: Date.now() - started,
+ firstTokenMs: null,
promptTokens: 0,
outputTokens: 0,
+ tokenSource: "none",
+ requestChars: 0,
+ responseChars: 0,
+ modelCalls: 0,
+ repairCalls: 0,
+ affectedBlocks: 0,
+ unrelatedBlocksModified: null,
+ provider: "none",
+ model: "none",
raw: "",
error: message,
};
diff --git a/packages/llm/src/advisor.ts b/packages/llm/src/advisor.ts
index bea7876..24d11bd 100644
--- a/packages/llm/src/advisor.ts
+++ b/packages/llm/src/advisor.ts
@@ -1,5 +1,5 @@
import { FORMATS, pickFormat } from "@lucid/kit";
-import type { LmStudioClient } from "./client";
+import type { ChatResult, LmStudioClient } from "./client";
import { DEFAULT_LANES, type Lane } from "./lanes";
import { advisorJsonSchema, buildAdvisorMessages } from "./prompts/advisor";
@@ -7,6 +7,8 @@ export interface AdvisorDeps {
client: LmStudioClient;
lane?: Lane | undefined;
signal?: AbortSignal | undefined;
+ /** Development/eval telemetry hook. It receives counts and timings, never credentials. */
+ onResult?: ((result: ChatResult) => void) | undefined;
}
export interface Advice {
@@ -76,6 +78,7 @@ export async function runAdvisor(description: string, deps: AdvisorDeps): Promis
jsonSchema: { name: "lucid_format", schema: advisorJsonSchema() },
signal: deps.signal,
});
+ deps.onResult?.(result);
const answer = readAnswer(result.content);
if (!answer) return fallback("the answer was not in the expected shape");
if (!FORMATS.some((f) => f.id === answer.formatId))
diff --git a/packages/llm/src/client.test.ts b/packages/llm/src/client.test.ts
index 84e6d49..9f9b844 100644
--- a/packages/llm/src/client.test.ts
+++ b/packages/llm/src/client.test.ts
@@ -40,6 +40,10 @@ describe("chat", () => {
expect(deltas.length).toBeGreaterThan(2);
expect(result.usage.completion).toBeGreaterThan(0);
expect(result.usage.reasoning).toBe(0);
+ expect(result.usage.source).toBe("provider");
+ expect(result.characters.request).toBeGreaterThan(0);
+ expect(result.characters.response).toBe(result.content.length);
+ expect(result.repairAttempts).toBe(0);
expect(result.timings.firstTokenAt).not.toBeNull();
expect(result.timings.doneAt).toBeGreaterThanOrEqual(result.timings.sentAt);
@@ -55,6 +59,16 @@ describe("chat", () => {
expect(body).not.toHaveProperty("response_format");
});
+ it("labels estimated usage when the provider omits token counts", async () => {
+ const { client } = await start({
+ script: [{ kind: "reply", content: "estimated response", includeUsage: false }],
+ });
+ const result = await client.chat({ lane, messages });
+ expect(result.usage.source).toBe("estimated");
+ expect(result.usage.prompt).toBeGreaterThan(0);
+ expect(result.usage.completion).toBeGreaterThan(0);
+ });
+
it("asks for strict structured output when a schema is given", async () => {
const { mock, client } = await start({
script: [{ kind: "reply", content: '{"kind":"none","reason":"x"}' }],
@@ -71,7 +85,9 @@ describe("chat", () => {
const { mock, client } = await start({
script: [{ kind: "empty-with-reasoning" }, { kind: "reply", content: "ok" }],
});
- expect((await client.chat({ lane, messages })).content).toBe("ok");
+ const result = await client.chat({ lane, messages });
+ expect(result.content).toBe("ok");
+ expect(result.repairAttempts).toBe(1);
expect(mock.requests).toHaveLength(2);
});
diff --git a/packages/llm/src/client.ts b/packages/llm/src/client.ts
index 2244ee9..1d53e7c 100644
--- a/packages/llm/src/client.ts
+++ b/packages/llm/src/client.ts
@@ -19,9 +19,19 @@ export interface ChatRequest {
export interface ChatResult {
content: string;
- usage: { prompt: number; completion: number; reasoning: number };
+ usage: {
+ prompt: number;
+ completion: number;
+ reasoning: number;
+ /** Provider counts are preferred; estimates are explicitly marked when a server omits usage. */
+ source: "provider" | "estimated";
+ };
+ characters: { request: number; response: number };
timings: { sentAt: number; firstTokenAt: number | null; doneAt: number };
model: string;
+ provider: "lmstudio";
+ /** Automatic retries performed inside this client before returning the answer. */
+ repairAttempts: number;
}
export interface ClientOptions {
@@ -52,6 +62,7 @@ interface StreamChunk {
}
const num = (v: unknown): number => (typeof v === "number" ? v : 0);
+const estimateTokens = (text: string): number => Math.max(1, Math.ceil(text.length / 4));
/** The only request shape Lucid uses: /v1/chat/completions, streamed, with reasoning off. */
export class LmStudioClient {
@@ -100,7 +111,10 @@ export class LmStudioClient {
try {
return await this.once(request);
} catch (error) {
- if (isLlmError(error) && error.code === "ReasoningLeak") return this.once(request);
+ if (isLlmError(error) && error.code === "ReasoningLeak") {
+ const repaired = await this.once(request);
+ return { ...repaired, repairAttempts: repaired.repairAttempts + 1 };
+ }
throw error;
}
}
@@ -132,6 +146,7 @@ export class LmStudioClient {
}
: {}),
};
+ const requestCharacters = JSON.stringify(body).length;
const controller = new AbortController();
let reason: LlmErrorCode | undefined;
@@ -219,6 +234,7 @@ export class LmStudioClient {
let done = false;
let model = lane.model;
const usage = { prompt: 0, completion: 0, reasoning: 0 };
+ let usageReported = false;
try {
for await (const data of readSse(res.body)) {
if (data === "[DONE]") {
@@ -254,6 +270,9 @@ export class LmStudioClient {
usage.prompt = num(chunk.usage.prompt_tokens);
usage.completion = num(chunk.usage.completion_tokens);
usage.reasoning = num(chunk.usage.completion_tokens_details?.reasoning_tokens);
+ usageReported =
+ typeof chunk.usage.prompt_tokens === "number" &&
+ typeof chunk.usage.completion_tokens === "number";
}
if (typeof (chunk as { model?: unknown }).model === "string")
model = (chunk as { model: string }).model;
@@ -278,7 +297,22 @@ export class LmStudioClient {
throw new LlmError("BadResponse", "The model gave an empty answer.");
}
completed = true;
- return { content, usage, timings: { sentAt, firstTokenAt, doneAt: Date.now() }, model };
+ return {
+ content,
+ usage: usageReported
+ ? { ...usage, source: "provider" }
+ : {
+ prompt: estimateTokens(JSON.stringify(request.messages)),
+ completion: estimateTokens(content),
+ reasoning: 0,
+ source: "estimated",
+ },
+ characters: { request: requestCharacters, response: content.length },
+ timings: { sentAt, firstTokenAt, doneAt: Date.now() },
+ model,
+ provider: "lmstudio",
+ repairAttempts: 0,
+ };
} finally {
clearTimeout(requestTimer);
clearFirstToken();
diff --git a/packages/mock-lmstudio/src/index.ts b/packages/mock-lmstudio/src/index.ts
index 4b26cf5..b12197a 100644
--- a/packages/mock-lmstudio/src/index.ts
+++ b/packages/mock-lmstudio/src/index.ts
@@ -9,7 +9,14 @@ import type { AddressInfo } from "node:net";
export type Behaviour =
/** A normal streamed answer. */
- | { kind: "reply"; content: string; chunkChars?: number; delayMs?: number; promptTokens?: number }
+ | {
+ kind: "reply";
+ content: string;
+ chunkChars?: number;
+ delayMs?: number;
+ promptTokens?: number;
+ includeUsage?: boolean;
+ }
/** The reasoning leak: thinking tokens, no content. */
| { kind: "empty-with-reasoning"; reasoningTokens?: number }
/** HTTP error with LM Studio's error body. */
@@ -169,7 +176,9 @@ export async function startMockLmStudio(options: MockOptions = {}): Promise
Date: Sun, 18 Oct 2026 20:46:13 +0530
Subject: [PATCH 02/13] refactor(llm): add provider-neutral client contract
(#35)
---
apps/desktop/src/jobs/queue.ts | 4 +-
apps/desktop/src/llm/model.ts | 4 +-
docs/architecture.md | 23 +++++++++-
packages/evals/src/run.ts | 4 +-
packages/llm/src/advisor.ts | 4 +-
packages/llm/src/client.test.ts | 24 +++++++++-
packages/llm/src/client.ts | 66 ++++++++++-----------------
packages/llm/src/commandPipeline.ts | 4 +-
packages/llm/src/index.ts | 1 +
packages/llm/src/input.ts | 4 +-
packages/llm/src/provider.ts | 71 +++++++++++++++++++++++++++++
11 files changed, 153 insertions(+), 56 deletions(-)
create mode 100644 packages/llm/src/provider.ts
diff --git a/apps/desktop/src/jobs/queue.ts b/apps/desktop/src/jobs/queue.ts
index 85ace7a..76f4155 100644
--- a/apps/desktop/src/jobs/queue.ts
+++ b/apps/desktop/src/jobs/queue.ts
@@ -14,7 +14,7 @@ import {
runInput,
unprocessedPatch,
type CommandOutcome,
- type LmStudioClient,
+ type AiClient,
type Lane,
type LlmError,
} from "@lucid/llm";
@@ -52,7 +52,7 @@ export interface EditorPort {
}
export interface QueueOptions {
- client: LmStudioClient;
+ client: AiClient;
lanes: { input: Lane; command: Lane };
/** Shown when the model is not loaded. */
modelName: string;
diff --git a/apps/desktop/src/llm/model.ts b/apps/desktop/src/llm/model.ts
index 0bcb010..e48e2ba 100644
--- a/apps/desktop/src/llm/model.ts
+++ b/apps/desktop/src/llm/model.ts
@@ -1,8 +1,8 @@
-import { DEFAULT_LANES, LmStudioClient, type Lane } from "@lucid/llm";
+import { DEFAULT_LANES, LmStudioClient, type AiClient, type Lane } from "@lucid/llm";
import { bridgeFetch } from "./bridgeFetch";
export interface Model {
- client: LmStudioClient;
+ client: AiClient;
lanes: { input: Lane; command: Lane; advisor: Lane };
/** The model's short name, for messages. */
name: string;
diff --git a/docs/architecture.md b/docs/architecture.md
index c6ed065..8506c36 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -25,7 +25,7 @@ flowchart TD
|---|---|
| `packages/core` | Pure TypeScript, no DOM, no network. The document model (`model/`), the op language and its checks (`ops/`), the diagram language LGN and its Mermaid output (`lgn/`, `mermaid/`), the guided builders' grammar (`guided/`), grounding checks (`grounding/`), reverts, and the saved file format (`persistence/`). |
| `packages/kit` | The block kit. Each block type is a folder with its schema, a short description for the model, outline, Markdown and LaTeX output and fixtures. Also the note formats (`formats/`), the Lucid Markdown language the model writes (`lmd/`), the op compiler (`ops/`) and the LaTeX document builder (`latex/`). |
-| `packages/llm` | The LM Studio client (streamed `/v1/chat/completions`, reasoning off), the Input and Command pipelines, and the prompts. |
+| `packages/llm` | Lucid's provider-neutral AI contract, the LM Studio adapter (streamed `/v1/chat/completions`), the Input and Command pipelines, and the prompts. |
| `packages/mcp` | The MCP server and the socket protocol between it and the running app. |
| `packages/evals` | Eval cases and the runner that scores the model against them. |
| `packages/mock-lmstudio` | A scripted LM Studio for tests. |
@@ -52,6 +52,27 @@ flowchart TD
- **Guided builders:** a grammar in `packages/core/src/guided` reads "client to server", "rename 3 to Cache" and similar, and
builds diagrams and tables step by step; only what the grammar cannot read goes to the model, with the builder's numbering.
+## AI provider boundary
+
+Input, Command, Advisor, the desktop job queue, and evals depend on Lucid's `AiClient` interface in
+`packages/llm/src/provider.ts`; only application composition constructs `LmStudioClient`. The contract covers streamed text,
+`AbortSignal` cancellation, optional JSON Schema output, usage and timing results, model discovery, and optional local-model
+controls. Every adapter publishes a capability record, so unsupported behavior is data rather than an assumption. LM Studio
+currently advertises JSON Schema, streaming, cancellation, discovery, reasoning controls, and custom base URLs; it does not
+advertise in-app model loading because Lucid has not implemented that control yet.
+
+### Vercel AI SDK compatibility spike
+
+The SDK could sit behind `AiClient`, but it is not the Lucid contract. Its current `streamText` API accepts an abort signal,
+streams text, returns usage, and supports structured output from JSON Schema. Its OpenAI-compatible adapter accepts custom base
+URLs, provider options, structured-output flags, and streamed usage. Those cover the existing Input, Command, and Advisor call
+shape. Model discovery and local-model load/status are not portable SDK features, and reasoning switches remain
+provider-specific. Lucid therefore keeps those capabilities in its own adapter boundary. No SDK dependency is added in this
+spike: adopting it later is an adapter implementation choice and should be justified by a concrete provider migration.
+
+References: [AI SDK `streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) and
+[OpenAI-compatible providers](https://ai-sdk.dev/providers/openai-compatible-providers).
+
## PDF
The renderer draws each diagram and chart as SVG with Mermaid. The main process converts the SVG to vector PDF with Electron's
diff --git a/packages/evals/src/run.ts b/packages/evals/src/run.ts
index 626fb2c..34503a3 100644
--- a/packages/evals/src/run.ts
+++ b/packages/evals/src/run.ts
@@ -6,7 +6,7 @@ import {
runInput,
type ChatResult,
type Lane,
- type LmStudioClient,
+ type AiClient,
} from "@lucid/llm";
import { fixture } from "./fixtures";
import { evaluate, findBlock, unrelatedChangedCount, type Observed } from "./matchers";
@@ -45,7 +45,7 @@ export interface Trial {
}
export interface RunDeps {
- client: LmStudioClient;
+ client: AiClient;
lanes?: { input: Lane; command: Lane; advisor?: Lane } | undefined;
}
diff --git a/packages/llm/src/advisor.ts b/packages/llm/src/advisor.ts
index 24d11bd..00251e3 100644
--- a/packages/llm/src/advisor.ts
+++ b/packages/llm/src/advisor.ts
@@ -1,10 +1,10 @@
import { FORMATS, pickFormat } from "@lucid/kit";
-import type { ChatResult, LmStudioClient } from "./client";
+import type { AiClient, ChatResult } from "./provider";
import { DEFAULT_LANES, type Lane } from "./lanes";
import { advisorJsonSchema, buildAdvisorMessages } from "./prompts/advisor";
export interface AdvisorDeps {
- client: LmStudioClient;
+ client: AiClient;
lane?: Lane | undefined;
signal?: AbortSignal | undefined;
/** Development/eval telemetry hook. It receives counts and timings, never credentials. */
diff --git a/packages/llm/src/client.test.ts b/packages/llm/src/client.test.ts
index 9f9b844..3176789 100644
--- a/packages/llm/src/client.test.ts
+++ b/packages/llm/src/client.test.ts
@@ -29,6 +29,22 @@ const failure = async (p: Promise): Promise => {
};
describe("chat", () => {
+ it("advertises the LM Studio adapter capabilities explicitly", async () => {
+ const { client } = await start();
+ expect(client.provider).toBe("lmstudio");
+ expect(client.capabilities).toEqual({
+ streaming: true,
+ cancellation: true,
+ structuredOutput: "json-schema",
+ modelDiscovery: true,
+ usage: "provider-or-estimated",
+ reasoningControl: true,
+ localModelManagement: false,
+ customBaseUrl: true,
+ });
+ expect(client.localModels).toBeUndefined();
+ });
+
it("streams content, reports usage and timings, and sends the request Lucid needs", async () => {
const { mock, client } = await start({
script: [{ kind: "reply", content: "## Load balancer\n- one", chunkChars: 5 }],
@@ -74,11 +90,17 @@ describe("chat", () => {
script: [{ kind: "reply", content: '{"kind":"none","reason":"x"}' }],
});
const schema = { type: "object", properties: { kind: { type: "string" } } };
- await client.chat({ lane, messages, jsonSchema: { name: "lucid_command", schema } });
+ await client.chat({
+ lane,
+ messages,
+ jsonSchema: { name: "lucid_command", schema },
+ reasoning: "low",
+ });
expect(mock.requests[0]?.body["response_format"]).toEqual({
type: "json_schema",
json_schema: { name: "lucid_command", strict: true, schema },
});
+ expect(mock.requests[0]?.body["reasoning_effort"]).toBe("low");
});
it("retries a reasoning leak once and succeeds", async () => {
diff --git a/packages/llm/src/client.ts b/packages/llm/src/client.ts
index 1d53e7c..398660e 100644
--- a/packages/llm/src/client.ts
+++ b/packages/llm/src/client.ts
@@ -1,38 +1,14 @@
import { LlmError, isLlmError, type LlmErrorCode } from "./errors";
-import type { Lane } from "./lanes";
+import type {
+ AiClient,
+ ChatRequest,
+ ChatResult,
+ ProviderCapabilities,
+ ProviderHealth,
+} from "./provider";
import { readSse } from "./sse";
-export interface ChatMessage {
- role: "system" | "user" | "assistant";
- content: string;
-}
-
-export interface ChatRequest {
- lane: Lane;
- messages: ChatMessage[];
- /** Strict structured output (Command and advisor lanes). The Input lane streams plain text. */
- jsonSchema?: { name: string; schema: object };
- signal?: AbortSignal | undefined;
- /** Called with each piece of streamed content. */
- onDelta?: ((text: string) => void) | undefined;
-}
-
-export interface ChatResult {
- content: string;
- usage: {
- prompt: number;
- completion: number;
- reasoning: number;
- /** Provider counts are preferred; estimates are explicitly marked when a server omits usage. */
- source: "provider" | "estimated";
- };
- characters: { request: number; response: number };
- timings: { sentAt: number; firstTokenAt: number | null; doneAt: number };
- model: string;
- provider: "lmstudio";
- /** Automatic retries performed inside this client before returning the answer. */
- repairAttempts: number;
-}
+export type { ChatMessage, ChatRequest, ChatResult, ProviderHealth as Health } from "./provider";
export interface ClientOptions {
baseUrl?: string;
@@ -42,12 +18,6 @@ export interface ClientOptions {
requestTimeoutMs?: number;
}
-export interface Health {
- reachable: boolean;
- /** Ids of the models LM Studio reports. Empty means none is loaded. */
- models: string[];
-}
-
interface StreamChunk {
choices?: {
delta?: { content?: string; reasoning?: string; reasoning_content?: string };
@@ -65,7 +35,19 @@ const num = (v: unknown): number => (typeof v === "number" ? v : 0);
const estimateTokens = (text: string): number => Math.max(1, Math.ceil(text.length / 4));
/** The only request shape Lucid uses: /v1/chat/completions, streamed, with reasoning off. */
-export class LmStudioClient {
+export class LmStudioClient implements AiClient {
+ readonly provider = "lmstudio";
+ readonly localModels = undefined;
+ readonly capabilities: Readonly = Object.freeze({
+ streaming: true,
+ cancellation: true,
+ structuredOutput: "json-schema",
+ modelDiscovery: true,
+ usage: "provider-or-estimated",
+ reasoningControl: true,
+ localModelManagement: false,
+ customBaseUrl: true,
+ });
private readonly baseUrl: string;
private readonly doFetch: typeof fetch;
private readonly firstTokenTimeoutMs: number;
@@ -79,7 +61,7 @@ export class LmStudioClient {
}
/** Never throws: an unreachable server is just `{ reachable: false }`. */
- async health(signal?: AbortSignal): Promise {
+ async health(signal?: AbortSignal): Promise {
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
@@ -131,7 +113,7 @@ export class LmStudioClient {
top_k: lane.topK,
presence_penalty: lane.presencePenalty,
max_tokens: lane.maxTokens,
- reasoning_effort: "none",
+ reasoning_effort: request.reasoning ?? "none",
ttl: 3600,
...(request.jsonSchema
? {
@@ -310,7 +292,7 @@ export class LmStudioClient {
characters: { request: requestCharacters, response: content.length },
timings: { sentAt, firstTokenAt, doneAt: Date.now() },
model,
- provider: "lmstudio",
+ provider: this.provider,
repairAttempts: 0,
};
} finally {
diff --git a/packages/llm/src/commandPipeline.ts b/packages/llm/src/commandPipeline.ts
index 12dd87f..7fdb800 100644
--- a/packages/llm/src/commandPipeline.ts
+++ b/packages/llm/src/commandPipeline.ts
@@ -14,12 +14,12 @@ import {
type Op,
} from "@lucid/core";
import { compileOps, OpError } from "@lucid/kit";
-import type { ChatMessage, ChatResult, LmStudioClient } from "./client";
+import type { AiClient, ChatMessage, ChatResult } from "./provider";
import { DEFAULT_LANES, type Lane } from "./lanes";
import { buildCommandMessages, endsWithAnswer, type CommandContext } from "./prompts/command";
export interface CommandDeps {
- client: LmStudioClient;
+ client: AiClient;
lane?: Lane | undefined;
signal?: AbortSignal | undefined;
onDelta?: ((text: string) => void) | undefined;
diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts
index 0b779fe..1ae7201 100644
--- a/packages/llm/src/index.ts
+++ b/packages/llm/src/index.ts
@@ -1,4 +1,5 @@
export * from "./client";
+export * from "./provider";
export * from "./errors";
export * from "./lanes";
export { readSse } from "./sse";
diff --git a/packages/llm/src/input.ts b/packages/llm/src/input.ts
index d5acc10..03376ae 100644
--- a/packages/llm/src/input.ts
+++ b/packages/llm/src/input.ts
@@ -17,12 +17,12 @@ import {
PlacementError,
type Placement,
} from "@lucid/kit";
-import { LmStudioClient, type ChatMessage, type ChatResult } from "./client";
+import type { AiClient, ChatMessage, ChatResult } from "./provider";
import { DEFAULT_LANES, type Lane } from "./lanes";
import { buildInputMessages, type InputContext } from "./prompts/input";
export interface InputDeps {
- client: LmStudioClient;
+ client: AiClient;
lane?: Lane | undefined;
signal?: AbortSignal | undefined;
/** Streamed text, for the ghost preview. */
diff --git a/packages/llm/src/provider.ts b/packages/llm/src/provider.ts
new file mode 100644
index 0000000..4479d32
--- /dev/null
+++ b/packages/llm/src/provider.ts
@@ -0,0 +1,71 @@
+import type { Lane } from "./lanes";
+
+export interface ChatMessage {
+ role: "system" | "user" | "assistant";
+ content: string;
+}
+
+export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high";
+
+export interface ChatRequest {
+ lane: Lane;
+ messages: ChatMessage[];
+ /** Strict structured output (Command and Advisor lanes). Input streams plain text. */
+ jsonSchema?: { name: string; schema: object } | undefined;
+ /** Used only when the provider advertises reasoningControl. */
+ reasoning?: ReasoningEffort | undefined;
+ signal?: AbortSignal | undefined;
+ /** Called with each piece of streamed content. */
+ onDelta?: ((text: string) => void) | undefined;
+}
+
+export interface ChatResult {
+ content: string;
+ usage: {
+ prompt: number;
+ completion: number;
+ reasoning: number;
+ /** Provider counts are preferred; estimates are explicitly marked when omitted. */
+ source: "provider" | "estimated";
+ };
+ characters: { request: number; response: number };
+ timings: { sentAt: number; firstTokenAt: number | null; doneAt: number };
+ model: string;
+ provider: string;
+ /** Automatic retries performed inside the provider adapter before returning the answer. */
+ repairAttempts: number;
+}
+
+export interface ProviderHealth {
+ reachable: boolean;
+ /** Model ids discovered from the provider. Empty may also mean discovery is unsupported. */
+ models: string[];
+}
+
+export interface LocalModelControl {
+ status(
+ model: string,
+ signal?: AbortSignal,
+ ): Promise<"loaded" | "unloaded" | "loading" | "unknown">;
+ load(model: string, signal?: AbortSignal): Promise;
+}
+
+export interface ProviderCapabilities {
+ streaming: boolean;
+ cancellation: boolean;
+ structuredOutput: "json-schema" | "json-object" | "none";
+ modelDiscovery: boolean;
+ usage: "provider-or-estimated" | "estimated" | "none";
+ reasoningControl: boolean;
+ localModelManagement: boolean;
+ customBaseUrl: boolean;
+}
+
+/** Lucid's stable boundary. Provider SDKs and wire protocols stay behind this interface. */
+export interface AiClient {
+ readonly provider: string;
+ readonly capabilities: Readonly;
+ readonly localModels?: LocalModelControl | undefined;
+ chat(request: ChatRequest): Promise;
+ health(signal?: AbortSignal): Promise;
+}
From 05f7769be349797c2f44a9a680d919290bb95d84 Mon Sep 17 00:00:00 2001
From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com>
Date: Sun, 18 Oct 2026 20:55:51 +0530
Subject: [PATCH 03/13] feat(desktop): add secure main-process AI service (#36)
---
apps/desktop/electron/ai/credentials.test.ts | 65 +++++++++++
apps/desktop/electron/ai/credentials.ts | 113 +++++++++++++++++++
apps/desktop/electron/ai/security.test.ts | 75 ++++++++++++
apps/desktop/electron/ai/security.ts | 63 +++++++++++
apps/desktop/electron/ai/service.test.ts | 85 ++++++++++++++
apps/desktop/electron/ai/service.ts | 79 +++++++++++++
apps/desktop/electron/ipc/ai.ts | 35 ++++++
apps/desktop/electron/ipc/index.ts | 2 +
apps/desktop/electron/ipc/types.ts | 67 +++++++++++
apps/desktop/electron/preload.ts | 24 ++++
docs/architecture.md | 9 ++
11 files changed, 617 insertions(+)
create mode 100644 apps/desktop/electron/ai/credentials.test.ts
create mode 100644 apps/desktop/electron/ai/credentials.ts
create mode 100644 apps/desktop/electron/ai/security.test.ts
create mode 100644 apps/desktop/electron/ai/security.ts
create mode 100644 apps/desktop/electron/ai/service.test.ts
create mode 100644 apps/desktop/electron/ai/service.ts
create mode 100644 apps/desktop/electron/ipc/ai.ts
diff --git a/apps/desktop/electron/ai/credentials.test.ts b/apps/desktop/electron/ai/credentials.test.ts
new file mode 100644
index 0000000..d86ad89
--- /dev/null
+++ b/apps/desktop/electron/ai/credentials.test.ts
@@ -0,0 +1,65 @@
+import { mkdtemp, readFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { CredentialVault, type SecretEncryption } from "./credentials";
+
+const dirs: string[] = [];
+afterEach(async () => Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true }))));
+
+const encryption = (available = true): SecretEncryption => ({
+ secureAvailable: () => available,
+ encrypt: (value) => Buffer.from(`encrypted:${value}`, "utf8"),
+ decrypt: (value) => value.toString("utf8").replace(/^encrypted:/, ""),
+});
+
+async function vault(available = true) {
+ const dir = await mkdtemp(join(tmpdir(), "lucid-credentials-"));
+ dirs.push(dir);
+ return { dir, vault: new CredentialVault(dir, encryption(available)) };
+}
+
+describe("CredentialVault", () => {
+ it("saves, reads, and deletes an encrypted persistent secret without writing plaintext", async () => {
+ const { dir, vault: first } = await vault();
+ const status = await first.save("openai", "sk-private-value", true);
+ expect(status).toEqual({ present: true, storage: "secure", securePersistenceAvailable: true });
+ expect(await first.read("openai")).toBe("sk-private-value");
+ expect(await readFile(join(dir, "credentials.json"), "utf8")).not.toContain("sk-private-value");
+
+ const reopened = new CredentialVault(dir, encryption());
+ expect(await reopened.read("openai")).toBe("sk-private-value");
+ await reopened.delete("openai");
+ expect(await reopened.status("openai")).toMatchObject({ present: false, storage: "none" });
+ });
+
+ it("falls back only to memory when secure persistence is unavailable", async () => {
+ const { dir, vault: current } = await vault(false);
+ expect(await current.save("anthropic", "session-secret", true)).toEqual({
+ present: true,
+ storage: "session",
+ securePersistenceAvailable: false,
+ });
+ expect(await current.read("anthropic")).toBe("session-secret");
+ await expect(readFile(join(dir, "credentials.json"), "utf8")).rejects.toThrow();
+ expect(await new CredentialVault(dir, encryption(false)).read("anthropic")).toBeUndefined();
+ });
+
+ it("uses session storage when encryption fails temporarily", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "lucid-credentials-"));
+ dirs.push(dir);
+ const failing: SecretEncryption = {
+ secureAvailable: () => true,
+ encrypt: () => {
+ throw new Error("keychain unavailable");
+ },
+ decrypt: () => "",
+ };
+ const current = new CredentialVault(dir, failing);
+ expect(await current.save("openrouter", "temporary-secret", true)).toMatchObject({
+ present: true,
+ storage: "session",
+ });
+ expect(await current.read("openrouter")).toBe("temporary-secret");
+ });
+});
diff --git a/apps/desktop/electron/ai/credentials.ts b/apps/desktop/electron/ai/credentials.ts
new file mode 100644
index 0000000..77abca5
--- /dev/null
+++ b/apps/desktop/electron/ai/credentials.ts
@@ -0,0 +1,113 @@
+import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+
+export interface SecretEncryption {
+ secureAvailable(): boolean;
+ encrypt(value: string): Buffer;
+ decrypt(value: Buffer): string;
+}
+
+export type CredentialStorage = "secure" | "session";
+export interface CredentialStatus {
+ present: boolean;
+ storage: CredentialStorage | "none";
+ securePersistenceAvailable: boolean;
+}
+
+interface StoredSecrets {
+ version: 1;
+ secrets: Record;
+}
+
+const credentialId = (value: string): string => {
+ if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(value)) throw new Error("invalid credential id");
+ return value;
+};
+const without = (values: Record, id: string): Record =>
+ Object.fromEntries(Object.entries(values).filter(([key]) => key !== id));
+
+/** Main-process-only credentials. Disk values are encrypted bytes encoded as base64, never plaintext. */
+export class CredentialVault {
+ private readonly session = new Map();
+ private readonly file: string;
+
+ constructor(
+ directory: string,
+ private readonly encryption: SecretEncryption,
+ ) {
+ this.file = join(directory, "credentials.json");
+ }
+
+ private async stored(): Promise {
+ try {
+ const parsed = JSON.parse(await readFile(this.file, "utf8")) as Partial;
+ return parsed.version === 1 && parsed.secrets && typeof parsed.secrets === "object"
+ ? { version: 1, secrets: { ...parsed.secrets } }
+ : { version: 1, secrets: {} };
+ } catch {
+ return { version: 1, secrets: {} };
+ }
+ }
+
+ private async write(stored: StoredSecrets): Promise {
+ await mkdir(dirname(this.file), { recursive: true });
+ const temporary = `${this.file}.${String(process.pid)}.tmp`;
+ await writeFile(temporary, `${JSON.stringify(stored, null, 2)}\n`, {
+ encoding: "utf8",
+ mode: 0o600,
+ });
+ await rename(temporary, this.file);
+ }
+
+ async save(idValue: string, secret: string, persist: boolean): Promise {
+ const id = credentialId(idValue);
+ if (!secret || secret.length > 20_000) throw new Error("invalid credential");
+ const stored = await this.stored();
+ if (persist && this.encryption.secureAvailable()) {
+ try {
+ stored.secrets[id] = this.encryption.encrypt(secret).toString("base64");
+ await this.write(stored);
+ this.session.delete(id);
+ return await this.status(id);
+ } catch {
+ // Temporary keychain failures fall through to memory. Never write the secret itself.
+ }
+ }
+ if (id in stored.secrets) {
+ stored.secrets = without(stored.secrets, id);
+ await this.write(stored);
+ }
+ this.session.set(id, secret);
+ return this.status(id);
+ }
+
+ /** Deliberately not exposed through preload or IPC. */
+ async read(idValue: string): Promise {
+ const id = credentialId(idValue);
+ const inSession = this.session.get(id);
+ if (inSession !== undefined) return inSession;
+ if (!this.encryption.secureAvailable()) return undefined;
+ const encrypted = (await this.stored()).secrets[id];
+ if (!encrypted) return undefined;
+ return this.encryption.decrypt(Buffer.from(encrypted, "base64"));
+ }
+
+ async delete(idValue: string): Promise {
+ const id = credentialId(idValue);
+ this.session.delete(id);
+ const stored = await this.stored();
+ if (id in stored.secrets) {
+ stored.secrets = without(stored.secrets, id);
+ await this.write(stored);
+ }
+ }
+
+ async status(idValue: string): Promise {
+ const id = credentialId(idValue);
+ const securePersistenceAvailable = this.encryption.secureAvailable();
+ if (this.session.has(id))
+ return { present: true, storage: "session", securePersistenceAvailable };
+ const secure = securePersistenceAvailable && id in (await this.stored()).secrets;
+ return { present: secure, storage: secure ? "secure" : "none", securePersistenceAvailable };
+ }
+}
diff --git a/apps/desktop/electron/ai/security.test.ts b/apps/desktop/electron/ai/security.test.ts
new file mode 100644
index 0000000..af24087
--- /dev/null
+++ b/apps/desktop/electron/ai/security.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from "vitest";
+import {
+ redactSensitive,
+ secureProviderFetch,
+ validateProviderEndpoint,
+ validateProviderRedirect,
+} from "./security";
+
+describe("provider endpoint security", () => {
+ it.each([
+ "https://api.openai.com/v1",
+ "https://models.example.test/custom/v1",
+ "http://127.0.0.1:11434/v1",
+ "http://localhost:1234/v1",
+ "http://[::1]:8080/v1",
+ ])("allows %s", (value) => {
+ expect(validateProviderEndpoint(value).href).toContain("://");
+ });
+
+ it.each([
+ "http://example.com/v1",
+ "ftp://example.com/v1",
+ "file:///tmp/secret",
+ "https://key@example.com/v1",
+ "https://example.com/v1?api_key=secret",
+ "not a URL",
+ ])("refuses %s", (value) => {
+ expect(() => validateProviderEndpoint(value)).toThrow();
+ });
+
+ it("allows only validated same-origin redirects", () => {
+ const from = validateProviderEndpoint("https://api.example.com/v1/chat");
+ expect(validateProviderRedirect(from, "/v2/chat").href).toBe("https://api.example.com/v2/chat");
+ expect(() => validateProviderRedirect(from, "https://elsewhere.example/v1")).toThrow(
+ /another origin/,
+ );
+ expect(() => validateProviderRedirect(from, "http://api.example.com/v1")).toThrow();
+ });
+
+ it("manually validates a redirect before following it", async () => {
+ const fetcher = (input: string | URL | Request) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ return Promise.resolve(
+ url.endsWith("/first")
+ ? new Response(null, { status: 307, headers: { location: "/second" } })
+ : new Response("ok", { status: 200 }),
+ );
+ };
+ expect(
+ await (await secureProviderFetch("https://api.example.com/first", {}, fetcher)).text(),
+ ).toBe("ok");
+ await expect(
+ secureProviderFetch("https://api.example.com/first", {}, async () =>
+ Promise.resolve(
+ new Response(null, {
+ status: 302,
+ headers: { location: "https://attacker.example/steal" },
+ }),
+ ),
+ ),
+ ).rejects.toThrow(/another origin/);
+ });
+
+ it("redacts bearer tokens, query credentials, and known secrets", () => {
+ const secret = "sk-super-secret";
+ const text = redactSensitive(
+ new Error(`Bearer abc123 failed at https://x.test?a=1&api_key=query-secret: ${secret}`),
+ [secret],
+ );
+ expect(text).not.toContain("abc123");
+ expect(text).not.toContain("query-secret");
+ expect(text).not.toContain(secret);
+ expect(text).toContain("[redacted]");
+ });
+});
diff --git a/apps/desktop/electron/ai/security.ts b/apps/desktop/electron/ai/security.ts
new file mode 100644
index 0000000..c0d768b
--- /dev/null
+++ b/apps/desktop/electron/ai/security.ts
@@ -0,0 +1,63 @@
+const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
+
+/** Cloud endpoints require HTTPS; plaintext HTTP is allowed only on this machine. */
+export function validateProviderEndpoint(value: string): URL {
+ let url: URL;
+ try {
+ url = new URL(value);
+ } catch {
+ throw new Error("The provider address is not a valid URL.");
+ }
+ if (url.username || url.password)
+ throw new Error("Provider credentials must not be included in the address.");
+ if (url.hash || url.search)
+ throw new Error("The provider address cannot include a query or fragment.");
+ const secure = url.protocol === "https:";
+ const local = url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
+ if (!secure && !local)
+ throw new Error("Provider addresses must use HTTPS; HTTP is allowed only on this computer.");
+ return url;
+}
+
+/** Redirects are deliberately same-origin so an Authorization header can never cross origins. */
+export function validateProviderRedirect(from: URL, location: string): URL {
+ const target = validateProviderEndpoint(new URL(location, from).href);
+ if (target.origin !== from.origin) throw new Error("The provider redirected to another origin.");
+ return target;
+}
+
+/** Fetch with redirects disabled at the platform layer and revalidated before following. */
+export async function secureProviderFetch(
+ input: string | URL | Request,
+ init: RequestInit = {},
+ doFetch: typeof fetch = fetch,
+): Promise {
+ let url = validateProviderEndpoint(
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
+ );
+ let options = { ...init };
+ for (let redirects = 0; redirects <= 5; redirects++) {
+ const response = await doFetch(url, { ...options, redirect: "manual" });
+ if (response.status < 300 || response.status >= 400) return response;
+ const location = response.headers.get("location");
+ if (!location) throw new Error("The provider returned a redirect without a destination.");
+ if (redirects === 5) throw new Error("The provider redirected too many times.");
+ url = validateProviderRedirect(url, location);
+ if (response.status === 303) {
+ const { body: _body, ...withoutBody } = options;
+ options = { ...withoutBody, method: "GET" };
+ }
+ }
+ throw new Error("The provider redirected too many times.");
+}
+
+/** A last line of defence for errors that may be shown or logged. */
+export function redactSensitive(value: unknown, secrets: readonly string[] = []): string {
+ let text = value instanceof Error ? value.message : String(value);
+ text = text.replace(/\b(Bearer|Basic)\s+[^\s,;]+/gi, "$1 [redacted]");
+ text = text.replace(/([?&](?:api[_-]?key|token|access_token|key)=)[^&\s]+/gi, "$1[redacted]");
+ for (const secret of [...secrets].filter(Boolean).sort((a, b) => b.length - a.length)) {
+ text = text.split(secret).join("[redacted]");
+ }
+ return text.slice(0, 500);
+}
diff --git a/apps/desktop/electron/ai/service.test.ts b/apps/desktop/electron/ai/service.test.ts
new file mode 100644
index 0000000..e00da8a
--- /dev/null
+++ b/apps/desktop/electron/ai/service.test.ts
@@ -0,0 +1,85 @@
+import type { AiClient } from "@lucid/llm";
+import { describe, expect, it, vi } from "vitest";
+import type { AiEvent, AiStartRequest } from "../ipc/types";
+import { MainAiService } from "./service";
+
+const request: AiStartRequest = {
+ id: 7,
+ providerId: "test",
+ model: "model",
+ messages: [{ role: "user", content: "hello" }],
+ lane: { temperature: 0, topP: 1, topK: 20, presencePenalty: 0, maxTokens: 100 },
+};
+
+const client = (chat: AiClient["chat"]): AiClient => ({
+ provider: "test",
+ capabilities: {
+ streaming: true,
+ cancellation: true,
+ structuredOutput: "json-schema",
+ modelDiscovery: false,
+ usage: "estimated",
+ reasoningControl: false,
+ localModelManagement: false,
+ customBaseUrl: false,
+ },
+ chat,
+ health: () => Promise.resolve({ reachable: true, models: [] }),
+});
+
+describe("MainAiService", () => {
+ it("streams deltas and a secret-free completion through narrow events", async () => {
+ const emit = vi.fn<(event: AiEvent) => void>();
+ const service = new MainAiService(() =>
+ Promise.resolve({
+ endpoint: "https://api.example.test/v1",
+ client: client(({ onDelta }) => {
+ onDelta?.("hello");
+ return Promise.resolve({
+ content: "hello",
+ usage: { prompt: 2, completion: 1, reasoning: 0, source: "estimated" },
+ characters: { request: 5, response: 5 },
+ timings: { sentAt: 1, firstTokenAt: 2, doneAt: 3 },
+ model: "model",
+ provider: "test",
+ repairAttempts: 0,
+ });
+ }),
+ }),
+ );
+ expect(await service.start(request, emit)).toEqual({ ok: true });
+ await vi.waitFor(() => {
+ expect(emit).toHaveBeenCalledTimes(2);
+ });
+ expect(emit.mock.calls[0]?.[0]).toEqual({ id: 7, type: "delta", text: "hello" });
+ expect(emit.mock.calls[1]?.[0]).toMatchObject({ id: 7, type: "complete" });
+ expect(emit.mock.calls[1]?.[0]).not.toHaveProperty("result.content");
+ });
+
+ it("cancels through AbortSignal and redacts provider errors", async () => {
+ const emit = vi.fn<(event: AiEvent) => void>();
+ const service = new MainAiService(() =>
+ Promise.resolve({
+ endpoint: "https://api.example.test/v1",
+ secrets: ["sk-secret"],
+ client: client(
+ ({ signal }) =>
+ new Promise((_resolve, reject) => {
+ signal?.addEventListener("abort", () => {
+ reject(new Error("Bearer sk-secret cancelled"));
+ });
+ }),
+ ),
+ }),
+ );
+ expect(await service.start(request, emit)).toEqual({ ok: true });
+ service.abort(request.id);
+ await vi.waitFor(() => {
+ expect(emit).toHaveBeenCalledOnce();
+ });
+ expect(emit.mock.calls[0]?.[0]).toMatchObject({
+ type: "error",
+ message: "Bearer [redacted] cancelled",
+ });
+ });
+});
diff --git a/apps/desktop/electron/ai/service.ts b/apps/desktop/electron/ai/service.ts
new file mode 100644
index 0000000..9a31d36
--- /dev/null
+++ b/apps/desktop/electron/ai/service.ts
@@ -0,0 +1,79 @@
+import type { AiClient, ChatResult, Lane } from "@lucid/llm";
+import type { AiEvent, AiStartRequest, AiStartResult } from "../ipc/types";
+import { redactSensitive, validateProviderEndpoint } from "./security";
+
+export interface AiRuntime {
+ client: AiClient;
+ /** Main-owned endpoint configuration, never supplied in an individual renderer request. */
+ endpoint: string;
+ /** Known credentials are used only to scrub provider errors before they leave main. */
+ secrets?: readonly string[] | undefined;
+}
+export type AiRuntimeResolver = (providerId: string) => Promise;
+
+const validRequest = (request: AiStartRequest): boolean =>
+ Number.isSafeInteger(request.id) &&
+ request.id >= 0 &&
+ /^[a-z0-9][a-z0-9._-]{0,79}$/i.test(request.providerId) &&
+ request.model.length > 0 &&
+ request.model.length <= 240 &&
+ request.messages.length > 0 &&
+ request.messages.length <= 100 &&
+ request.messages.every((message) => message.content.length <= 1_000_000);
+
+export class MainAiService {
+ private readonly running = new Map();
+
+ constructor(private readonly resolve: AiRuntimeResolver) {}
+
+ async start(request: AiStartRequest, emit: (event: AiEvent) => void): Promise {
+ if (!validRequest(request)) return { ok: false, message: "Invalid AI request." };
+ if (this.running.has(request.id))
+ return { ok: false, message: "That AI request is already running." };
+ const runtime = await this.resolve(request.providerId);
+ if (!runtime) return { ok: false, message: "That AI provider is not configured." };
+ try {
+ validateProviderEndpoint(runtime.endpoint);
+ } catch (error) {
+ return { ok: false, message: redactSensitive(error, runtime.secrets) };
+ }
+ const controller = new AbortController();
+ this.running.set(request.id, controller);
+ const lane: Lane = { ...request.lane, model: request.model };
+ void runtime.client
+ .chat({
+ lane,
+ messages: request.messages,
+ jsonSchema: request.jsonSchema,
+ reasoning: request.reasoning,
+ signal: controller.signal,
+ onDelta: (text) => {
+ emit({ id: request.id, type: "delta", text });
+ },
+ })
+ .then((result: ChatResult) => {
+ const { content: _content, ...safeResult } = result;
+ emit({ id: request.id, type: "complete", result: safeResult });
+ })
+ .catch((error: unknown) => {
+ const code =
+ error instanceof Error &&
+ "code" in error &&
+ typeof (error as { code?: unknown }).code === "string"
+ ? (error as { code: string }).code
+ : "ProviderError";
+ emit({
+ id: request.id,
+ type: "error",
+ code,
+ message: redactSensitive(error, runtime.secrets),
+ });
+ })
+ .finally(() => this.running.delete(request.id));
+ return { ok: true };
+ }
+
+ abort(id: number): void {
+ this.running.get(id)?.abort();
+ }
+}
diff --git a/apps/desktop/electron/ipc/ai.ts b/apps/desktop/electron/ipc/ai.ts
new file mode 100644
index 0000000..7c58fb2
--- /dev/null
+++ b/apps/desktop/electron/ipc/ai.ts
@@ -0,0 +1,35 @@
+import { ipcMain, safeStorage } from "electron";
+import { join } from "node:path";
+import { CredentialVault, type SecretEncryption } from "../ai/credentials";
+import { MainAiService, type AiRuntimeResolver } from "../ai/service";
+import { IPC, type AiEvent, type AiStartRequest, type CredentialSaveRequest } from "./types";
+
+const encryption: SecretEncryption = {
+ secureAvailable: () =>
+ safeStorage.isEncryptionAvailable() &&
+ (process.platform !== "linux" || safeStorage.getSelectedStorageBackend() !== "basic_text"),
+ encrypt: (value) => safeStorage.encryptString(value),
+ decrypt: (value) => safeStorage.decryptString(value),
+};
+
+export function registerSecureAiIpc(
+ libraryDir: string,
+ resolve: AiRuntimeResolver = () => Promise.resolve(undefined),
+): CredentialVault {
+ const vault = new CredentialVault(join(libraryDir, ".lucid"), encryption);
+ const service = new MainAiService(resolve);
+ ipcMain.handle(IPC.aiStart, async (event, request: AiStartRequest) =>
+ service.start(request, (aiEvent: AiEvent) => {
+ if (!event.sender.isDestroyed()) event.sender.send(IPC.aiEvent, aiEvent);
+ }),
+ );
+ ipcMain.handle(IPC.aiAbort, (_event, id: unknown) => {
+ if (typeof id === "number") service.abort(id);
+ });
+ ipcMain.handle(IPC.credentialSave, (_event, request: CredentialSaveRequest) =>
+ vault.save(request.id, request.secret, request.persist),
+ );
+ ipcMain.handle(IPC.credentialDelete, (_event, id: string) => vault.delete(id));
+ ipcMain.handle(IPC.credentialStatus, (_event, id: string) => vault.status(id));
+ return vault;
+}
diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts
index c326c87..67126a9 100644
--- a/apps/desktop/electron/ipc/index.ts
+++ b/apps/desktop/electron/ipc/index.ts
@@ -23,6 +23,7 @@ import { RendererBridge } from "../agentBridge";
import { lmLoad, lmStatus, type LmDeps } from "../lmstudio";
import { LMSTUDIO_DOWNLOAD, defaultLmsPath, findLmStudio } from "../studio";
import { checkLlmUrl, registerLlmIpc } from "./llm";
+import { registerSecureAiIpc } from "./ai";
import {
IPC,
type NoteImportResult,
@@ -92,6 +93,7 @@ export function registerIpc(): void {
(await library).save(parseLucidFile(file)),
);
registerLlmIpc();
+ registerSecureAiIpc(libraryDir());
const lmDeps = async (): Promise => {
const s = await loadSettings(libraryDir());
const baseUrl = process.env["LUCID_LLM_URL"] ?? s.llm.baseUrl;
diff --git a/apps/desktop/electron/ipc/types.ts b/apps/desktop/electron/ipc/types.ts
index 9fc3122..48a577f 100644
--- a/apps/desktop/electron/ipc/types.ts
+++ b/apps/desktop/electron/ipc/types.ts
@@ -134,6 +134,56 @@ export type LlmEvent =
| { id: number; type: "end" }
| { id: number; type: "error"; message: string };
+export interface AiStartRequest {
+ id: number;
+ /** Resolves to main-process configuration; it is not an endpoint or credential. */
+ providerId: string;
+ model: string;
+ messages: { role: "system" | "user" | "assistant"; content: string }[];
+ lane: {
+ temperature: number;
+ topP: number;
+ topK: number;
+ presencePenalty: number;
+ maxTokens: number;
+ };
+ jsonSchema?: { name: string; schema: object } | undefined;
+ reasoning?: "none" | "minimal" | "low" | "medium" | "high" | undefined;
+}
+export type AiStartResult = { ok: true } | { ok: false; message: string };
+export type AiEvent =
+ | { id: number; type: "delta"; text: string }
+ | {
+ id: number;
+ type: "complete";
+ result: {
+ usage: {
+ prompt: number;
+ completion: number;
+ reasoning: number;
+ source: "provider" | "estimated";
+ };
+ characters: { request: number; response: number };
+ timings: { sentAt: number; firstTokenAt: number | null; doneAt: number };
+ model: string;
+ provider: string;
+ repairAttempts: number;
+ };
+ }
+ | { id: number; type: "error"; code: string; message: string };
+
+export interface CredentialSaveRequest {
+ id: string;
+ secret: string;
+ /** When secure persistence is unavailable, the result reports session storage instead. */
+ persist: boolean;
+}
+export interface CredentialStatus {
+ present: boolean;
+ storage: "secure" | "session" | "none";
+ securePersistenceAvailable: boolean;
+}
+
/** The PDF template (`lucid.sty`): the person's own, or the one that comes with Lucid. */
export interface TemplateState {
custom: boolean;
@@ -173,6 +223,12 @@ export const IPC = {
agentReply: "lucid:agent-reply",
pagesChanged: "lucid:pages-changed",
llmEvent: "lucid:llm-event",
+ aiStart: "lucid:ai-start",
+ aiAbort: "lucid:ai-abort",
+ aiEvent: "lucid:ai-event",
+ credentialSave: "lucid:credential-save",
+ credentialDelete: "lucid:credential-delete",
+ credentialStatus: "lucid:credential-status",
} as const;
export interface LucidApi {
@@ -212,6 +268,17 @@ export interface LucidApi {
/** Returns a function that stops listening. */
onEvent(listener: (event: LlmEvent) => void): () => void;
};
+ ai: {
+ start(request: AiStartRequest): Promise;
+ abort(id: number): Promise;
+ onEvent(listener: (event: AiEvent) => void): () => void;
+ };
+ credentials: {
+ /** Saves a secret but never returns it. */
+ save(request: CredentialSaveRequest): Promise;
+ delete(id: string): Promise;
+ status(id: string): Promise;
+ };
lm: {
status(): Promise;
/** Loads the configured model in LM Studio (after the person agreed), unloading others first. */
diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts
index 9762a76..f812b49 100644
--- a/apps/desktop/electron/preload.ts
+++ b/apps/desktop/electron/preload.ts
@@ -1,6 +1,7 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron";
import {
IPC,
+ type AiEvent,
type AgentReply,
type AgentRequest,
type LlmEvent,
@@ -96,6 +97,29 @@ const api: LucidApi = {
};
},
},
+ ai: {
+ start: (request) =>
+ ipcRenderer.invoke(IPC.aiStart, request) as ReturnType,
+ abort: (id) => ipcRenderer.invoke(IPC.aiAbort, id) as Promise,
+ onEvent: (listener) => {
+ const handler = (_event: IpcRendererEvent, aiEvent: AiEvent) => {
+ listener(aiEvent);
+ };
+ ipcRenderer.on(IPC.aiEvent, handler);
+ return () => {
+ ipcRenderer.removeListener(IPC.aiEvent, handler);
+ };
+ },
+ },
+ credentials: {
+ save: (request) =>
+ ipcRenderer.invoke(IPC.credentialSave, request) as ReturnType<
+ LucidApi["credentials"]["save"]
+ >,
+ delete: (id) => ipcRenderer.invoke(IPC.credentialDelete, id) as Promise,
+ status: (id) =>
+ ipcRenderer.invoke(IPC.credentialStatus, id) as ReturnType,
+ },
lm: {
status: () => ipcRenderer.invoke(IPC.lmStatus) as Promise,
load: () => ipcRenderer.invoke(IPC.lmLoad) as Promise,
diff --git a/docs/architecture.md b/docs/architecture.md
index 8506c36..08cbd7d 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -73,6 +73,15 @@ spike: adopting it later is an adapter implementation choice and should be justi
References: [AI SDK `streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) and
[OpenAI-compatible providers](https://ai-sdk.dev/providers/openai-compatible-providers).
+Provider requests and credentials cross a second, narrower boundary in the desktop app. The renderer can start or abort a
+configured provider job and receive text/result events; it cannot submit arbitrary URLs, headers, or methods. Provider runtime
+configuration and requests execute in Electron's main process. Credentials are never returned through preload: the renderer
+can only save, delete, or ask whether one is present. Persistent values are encrypted with Electron `safeStorage`; Linux's
+`basic_text` backend and temporary encryption failures use memory for that app session instead of writing plaintext. Provider
+addresses require HTTPS except for loopback HTTP, and redirects are followed only after validation and only on the same origin.
+The old loopback-only LM Studio fetch bridge remains temporarily for compatibility and is removed when LM Studio moves onto
+this service.
+
## PDF
The renderer draws each diagram and chart as SVG with Mermaid. The main process converts the SVG to vector PDF with Electron's
From 054a8bed9b44a1fc69c1b5b854132e04828943f4 Mon Sep 17 00:00:00 2001
From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com>
Date: Mon, 19 Oct 2026 00:45:02 +0530
Subject: [PATCH 04/13] feat(settings): add AI provider profiles (#37)
---
apps/desktop/e2e/settings.spec.ts | 28 +-
apps/desktop/electron/ai/providers.test.ts | 100 +++++
apps/desktop/electron/ai/providers.ts | 89 +++++
apps/desktop/electron/ipc/ai.ts | 22 +-
apps/desktop/electron/ipc/index.ts | 33 +-
apps/desktop/electron/ipc/types.ts | 87 +++++
apps/desktop/electron/preload.ts | 6 +
apps/desktop/electron/settings.test.ts | 59 +++
apps/desktop/electron/settings.ts | 108 +++++-
.../desktop/src/settings/ProviderSettings.tsx | 350 ++++++++++++++++++
apps/desktop/src/settings/SettingsDialog.tsx | 76 ++--
apps/desktop/src/styles.css | 129 +++++++
12 files changed, 1038 insertions(+), 49 deletions(-)
create mode 100644 apps/desktop/electron/ai/providers.test.ts
create mode 100644 apps/desktop/electron/ai/providers.ts
create mode 100644 apps/desktop/src/settings/ProviderSettings.tsx
diff --git a/apps/desktop/e2e/settings.spec.ts b/apps/desktop/e2e/settings.spec.ts
index cbcd0d1..655c286 100644
--- a/apps/desktop/e2e/settings.spec.ts
+++ b/apps/desktop/e2e/settings.spec.ts
@@ -39,7 +39,7 @@ const dialog = (page: Page) => page.getByRole("dialog", { name: "Settings" });
/** A field of the dialog by the start of its label (the labels carry a help line too). */
const field = (page: Page, label: RegExp) => dialog(page).getByRole("textbox", { name: label });
const MODEL = /^Model\b/;
-const ADDRESS = /^Address/;
+const ADDRESS = /^Base URL/;
const NAME = /^Your name/;
const FOOTER = /^Footer/;
const WORDS = /^One per line/;
@@ -110,6 +110,32 @@ test("Settings opens from the sidebar and with ⌘, , closes with Esc, and shows
}
});
+test("provider profiles expose only relevant controls and keep cloud use explicit", async () => {
+ test.setTimeout(60_000);
+ const { app, page } = await launch();
+ try {
+ await page.getByRole("button", { name: /Settings/ }).click();
+ const box = dialog(page);
+ await expect(box.getByRole("group", { name: "AI providers" })).toContainText(
+ "never switches providers automatically",
+ );
+ await box.getByLabel("Provider to add").selectOption("openai");
+ await box.getByRole("button", { name: "Add", exact: true }).click();
+ await expect(field(page, /^API key/)).toBeVisible();
+ await expect(field(page, ADDRESS)).toBeDisabled();
+ await expect(box).toContainText("subscriptions do not include API access");
+ await expect(box).toContainText("selected note context");
+ await box.getByRole("button", { name: "Set default" }).click();
+ await box.getByRole("button", { name: "Save", exact: true }).click();
+ await expect(box).toBeHidden();
+
+ await page.keyboard.press("Meta+,");
+ await expect(box).toContainText("OpenAI API · default");
+ } finally {
+ await app.close();
+ }
+});
+
test("saved settings apply at once: the model asked, the PDF footer, the words; and they are in the settings file", async () => {
test.setTimeout(120_000);
const notes = "## Caching\n- Redis keeps hot keys in memory.";
diff --git a/apps/desktop/electron/ai/providers.test.ts b/apps/desktop/electron/ai/providers.test.ts
new file mode 100644
index 0000000..671eee3
--- /dev/null
+++ b/apps/desktop/electron/ai/providers.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it, vi } from "vitest";
+import type { ProviderProfile } from "../ipc/types";
+import { inspectProvider } from "./providers";
+
+const profile = (change: Partial = {}): ProviderProfile => ({
+ id: "local",
+ name: "Local",
+ kind: "lmstudio",
+ baseUrl: "http://127.0.0.1:1234",
+ model: "local/model",
+ ...change,
+});
+
+describe("inspectProvider", () => {
+ it("reads OpenAI-shaped model lists from the provider's models endpoint", async () => {
+ const request = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ data: [{ id: "z-model" }, { id: "a-model" }] }), {
+ status: 200,
+ }),
+ );
+ const result = await inspectProvider(profile(), undefined, request);
+ expect(result).toEqual({
+ ok: true,
+ message: "Connected to LM Studio. 2 models available.",
+ models: ["a-model", "z-model"],
+ });
+ expect(request.mock.calls[0]?.[0]).toEqual(new URL("http://127.0.0.1:1234/v1/models"));
+ });
+
+ it("reads Ollama-shaped model lists from the native tags endpoint", async () => {
+ const request = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(JSON.stringify({ models: [{ name: "llama3.2" }] }), { status: 200 }),
+ );
+ const result = await inspectProvider(
+ profile({ kind: "ollama", baseUrl: "http://localhost:11434" }),
+ undefined,
+ request,
+ );
+ expect(result.models).toEqual(["llama3.2"]);
+ expect(request.mock.calls[0]?.[0]).toEqual(new URL("http://localhost:11434/api/tags"));
+ });
+
+ it("requires credentials before contacting a cloud provider", async () => {
+ const request = vi.fn();
+ const result = await inspectProvider(
+ profile({
+ kind: "openai",
+ baseUrl: "https://api.openai.com/v1",
+ model: "gpt-4.1-mini",
+ }),
+ undefined,
+ request,
+ );
+ expect(result).toEqual({
+ ok: false,
+ message: "Add an API key for OpenAI API, then try again.",
+ });
+ expect(request).not.toHaveBeenCalled();
+ });
+
+ it("uses provider-specific authentication without returning the key", async () => {
+ const request = vi
+ .fn()
+ .mockResolvedValue(new Response(JSON.stringify({ data: [] }), { status: 200 }));
+ const result = await inspectProvider(
+ profile({
+ kind: "anthropic",
+ baseUrl: "https://api.anthropic.com/v1",
+ model: "claude-sonnet-4-5",
+ }),
+ "top-secret",
+ request,
+ );
+ expect(request.mock.calls[0]?.[1]?.headers).toMatchObject({
+ "x-api-key": "top-secret",
+ "anthropic-version": "2023-06-01",
+ });
+ expect(JSON.stringify(result)).not.toContain("top-secret");
+ });
+
+ it("redacts credentials from actionable network failures", async () => {
+ const request = vi
+ .fn()
+ .mockRejectedValue(new Error("Bearer top-secret was rejected"));
+ const result = await inspectProvider(
+ profile({
+ kind: "custom",
+ baseUrl: "https://models.example.test/v1",
+ model: "custom",
+ }),
+ "top-secret",
+ request,
+ );
+ expect(result.ok).toBe(false);
+ expect(result.message).toContain("Could not connect");
+ expect(result.message).not.toContain("top-secret");
+ });
+});
diff --git a/apps/desktop/electron/ai/providers.ts b/apps/desktop/electron/ai/providers.ts
new file mode 100644
index 0000000..ffc4011
--- /dev/null
+++ b/apps/desktop/electron/ai/providers.ts
@@ -0,0 +1,89 @@
+import {
+ AI_PROVIDER_CATALOG,
+ type ProviderConnectionResult,
+ type ProviderProfile,
+} from "../ipc/types";
+import { redactSensitive, secureProviderFetch } from "./security";
+
+const providerFor = (profile: ProviderProfile) =>
+ AI_PROVIDER_CATALOG.find((provider) => provider.kind === profile.kind);
+
+const modelsUrl = (profile: ProviderProfile): string => {
+ const base = profile.baseUrl.replace(/\/+$/, "");
+ if (profile.kind === "lmstudio") return `${base}/v1/models`;
+ if (profile.kind === "ollama") return `${base}/api/tags`;
+ return `${base}/models`;
+};
+
+const headersFor = (profile: ProviderProfile, secret?: string): Record => {
+ if (!secret) return { Accept: "application/json" };
+ if (profile.kind === "anthropic")
+ return {
+ Accept: "application/json",
+ "x-api-key": secret,
+ "anthropic-version": "2023-06-01",
+ };
+ return { Accept: "application/json", Authorization: `Bearer ${secret}` };
+};
+
+const modelIds = (value: unknown): string[] => {
+ if (!value || typeof value !== "object") return [];
+ const body = value as { data?: unknown; models?: unknown };
+ const entries = Array.isArray(body.data)
+ ? body.data
+ : Array.isArray(body.models)
+ ? body.models
+ : [];
+ return [
+ ...new Set(
+ entries
+ .map((entry) => {
+ if (!entry || typeof entry !== "object") return undefined;
+ const item = entry as { id?: unknown; name?: unknown; model?: unknown };
+ const id = item.id ?? item.name ?? item.model;
+ return typeof id === "string" && id.length <= 240 ? id : undefined;
+ })
+ .filter((id): id is string => id !== undefined),
+ ),
+ ]
+ .sort((a, b) => a.localeCompare(b))
+ .slice(0, 1000);
+};
+
+/** Check one persisted profile without ever returning its credential or request headers. */
+export async function inspectProvider(
+ profile: ProviderProfile,
+ secret?: string,
+ doFetch: typeof fetch = fetch,
+): Promise {
+ const provider = providerFor(profile);
+ if (!provider) return { ok: false, message: "That provider type is not supported." };
+ if (provider.credential === "required" && !secret)
+ return { ok: false, message: `Add an API key for ${provider.label}, then try again.` };
+ try {
+ const response = await secureProviderFetch(
+ modelsUrl(profile),
+ { headers: headersFor(profile, secret) },
+ doFetch,
+ );
+ if (!response.ok)
+ return {
+ ok: false,
+ message: `${provider.label} returned HTTP ${String(response.status)}. Check the address, API key, and account access.`,
+ };
+ const models = modelIds(await response.json());
+ return {
+ ok: true,
+ message:
+ models.length > 0
+ ? `Connected to ${provider.label}. ${String(models.length)} model${models.length === 1 ? "" : "s"} available.`
+ : `Connected to ${provider.label}, but it reported no models.`,
+ models,
+ };
+ } catch (error) {
+ return {
+ ok: false,
+ message: `Could not connect to ${provider.label}: ${redactSensitive(error, secret ? [secret] : [])}`,
+ };
+ }
+}
diff --git a/apps/desktop/electron/ipc/ai.ts b/apps/desktop/electron/ipc/ai.ts
index 7c58fb2..f705a5e 100644
--- a/apps/desktop/electron/ipc/ai.ts
+++ b/apps/desktop/electron/ipc/ai.ts
@@ -1,8 +1,17 @@
import { ipcMain, safeStorage } from "electron";
import { join } from "node:path";
import { CredentialVault, type SecretEncryption } from "../ai/credentials";
+import { inspectProvider } from "../ai/providers";
import { MainAiService, type AiRuntimeResolver } from "../ai/service";
-import { IPC, type AiEvent, type AiStartRequest, type CredentialSaveRequest } from "./types";
+import { loadSettings } from "../settings";
+import {
+ IPC,
+ providerCredentialId,
+ type AiEvent,
+ type AiStartRequest,
+ type CredentialSaveRequest,
+ type ProviderConnectionResult,
+} from "./types";
const encryption: SecretEncryption = {
secureAvailable: () =>
@@ -31,5 +40,16 @@ export function registerSecureAiIpc(
);
ipcMain.handle(IPC.credentialDelete, (_event, id: string) => vault.delete(id));
ipcMain.handle(IPC.credentialStatus, (_event, id: string) => vault.status(id));
+ const inspect = async (id: unknown): Promise => {
+ if (typeof id !== "string") return { ok: false, message: "Choose a provider profile first." };
+ const profile = (await loadSettings(libraryDir)).ai.profiles.find(
+ (candidate) => candidate.id === id,
+ );
+ if (!profile) return { ok: false, message: "That provider profile no longer exists." };
+ const secret = await vault.read(providerCredentialId(profile.id));
+ return inspectProvider(profile, secret);
+ };
+ ipcMain.handle(IPC.providerTest, (_event, id: unknown) => inspect(id));
+ ipcMain.handle(IPC.providerModels, (_event, id: unknown) => inspect(id));
return vault;
}
diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts
index 67126a9..19d4a4c 100644
--- a/apps/desktop/electron/ipc/index.ts
+++ b/apps/desktop/electron/ipc/index.ts
@@ -163,18 +163,47 @@ export function registerIpc(): void {
model: s.llm.model,
contextLength: s.llm.contextLength,
},
+ ai: {
+ defaultProfileId: s.ai.defaultProfileId,
+ profiles: s.ai.profiles.map((profile) => ({
+ ...profile,
+ baseUrl:
+ profile.kind === "lmstudio" && process.env["LUCID_LLM_URL"]
+ ? process.env["LUCID_LLM_URL"]
+ : profile.baseUrl,
+ })),
+ },
llmUrlLocked: process.env["LUCID_LLM_URL"] !== undefined,
});
ipcMain.handle(IPC.settingsGet, async () => publicSettings(await loadSettings(libraryDir())));
ipcMain.handle(IPC.settingsSave, async (_e, input: unknown): Promise => {
try {
// With the address fixed from the environment, keep the stored one whatever the window sends.
- const sent = input as { llm?: { baseUrl?: unknown } } | null;
+ const sent = input as {
+ llm?: { baseUrl?: unknown };
+ ai?: { profiles?: { id?: unknown; kind?: unknown; baseUrl?: unknown }[] };
+ } | null;
const locked = process.env["LUCID_LLM_URL"] !== undefined;
const stored = await loadSettings(libraryDir());
const checked =
locked && sent?.llm
- ? { ...sent, llm: { ...sent.llm, baseUrl: stored.llm.baseUrl } }
+ ? {
+ ...sent,
+ llm: { ...sent.llm, baseUrl: stored.llm.baseUrl },
+ ai: sent.ai
+ ? {
+ ...sent.ai,
+ profiles: sent.ai.profiles?.map((profile) => ({
+ ...profile,
+ baseUrl:
+ profile.kind === "lmstudio"
+ ? (stored.ai.profiles.find((saved) => saved.id === profile.id)?.baseUrl ??
+ "http://127.0.0.1:1234")
+ : profile.baseUrl,
+ })),
+ }
+ : sent.ai,
+ }
: input;
const saved = await saveSettings(libraryDir(), checked);
(await compiler).setStyle({
diff --git a/apps/desktop/electron/ipc/types.ts b/apps/desktop/electron/ipc/types.ts
index 48a577f..f516c5a 100644
--- a/apps/desktop/electron/ipc/types.ts
+++ b/apps/desktop/electron/ipc/types.ts
@@ -112,6 +112,7 @@ export interface PublicSettings {
dictation: { autoSendMs: AutoSendMs };
vocabulary: string[];
llm: { baseUrl: string; model: string; contextLength: number };
+ ai: AiSettings;
/** The address comes from the LUCID_LLM_URL environment variable (tests, development) and is not changed here. */
llmUrlLocked: boolean;
}
@@ -119,6 +120,84 @@ export type SettingsInput = Omit;
export type SettingsSaveResult =
{ ok: true; settings: PublicSettings } | { ok: false; problems: string[] };
+export const AI_PROVIDER_CATALOG = [
+ {
+ kind: "lmstudio",
+ label: "LM Studio",
+ credential: "none",
+ customBaseUrl: true,
+ cloud: false,
+ defaultBaseUrl: "http://127.0.0.1:1234",
+ },
+ {
+ kind: "ollama",
+ label: "Ollama",
+ credential: "none",
+ customBaseUrl: true,
+ cloud: false,
+ defaultBaseUrl: "http://127.0.0.1:11434",
+ },
+ {
+ kind: "openai",
+ label: "OpenAI API",
+ credential: "required",
+ customBaseUrl: false,
+ cloud: true,
+ defaultBaseUrl: "https://api.openai.com/v1",
+ },
+ {
+ kind: "anthropic",
+ label: "Anthropic API",
+ credential: "required",
+ customBaseUrl: false,
+ cloud: true,
+ defaultBaseUrl: "https://api.anthropic.com/v1",
+ },
+ {
+ kind: "openrouter",
+ label: "OpenRouter",
+ credential: "required",
+ customBaseUrl: false,
+ cloud: true,
+ defaultBaseUrl: "https://openrouter.ai/api/v1",
+ },
+ {
+ kind: "ollama-cloud",
+ label: "Ollama Cloud",
+ credential: "required",
+ customBaseUrl: false,
+ cloud: true,
+ defaultBaseUrl: "https://ollama.com/v1",
+ },
+ {
+ kind: "custom",
+ label: "OpenAI-compatible",
+ credential: "optional",
+ customBaseUrl: true,
+ cloud: true,
+ defaultBaseUrl: "https://api.example.com/v1",
+ },
+] as const;
+export type AiProviderKind = (typeof AI_PROVIDER_CATALOG)[number]["kind"];
+export interface ProviderProfile {
+ id: string;
+ name: string;
+ kind: AiProviderKind;
+ baseUrl: string;
+ model: string;
+}
+export interface AiSettings {
+ defaultProfileId: string;
+ profiles: ProviderProfile[];
+}
+export interface ProviderConnectionResult {
+ ok: boolean;
+ message: string;
+ models?: string[] | undefined;
+}
+
+export const providerCredentialId = (profileId: string): string => `provider.${profileId}`;
+
/** A request for LM Studio, made by the main process because LM Studio sends no CORS headers. */
export interface LlmRequest {
id: number;
@@ -229,6 +308,8 @@ export const IPC = {
credentialSave: "lucid:credential-save",
credentialDelete: "lucid:credential-delete",
credentialStatus: "lucid:credential-status",
+ providerTest: "lucid:provider-test",
+ providerModels: "lucid:provider-models",
} as const;
export interface LucidApi {
@@ -279,6 +360,12 @@ export interface LucidApi {
delete(id: string): Promise;
status(id: string): Promise;
};
+ providers: {
+ /** Tests the saved profile. Main resolves its URL and credential by id. */
+ test(id: string): Promise;
+ /** Returns model ids from the saved profile without exposing credentials. */
+ models(id: string): Promise;
+ };
lm: {
status(): Promise;
/** Loads the configured model in LM Studio (after the person agreed), unloading others first. */
diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts
index f812b49..6bcff96 100644
--- a/apps/desktop/electron/preload.ts
+++ b/apps/desktop/electron/preload.ts
@@ -120,6 +120,12 @@ const api: LucidApi = {
status: (id) =>
ipcRenderer.invoke(IPC.credentialStatus, id) as ReturnType,
},
+ providers: {
+ test: (id) =>
+ ipcRenderer.invoke(IPC.providerTest, id) as ReturnType,
+ models: (id) =>
+ ipcRenderer.invoke(IPC.providerModels, id) as ReturnType,
+ },
lm: {
status: () => ipcRenderer.invoke(IPC.lmStatus) as Promise,
load: () => ipcRenderer.invoke(IPC.lmLoad) as Promise,
diff --git a/apps/desktop/electron/settings.test.ts b/apps/desktop/electron/settings.test.ts
index 0ebb901..58ddd3b 100644
--- a/apps/desktop/electron/settings.test.ts
+++ b/apps/desktop/electron/settings.test.ts
@@ -25,6 +25,28 @@ describe("loadSettings", () => {
expect(await loadSettings(dir)).toMatchObject({ author: "Asha", footerRight: "" });
});
+ it("migrates an existing LM Studio address and model into the first provider profile", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "lucid-set-"));
+ await mkdir(join(dir, ".lucid"));
+ await writeFile(
+ join(dir, ".lucid", "settings.json"),
+ JSON.stringify({ llm: { baseUrl: "http://localhost:9876", model: "local/model" } }),
+ "utf8",
+ );
+ expect((await loadSettings(dir)).ai).toEqual({
+ defaultProfileId: "lmstudio",
+ profiles: [
+ {
+ id: "lmstudio",
+ name: "LM Studio",
+ kind: "lmstudio",
+ baseUrl: "http://localhost:9876",
+ model: "local/model",
+ },
+ ],
+ });
+ });
+
it("sends a paste after 0.5 s unless the settings say otherwise (a file from before has no such setting)", async () => {
const dir = await mkdtemp(join(tmpdir(), "lucid-set-"));
await mkdir(join(dir, ".lucid"));
@@ -69,6 +91,18 @@ describe("saveSettings", () => {
dictation: { autoSendMs: 1000 },
vocabulary: ["vLLM", "Kafka", "vLLM"],
llm: { baseUrl: "http://localhost:1234/", model: "other/model-7b", contextLength: 16384 },
+ ai: {
+ defaultProfileId: "lmstudio",
+ profiles: [
+ {
+ id: "lmstudio",
+ name: "Local",
+ kind: "lmstudio",
+ baseUrl: "http://localhost:1234",
+ model: "other/model-7b",
+ },
+ ],
+ },
};
it("writes the new values, keeps program paths and everything else, and tidies the address and the words", async () => {
@@ -93,6 +127,7 @@ describe("saveSettings", () => {
contextLength: 16384,
lmsPath: "/custom/lms",
},
+ ai: valid.ai,
});
expect(JSON.parse(await readFile(join(dir, ".lucid", "settings.json"), "utf8"))).toEqual(saved);
expect(await loadSettings(dir)).toEqual(saved);
@@ -135,6 +170,30 @@ describe("saveSettings", () => {
expect(await bad({ pdf: undefined })).toBeInstanceOf(SettingsError);
expect(await bad({ dictation: { autoSendMs: 300 } })).toBeInstanceOf(SettingsError);
expect(await bad({ dictation: undefined })).toBeInstanceOf(SettingsError);
+ expect(
+ await bad({
+ ai: {
+ defaultProfileId: "missing",
+ profiles: valid.ai.profiles,
+ },
+ }),
+ ).toBeInstanceOf(SettingsError);
+ expect(
+ await bad({
+ ai: {
+ defaultProfileId: "openai",
+ profiles: [
+ {
+ id: "openai",
+ name: "OpenAI",
+ kind: "openai",
+ baseUrl: "https://example.com/v1",
+ model: "gpt-4.1-mini",
+ },
+ ],
+ },
+ }),
+ ).toBeInstanceOf(SettingsError);
expect(await loadSettings(dir)).toEqual(before);
});
});
diff --git a/apps/desktop/electron/settings.ts b/apps/desktop/electron/settings.ts
index b160170..e915972 100644
--- a/apps/desktop/electron/settings.ts
+++ b/apps/desktop/electron/settings.ts
@@ -1,7 +1,13 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { DEFAULT_PDF_STYLE, PdfStyleSchema } from "@lucid/kit";
-import { AUTO_SEND_MS } from "./ipc/types";
+import {
+ AI_PROVIDER_CATALOG,
+ AUTO_SEND_MS,
+ type AiProviderKind,
+ type AiSettings,
+} from "./ipc/types";
+import { validateProviderEndpoint } from "./ai/security";
import { z } from "zod";
const AutoSendMsSchema = z.union([
@@ -11,6 +17,90 @@ const AutoSendMsSchema = z.union([
z.literal(AUTO_SEND_MS[3]),
]);
+const defaultAiSettings = (llm?: { baseUrl?: string; model?: string }): AiSettings => ({
+ defaultProfileId: "lmstudio",
+ profiles: [
+ {
+ id: "lmstudio",
+ name: "LM Studio",
+ kind: "lmstudio",
+ baseUrl: llm?.baseUrl ?? "http://127.0.0.1:1234",
+ model: llm?.model ?? "qwen/qwen3.8-27b",
+ },
+ ],
+});
+
+const ProviderKindSchema = z.enum(
+ AI_PROVIDER_CATALOG.map((provider) => provider.kind) as [AiProviderKind, ...AiProviderKind[]],
+);
+const ProviderProfileSchema = z.strictObject({
+ id: z
+ .string()
+ .trim()
+ .regex(/^[a-z0-9][a-z0-9._-]{0,79}$/i, "must contain only letters, numbers, dots or dashes"),
+ name: z
+ .string()
+ .trim()
+ .min(1)
+ .max(80)
+ .regex(/^[^\r\n]*$/, "must be one line"),
+ kind: ProviderKindSchema,
+ baseUrl: z
+ .string()
+ .trim()
+ .transform((value, ctx) => {
+ try {
+ return validateProviderEndpoint(value).href.replace(/\/+$/, "");
+ } catch (error) {
+ ctx.addIssue({
+ code: "custom",
+ message: error instanceof Error ? error.message : "must be a valid provider address",
+ });
+ return z.NEVER;
+ }
+ }),
+ model: z
+ .string()
+ .trim()
+ .min(1)
+ .max(240)
+ .regex(/^[^\r\n]*$/, "must be one line"),
+});
+const AiSettingsSchema = z
+ .strictObject({
+ defaultProfileId: z.string().trim(),
+ profiles: z.array(ProviderProfileSchema).min(1).max(20),
+ })
+ .superRefine((settings, ctx) => {
+ const ids = new Set();
+ for (const [index, profile] of settings.profiles.entries()) {
+ if (ids.has(profile.id))
+ ctx.addIssue({
+ code: "custom",
+ path: ["profiles", index, "id"],
+ message: "must be unique",
+ });
+ ids.add(profile.id);
+ const provider = AI_PROVIDER_CATALOG.find((candidate) => candidate.kind === profile.kind);
+ if (
+ provider &&
+ !provider.customBaseUrl &&
+ profile.baseUrl !== provider.defaultBaseUrl.replace(/\/+$/, "")
+ )
+ ctx.addIssue({
+ code: "custom",
+ path: ["profiles", index, "baseUrl"],
+ message: `is fixed for ${provider.label}`,
+ });
+ }
+ if (!ids.has(settings.defaultProfileId))
+ ctx.addIssue({
+ code: "custom",
+ path: ["defaultProfileId"],
+ message: "must name one of the provider profiles",
+ });
+ });
+
/** User settings in `/.lucid/settings.json`. Missing file or fields fall back to the defaults. */
export const SettingsSchema = z.object({
/** Your name under the title of exported PDFs (optional). */
@@ -40,6 +130,7 @@ export const SettingsSchema = z.object({
contextLength: 32768,
lmsPath: "",
}),
+ ai: AiSettingsSchema.default(defaultAiSettings()),
});
export type Settings = z.infer;
@@ -56,7 +147,11 @@ export async function loadSettings(libraryDir: string): Promise {
return defaults;
}
try {
- const parsed = SettingsSchema.safeParse(JSON.parse(text));
+ const source = JSON.parse(text) as { ai?: unknown; llm?: { baseUrl?: string; model?: string } };
+ const parsed = SettingsSchema.safeParse({
+ ...source,
+ ai: source.ai ?? defaultAiSettings(source.llm),
+ });
return parsed.success ? parsed.data : SettingsSchema.parse({});
} catch {
return SettingsSchema.parse({});
@@ -107,6 +202,7 @@ export const EditableSettingsSchema = z.strictObject({
.regex(/^[\w][\w./@:+-]{0,200}$/, "must be a model name like qwen/qwen3.8-27b"),
contextLength: z.number().int().min(2048).max(262144),
}),
+ ai: AiSettingsSchema,
});
export type EditableSettings = z.infer;
@@ -134,7 +230,15 @@ export async function saveSettings(libraryDir: string, input: unknown): Promise<
dictation: parsed.data.dictation,
vocabulary: [...new Set(parsed.data.vocabulary)],
llm: { ...current.llm, ...parsed.data.llm },
+ ai: parsed.data.ai,
};
+ const defaultProfile = next.ai.profiles.find(
+ (profile) => profile.id === next.ai.defaultProfileId,
+ );
+ if (defaultProfile?.kind === "lmstudio") {
+ next.llm.baseUrl = defaultProfile.baseUrl;
+ next.llm.model = defaultProfile.model;
+ }
const dir = join(libraryDir, ".lucid");
await mkdir(dir, { recursive: true });
const tmp = join(dir, `settings.json.${String(process.pid)}.tmp`);
diff --git a/apps/desktop/src/settings/ProviderSettings.tsx b/apps/desktop/src/settings/ProviderSettings.tsx
new file mode 100644
index 0000000..4df60f1
--- /dev/null
+++ b/apps/desktop/src/settings/ProviderSettings.tsx
@@ -0,0 +1,350 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ AI_PROVIDER_CATALOG,
+ providerCredentialId,
+ type AiProviderKind,
+ type AiSettings,
+ type CredentialStatus,
+ type ProviderConnectionResult,
+ type ProviderProfile,
+} from "../../electron/ipc/types";
+
+const modelHint = (kind: AiProviderKind): string => {
+ if (kind === "lmstudio") return "qwen/qwen3.8-27b";
+ if (kind === "ollama" || kind === "ollama-cloud") return "llama3.2";
+ if (kind === "anthropic") return "claude-sonnet-4-5";
+ if (kind === "openrouter") return "openai/gpt-4.1-mini";
+ if (kind === "openai") return "gpt-4.1-mini";
+ return "model-name";
+};
+
+const emptyCredential: CredentialStatus = {
+ present: false,
+ storage: "none",
+ securePersistenceAvailable: true,
+};
+
+interface Props {
+ value: AiSettings;
+ lmStudioUrlLocked: boolean;
+ onChange: (value: AiSettings) => void;
+ onPersist: () => Promise;
+}
+
+export function ProviderSettings({ value, lmStudioUrlLocked, onChange, onPersist }: Props) {
+ const [activeId, setActiveId] = useState(value.defaultProfileId);
+ const [addKind, setAddKind] = useState("ollama");
+ const [credential, setCredential] = useState(emptyCredential);
+ const [connection, setConnection] = useState();
+ const [busy, setBusy] = useState<"key" | "test" | "models">();
+ const [models, setModels] = useState([]);
+ const secret = useRef(null);
+ const active =
+ value.profiles.find((profile) => profile.id === activeId) ??
+ value.profiles.find((profile) => profile.id === value.defaultProfileId) ??
+ value.profiles[0];
+ const provider = AI_PROVIDER_CATALOG.find((candidate) => candidate.kind === active?.kind);
+ const credentialId = active ? providerCredentialId(active.id) : "";
+ const modelListId = useMemo(() => `provider-models-${active?.id ?? "none"}`, [active?.id]);
+
+ useEffect(() => {
+ setConnection(undefined);
+ setModels([]);
+ if (!active) return;
+ void window.lucid.credentials.status(providerCredentialId(active.id)).then(setCredential);
+ }, [active?.id]);
+
+ const replace = (change: Partial) => {
+ if (!active) return;
+ onChange({
+ ...value,
+ profiles: value.profiles.map((profile) =>
+ profile.id === active.id ? { ...profile, ...change } : profile,
+ ),
+ });
+ };
+
+ const add = () => {
+ const definition = AI_PROVIDER_CATALOG.find((candidate) => candidate.kind === addKind);
+ if (!definition) return;
+ const id = `${addKind}-${crypto.randomUUID().slice(0, 8)}`;
+ const profile: ProviderProfile = {
+ id,
+ name: definition.label,
+ kind: addKind,
+ baseUrl: definition.defaultBaseUrl,
+ model: modelHint(addKind),
+ };
+ onChange({ ...value, profiles: [...value.profiles, profile] });
+ setActiveId(id);
+ };
+
+ const remove = () => {
+ if (!active || value.profiles.length === 1) return;
+ const profiles = value.profiles.filter((profile) => profile.id !== active.id);
+ const defaultProfileId =
+ value.defaultProfileId === active.id ? (profiles[0]?.id ?? "") : value.defaultProfileId;
+ onChange({ defaultProfileId, profiles });
+ setActiveId(defaultProfileId);
+ void window.lucid.credentials.delete(credentialId);
+ };
+
+ const saveKey = async () => {
+ if (!active || !secret.current) return;
+ const entered = secret.current.value;
+ secret.current.value = "";
+ if (!entered) return;
+ setBusy("key");
+ setConnection(undefined);
+ try {
+ setCredential(
+ await window.lucid.credentials.save({
+ id: credentialId,
+ secret: entered,
+ persist: true,
+ }),
+ );
+ } catch {
+ setConnection({
+ ok: false,
+ message: "The API key could not be saved. Check secure storage and try again.",
+ });
+ } finally {
+ setBusy(undefined);
+ }
+ };
+
+ const clearKey = async () => {
+ setBusy("key");
+ try {
+ await window.lucid.credentials.delete(credentialId);
+ setCredential(await window.lucid.credentials.status(credentialId));
+ } catch {
+ setConnection({
+ ok: false,
+ message: "The API key could not be removed. Check secure storage and try again.",
+ });
+ } finally {
+ setBusy(undefined);
+ }
+ };
+
+ const check = async (action: "test" | "models") => {
+ if (!active || !(await onPersist())) return;
+ setBusy(action);
+ setConnection(undefined);
+ try {
+ const result =
+ action === "test"
+ ? await window.lucid.providers.test(active.id)
+ : await window.lucid.providers.models(active.id);
+ setConnection(result);
+ if (result.ok && result.models) {
+ setModels(result.models);
+ if (!active.model && result.models[0]) replace({ model: result.models[0] });
+ }
+ } finally {
+ setBusy(undefined);
+ }
+ };
+
+ if (!active || !provider) return null;
+ const baseUrlLocked = active.kind === "lmstudio" && lmStudioUrlLocked;
+
+ return (
+
+ AI providers
+
+ Choose where Lucid runs AI. Lucid never switches providers automatically.
+
+
+
+ {value.profiles.map((profile) => {
+ const definition = AI_PROVIDER_CATALOG.find(
+ (candidate) => candidate.kind === profile.kind,
+ );
+ return (
+
{
+ setActiveId(profile.id);
+ }}
+ >
+ {profile.name}
+
+ {definition?.label}
+ {profile.id === value.defaultProfileId ? " · default" : ""}
+
+
+ );
+ })}
+
+ {
+ setAddKind(event.target.value as AiProviderKind);
+ }}
+ >
+ {AI_PROVIDER_CATALOG.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+ Add
+
+
+
+
+
+
+ Profile name
+ {
+ replace({ name: event.target.value });
+ }}
+ />
+
+
+ Base URL
+ {
+ replace({ baseUrl: event.target.value });
+ }}
+ spellCheck={false}
+ />
+
+ {baseUrlLocked
+ ? "Set by the LUCID_LLM_URL environment variable."
+ : provider.customBaseUrl
+ ? "Cloud addresses must use HTTPS. Local HTTP is limited to this computer."
+ : `Fixed for ${provider.label}.`}
+
+
+
+ Model
+ 0 ? modelListId : undefined}
+ value={active.model}
+ onChange={(event) => {
+ replace({ model: event.target.value });
+ }}
+ spellCheck={false}
+ />
+ {models.length > 0 && (
+
+ {models.map((model) => (
+
+ ))}
+
+ )}
+
+
+ {provider.credential !== "none" && (
+
+
+ API key {provider.credential === "optional" ? "(optional)" : ""}
+
+
+ {credential.present
+ ? credential.storage === "secure"
+ ? "Stored with this computer’s secure credential storage."
+ : "Kept for this session because secure storage is unavailable."
+ : "Sent directly to Lucid’s main process and cleared from this field."}
+
+
+
+ void saveKey()}
+ >
+ Save key
+
+ {credential.present && (
+ void clearKey()}
+ >
+ Remove key
+
+ )}
+
+
+ )}
+
+ {provider.cloud && (
+
+ Requests send the selected note context to {provider.label}. ChatGPT and Claude
+ subscriptions do not include API access; API billing is separate.
+
+ )}
+
+
+ void check("test")}
+ >
+ {busy === "test" ? "Testing…" : "Test connection"}
+
+ void check("models")}
+ >
+ {busy === "models" ? "Fetching…" : "Fetch models"}
+
+ {
+ onChange({ ...value, defaultProfileId: active.id });
+ }}
+ >
+ {active.id === value.defaultProfileId ? "Default provider" : "Set default"}
+
+
+ Remove
+
+
+ {connection && (
+
+ {connection.message}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/settings/SettingsDialog.tsx b/apps/desktop/src/settings/SettingsDialog.tsx
index 8f27134..5080c6e 100644
--- a/apps/desktop/src/settings/SettingsDialog.tsx
+++ b/apps/desktop/src/settings/SettingsDialog.tsx
@@ -1,6 +1,11 @@
-import { useEffect, useId, useRef, useState } from "react";
+import { useEffect, useId, useState } from "react";
import { ACCENT_COLORS, FONT_NAMES, type PdfStyle } from "@lucid/kit";
-import { AUTO_SEND_MS, type AutoSendMs, type PublicSettings } from "../../electron/ipc/types";
+import {
+ AUTO_SEND_MS,
+ type AiSettings,
+ type AutoSendMs,
+ type PublicSettings,
+} from "../../electron/ipc/types";
import {
setFont,
setPageLook,
@@ -14,6 +19,7 @@ import { setBlockNumbers, useBlockNumbers } from "../theme/blockNumbers";
import { PAGE_LOOKS } from "../pages/PageLookSwitch";
import { saveSettings } from "./settingsStore";
import { TemplateDialog } from "./TemplateDialog";
+import { ProviderSettings } from "./ProviderSettings";
const THEME_OPTIONS: { value: ThemeChoice; label: string; icon: IconName }[] = [
{ value: "light", label: "Light", icon: "sun" },
@@ -112,18 +118,15 @@ export function SettingsDialog({ settings, onClose }: Props) {
const changePdf = (change: Partial) => {
setPdf((before) => ({ ...before, ...change }));
};
- const [address, setAddress] = useState(settings.llm.baseUrl);
- const [model, setModel] = useState(settings.llm.model);
+ const [ai, setAi] = useState(settings.ai);
const [context, setContext] = useState(String(settings.llm.contextLength));
const [words, setWords] = useState(settings.vocabulary.join("\n"));
const [problems, setProblems] = useState([]);
const [saving, setSaving] = useState(false);
- const first = useRef(null);
const appearance = useAppearance();
const numbers = useBlockNumbers();
useEffect(() => {
- first.current?.focus();
const onKey = (e: KeyboardEvent) => {
// The template window has its own Escape.
if (e.key === "Escape" && !editingTemplate) onClose();
@@ -134,10 +137,11 @@ export function SettingsDialog({ settings, onClose }: Props) {
};
}, [onClose, editingTemplate]);
- const save = async () => {
+ const persist = async (close = true): Promise => {
setSaving(true);
setProblems([]);
try {
+ const defaultProfile = ai.profiles.find((profile) => profile.id === ai.defaultProfileId);
const result = await saveSettings({
author,
footerRight: footer,
@@ -147,15 +151,25 @@ export function SettingsDialog({ settings, onClose }: Props) {
.split(/\r?\n/)
.map((w) => w.trim())
.filter((w) => w.length > 0),
- llm: { baseUrl: address, model, contextLength: Number(context) },
+ llm: {
+ baseUrl:
+ defaultProfile?.kind === "lmstudio" ? defaultProfile.baseUrl : settings.llm.baseUrl,
+ model: defaultProfile?.kind === "lmstudio" ? defaultProfile.model : settings.llm.model,
+ contextLength: Number(context),
+ },
+ ai,
});
- if (result.ok) onClose();
- else setProblems(result.problems);
+ if (result.ok) {
+ if (close) onClose();
+ return true;
+ }
+ setProblems(result.problems);
} catch (error) {
setProblems([error instanceof Error ? error.message : "The settings could not be saved."]);
} finally {
setSaving(false);
}
+ return false;
};
return (
@@ -272,39 +286,15 @@ export function SettingsDialog({ settings, onClose }: Props) {
+ persist(false)}
+ />
+
- Language model (LM Studio)
-
- Address
- {
- setAddress(e.target.value);
- }}
- spellCheck={false}
- />
-
- {settings.llmUrlLocked
- ? "Set by the LUCID_LLM_URL environment variable."
- : "Only this computer, like http://127.0.0.1:1234."}
-
-
-
- Model
- {
- setModel(e.target.value);
- }}
- spellCheck={false}
- />
-
- The name LM Studio uses, like qwen/qwen3.8-27b. Load it with the pill in the top
- bar.
-
-
+ Local model
Context length when Lucid loads the model
{
- void save();
+ void persist();
}}
>
Save
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index 8ee6e8d..2d94ad4 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -1679,6 +1679,9 @@ select:focus-visible,
margin: 14px 0 0;
padding: 12px 0 0;
}
+.modal.settings {
+ width: min(780px, 100%);
+}
.settings legend {
padding: 0 8px 0 0;
font-size: 12px;
@@ -1695,6 +1698,7 @@ select:focus-visible,
}
.settings input,
.settings textarea,
+.settings select,
.template textarea,
.np-about textarea,
.np-title input,
@@ -1709,6 +1713,7 @@ select:focus-visible,
}
.settings input:focus,
.settings textarea:focus,
+.settings select:focus,
.template textarea:focus,
.np-about textarea:focus,
.np-title input:focus,
@@ -1725,6 +1730,130 @@ select:focus-visible,
color: var(--ink-3);
font-size: 12px;
}
+.settings-intro {
+ margin: 0 0 10px;
+ color: var(--ink-2);
+ font-size: 12.5px;
+ line-height: 1.45;
+}
+.provider-layout {
+ display: grid;
+ grid-template-columns: minmax(150px, 0.36fr) minmax(0, 1fr);
+ gap: 16px;
+ align-items: start;
+}
+.provider-list {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+.provider-list > button {
+ display: grid;
+ gap: 2px;
+ width: 100%;
+ padding: 8px 9px;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--ink);
+ text-align: left;
+}
+.provider-list > button:hover {
+ background: var(--hover);
+}
+.provider-list > button[aria-current="true"] {
+ border-color: var(--line);
+ background: var(--bg-2);
+}
+.provider-list > button span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-weight: 700;
+}
+.provider-list > button small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.provider-add {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 5px;
+ margin-top: 5px;
+}
+.provider-add select {
+ min-width: 0;
+ padding: 5px 7px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--bg-2);
+ color: var(--ink);
+}
+.provider-editor {
+ min-width: 0;
+ padding-left: 16px;
+ border-left: 1px solid var(--line);
+}
+.provider-key {
+ margin-bottom: 10px;
+}
+.provider-key label {
+ margin-bottom: 5px;
+}
+.provider-inline-actions,
+.provider-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.provider-actions {
+ margin-top: 10px;
+}
+.provider-actions .danger {
+ margin-left: auto;
+ color: var(--red);
+}
+.provider-disclosure,
+.provider-result {
+ margin: 8px 0 0;
+ padding: 8px 10px;
+ border-left: 2px solid var(--ink-3);
+ background: var(--bg-2);
+ color: var(--ink-2);
+ font-size: 12px;
+ line-height: 1.45;
+}
+.provider-result[data-kind="success"] {
+ border-left-color: var(--ok);
+ color: var(--ink);
+}
+.provider-result[data-kind="error"] {
+ border-left-color: var(--red);
+ background: var(--red-bg);
+ color: var(--red);
+}
+@media (max-width: 700px) {
+ .modal-backdrop {
+ padding: 12px;
+ }
+ .modal {
+ max-height: calc(100vh - 24px);
+ }
+ .provider-layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .provider-list {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .provider-add {
+ grid-column: 1 / -1;
+ }
+ .provider-editor {
+ padding: 12px 0 0;
+ border-top: 1px solid var(--line);
+ border-left: 0;
+ }
+}
.settings-problems {
margin: 8px 0 0;
padding: 8px 12px 8px 28px;
From 6bed655f5c03dd7617cf4003641b4114cd4894c9 Mon Sep 17 00:00:00 2001
From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com>
Date: Mon, 19 Oct 2026 00:56:36 +0530
Subject: [PATCH 05/13] refactor(desktop): migrate LM Studio to secure AI
service (#38)
Move LM Studio execution behind the main-process provider resolver and remove the renderer HTTP-shaped bridge while preserving streaming, cancellation, structured output, lane controls, retries, status, and model loading.\n\nThe configured 27B real-model eval was attempted but invalid because LM Studio's Metal backend ran out of memory before token generation; the diagnostic remains local only.
---
apps/desktop/electron/ai/runtime.test.ts | 56 ++++++
apps/desktop/electron/ai/runtime.ts | 25 +++
apps/desktop/electron/ipc/ai.ts | 5 +-
apps/desktop/electron/ipc/index.ts | 3 +-
apps/desktop/electron/ipc/llm.ts | 58 +------
apps/desktop/electron/ipc/types.ts | 25 ---
apps/desktop/electron/preload.ts | 14 --
apps/desktop/electron/settings.test.ts | 13 ++
apps/desktop/electron/settings.ts | 10 ++
apps/desktop/src/llm/bridgeFetch.ts | 58 -------
.../desktop/src/llm/mainProcessClient.test.ts | 163 ++++++++++++++++++
apps/desktop/src/llm/mainProcessClient.ts | 150 ++++++++++++++++
apps/desktop/src/llm/model.ts | 30 ++--
docs/architecture.md | 7 +-
14 files changed, 444 insertions(+), 173 deletions(-)
create mode 100644 apps/desktop/electron/ai/runtime.test.ts
create mode 100644 apps/desktop/electron/ai/runtime.ts
delete mode 100644 apps/desktop/src/llm/bridgeFetch.ts
create mode 100644 apps/desktop/src/llm/mainProcessClient.test.ts
create mode 100644 apps/desktop/src/llm/mainProcessClient.ts
diff --git a/apps/desktop/electron/ai/runtime.test.ts b/apps/desktop/electron/ai/runtime.test.ts
new file mode 100644
index 0000000..a81413c
--- /dev/null
+++ b/apps/desktop/electron/ai/runtime.test.ts
@@ -0,0 +1,56 @@
+import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it, vi } from "vitest";
+import { createRuntimeResolver } from "./runtime";
+
+describe("createRuntimeResolver", () => {
+ it("builds the LM Studio adapter in main from a migrated settings file", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "lucid-runtime-"));
+ await mkdir(join(dir, ".lucid"));
+ await writeFile(
+ join(dir, ".lucid", "settings.json"),
+ JSON.stringify({ llm: { baseUrl: "http://localhost:9876", model: "local/model" } }),
+ "utf8",
+ );
+ const runtime = await createRuntimeResolver(dir, vi.fn(), undefined)("lmstudio");
+ expect(runtime).toMatchObject({
+ endpoint: "http://localhost:9876",
+ client: { provider: "lmstudio" },
+ });
+ });
+
+ it("keeps the environment override used by tests and development", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "lucid-runtime-"));
+ const runtime = await createRuntimeResolver(
+ dir,
+ vi.fn(),
+ "http://127.0.0.1:4567/",
+ )("lmstudio");
+ expect(runtime?.endpoint).toBe("http://127.0.0.1:4567");
+ });
+
+ it("does not resolve providers whose production adapter has not landed yet", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "lucid-runtime-"));
+ await mkdir(join(dir, ".lucid"));
+ await writeFile(
+ join(dir, ".lucid", "settings.json"),
+ JSON.stringify({
+ ai: {
+ defaultProfileId: "openai",
+ profiles: [
+ {
+ id: "openai",
+ name: "OpenAI",
+ kind: "openai",
+ baseUrl: "https://api.openai.com/v1",
+ model: "gpt-5-mini",
+ },
+ ],
+ },
+ }),
+ "utf8",
+ );
+ expect(await createRuntimeResolver(dir)("openai")).toBeUndefined();
+ });
+});
diff --git a/apps/desktop/electron/ai/runtime.ts b/apps/desktop/electron/ai/runtime.ts
new file mode 100644
index 0000000..bb62713
--- /dev/null
+++ b/apps/desktop/electron/ai/runtime.ts
@@ -0,0 +1,25 @@
+import { LmStudioClient } from "@lucid/llm";
+import { loadSettings } from "../settings";
+import type { AiRuntimeResolver } from "./service";
+import { secureProviderFetch } from "./security";
+
+/** Resolve a persisted profile into a main-process adapter. Other providers are added incrementally. */
+export const createRuntimeResolver = (
+ libraryDir: string,
+ doFetch: typeof fetch = fetch,
+ environmentUrl: string | undefined = process.env["LUCID_LLM_URL"],
+): AiRuntimeResolver => {
+ return async (profileId) => {
+ const settings = await loadSettings(libraryDir);
+ const profile = settings.ai.profiles.find((candidate) => candidate.id === profileId);
+ if (!profile || profile.kind !== "lmstudio") return undefined;
+ const endpoint = (environmentUrl ?? profile.baseUrl).replace(/\/+$/, "");
+ return {
+ endpoint,
+ client: new LmStudioClient({
+ baseUrl: endpoint,
+ fetch: (input, init) => secureProviderFetch(input, init, doFetch),
+ }),
+ };
+ };
+};
diff --git a/apps/desktop/electron/ipc/ai.ts b/apps/desktop/electron/ipc/ai.ts
index f705a5e..45a6f67 100644
--- a/apps/desktop/electron/ipc/ai.ts
+++ b/apps/desktop/electron/ipc/ai.ts
@@ -2,6 +2,7 @@ import { ipcMain, safeStorage } from "electron";
import { join } from "node:path";
import { CredentialVault, type SecretEncryption } from "../ai/credentials";
import { inspectProvider } from "../ai/providers";
+import { createRuntimeResolver } from "../ai/runtime";
import { MainAiService, type AiRuntimeResolver } from "../ai/service";
import { loadSettings } from "../settings";
import {
@@ -23,10 +24,10 @@ const encryption: SecretEncryption = {
export function registerSecureAiIpc(
libraryDir: string,
- resolve: AiRuntimeResolver = () => Promise.resolve(undefined),
+ resolve?: AiRuntimeResolver,
): CredentialVault {
const vault = new CredentialVault(join(libraryDir, ".lucid"), encryption);
- const service = new MainAiService(resolve);
+ const service = new MainAiService(resolve ?? createRuntimeResolver(libraryDir));
ipcMain.handle(IPC.aiStart, async (event, request: AiStartRequest) =>
service.start(request, (aiEvent: AiEvent) => {
if (!event.sender.isDestroyed()) event.sender.send(IPC.aiEvent, aiEvent);
diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts
index 19d4a4c..ba90b5f 100644
--- a/apps/desktop/electron/ipc/index.ts
+++ b/apps/desktop/electron/ipc/index.ts
@@ -22,7 +22,7 @@ import { AppBackend } from "../appBackend";
import { RendererBridge } from "../agentBridge";
import { lmLoad, lmStatus, type LmDeps } from "../lmstudio";
import { LMSTUDIO_DOWNLOAD, defaultLmsPath, findLmStudio } from "../studio";
-import { checkLlmUrl, registerLlmIpc } from "./llm";
+import { checkLlmUrl } from "./llm";
import { registerSecureAiIpc } from "./ai";
import {
IPC,
@@ -92,7 +92,6 @@ export function registerIpc(): void {
ipcMain.handle(IPC.pagesSave, async (_e, file: unknown) =>
(await library).save(parseLucidFile(file)),
);
- registerLlmIpc();
registerSecureAiIpc(libraryDir());
const lmDeps = async (): Promise => {
const s = await loadSettings(libraryDir());
diff --git a/apps/desktop/electron/ipc/llm.ts b/apps/desktop/electron/ipc/llm.ts
index 0bbd6a3..84722b6 100644
--- a/apps/desktop/electron/ipc/llm.ts
+++ b/apps/desktop/electron/ipc/llm.ts
@@ -1,7 +1,4 @@
-import { ipcMain } from "electron";
-import { IPC, type LlmHead, type LlmRequest } from "./types";
-
-/** Only these LM Studio endpoints, only on this machine. The renderer cannot use this to reach anything else. */
+/** Only these LM Studio endpoints, only on this machine. */
const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
const PATHS = new Set(["/v1/models", "/v1/chat/completions"]);
@@ -14,56 +11,3 @@ export function checkLlmUrl(url: string): URL {
throw new Error(`Not an LM Studio endpoint Lucid uses: ${parsed.pathname}`);
return parsed;
}
-
-export function registerLlmIpc(): void {
- const running = new Map();
-
- ipcMain.handle(IPC.llmStart, async (event, raw: unknown): Promise => {
- const req = raw as Partial;
- if (typeof req.id !== "number" || typeof req.url !== "string")
- return { ok: false, message: "invalid request" };
- try {
- const url = checkLlmUrl(req.url);
- const method = req.method === "POST" ? "POST" : "GET";
- const controller = new AbortController();
- running.set(req.id, controller);
- const id = req.id;
- const res = await fetch(url, {
- method,
- headers: typeof req.headers === "object" ? req.headers : {},
- ...(method === "POST" && typeof req.body === "string" ? { body: req.body } : {}),
- signal: controller.signal,
- });
- // Stream the body back as it arrives; the renderer is already listening for this id.
- void (async () => {
- try {
- if (res.body) {
- for await (const chunk of res.body as unknown as AsyncIterable) {
- if (event.sender.isDestroyed()) return;
- event.sender.send(IPC.llmEvent, { id, type: "chunk", data: chunk });
- }
- }
- if (!event.sender.isDestroyed()) event.sender.send(IPC.llmEvent, { id, type: "end" });
- } catch (error) {
- if (!event.sender.isDestroyed()) {
- event.sender.send(IPC.llmEvent, {
- id,
- type: "error",
- message: error instanceof Error ? error.message : String(error),
- });
- }
- } finally {
- running.delete(id);
- }
- })();
- return { ok: true, status: res.status, headers: Object.fromEntries(res.headers) };
- } catch (error) {
- running.delete(req.id);
- return { ok: false, message: error instanceof Error ? error.message : String(error) };
- }
- });
-
- ipcMain.handle(IPC.llmAbort, (_e, id: unknown) => {
- if (typeof id === "number") running.get(id)?.abort();
- });
-}
diff --git a/apps/desktop/electron/ipc/types.ts b/apps/desktop/electron/ipc/types.ts
index f516c5a..6012c03 100644
--- a/apps/desktop/electron/ipc/types.ts
+++ b/apps/desktop/electron/ipc/types.ts
@@ -198,21 +198,6 @@ export interface ProviderConnectionResult {
export const providerCredentialId = (profileId: string): string => `provider.${profileId}`;
-/** A request for LM Studio, made by the main process because LM Studio sends no CORS headers. */
-export interface LlmRequest {
- id: number;
- url: string;
- method: string;
- headers: Record;
- body?: string | undefined;
-}
-export type LlmHead =
- { ok: true; status: number; headers: Record } | { ok: false; message: string };
-export type LlmEvent =
- | { id: number; type: "chunk"; data: Uint8Array }
- | { id: number; type: "end" }
- | { id: number; type: "error"; message: string };
-
export interface AiStartRequest {
id: number;
/** Resolves to main-process configuration; it is not an endpoint or credential. */
@@ -294,14 +279,11 @@ export const IPC = {
templateGet: "lucid:template-get",
templateSave: "lucid:template-save",
templateReset: "lucid:template-reset",
- llmStart: "lucid:llm-start",
- llmAbort: "lucid:llm-abort",
lmStatus: "lucid:lm-status",
lmLoad: "lucid:lm-load",
agentRequest: "lucid:agent-request",
agentReply: "lucid:agent-reply",
pagesChanged: "lucid:pages-changed",
- llmEvent: "lucid:llm-event",
aiStart: "lucid:ai-start",
aiAbort: "lucid:ai-abort",
aiEvent: "lucid:ai-event",
@@ -342,13 +324,6 @@ export interface LucidApi {
/** Back to the template that comes with Lucid. */
reset(): Promise;
};
- llm: {
- /** Starts the request; resolves when the response headers are in. The body then arrives as events. */
- start(req: LlmRequest): Promise;
- abort(id: number): Promise;
- /** Returns a function that stops listening. */
- onEvent(listener: (event: LlmEvent) => void): () => void;
- };
ai: {
start(request: AiStartRequest): Promise;
abort(id: number): Promise;
diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts
index 6bcff96..fe55074 100644
--- a/apps/desktop/electron/preload.ts
+++ b/apps/desktop/electron/preload.ts
@@ -4,7 +4,6 @@ import {
type AiEvent,
type AgentReply,
type AgentRequest,
- type LlmEvent,
type LmLoadResult,
type LmStatus,
type LucidApi,
@@ -84,19 +83,6 @@ const api: LucidApi = {
save: (input) =>
ipcRenderer.invoke(IPC.settingsSave, input) as ReturnType,
},
- llm: {
- start: (req) => ipcRenderer.invoke(IPC.llmStart, req) as ReturnType,
- abort: (id) => ipcRenderer.invoke(IPC.llmAbort, id) as Promise,
- onEvent: (listener) => {
- const handler = (_e: IpcRendererEvent, event: LlmEvent) => {
- listener(event);
- };
- ipcRenderer.on(IPC.llmEvent, handler);
- return () => {
- ipcRenderer.removeListener(IPC.llmEvent, handler);
- };
- },
- },
ai: {
start: (request) =>
ipcRenderer.invoke(IPC.aiStart, request) as ReturnType,
diff --git a/apps/desktop/electron/settings.test.ts b/apps/desktop/electron/settings.test.ts
index 58ddd3b..e4dabef 100644
--- a/apps/desktop/electron/settings.test.ts
+++ b/apps/desktop/electron/settings.test.ts
@@ -178,6 +178,19 @@ describe("saveSettings", () => {
},
}),
).toBeInstanceOf(SettingsError);
+ expect(
+ await bad({
+ ai: {
+ defaultProfileId: "lmstudio",
+ profiles: [
+ {
+ ...valid.ai.profiles[0],
+ baseUrl: "https://lm-studio.example.com",
+ },
+ ],
+ },
+ }),
+ ).toBeInstanceOf(SettingsError);
expect(
await bad({
ai: {
diff --git a/apps/desktop/electron/settings.ts b/apps/desktop/electron/settings.ts
index e915972..20fc90f 100644
--- a/apps/desktop/electron/settings.ts
+++ b/apps/desktop/electron/settings.ts
@@ -66,6 +66,7 @@ const ProviderProfileSchema = z.strictObject({
.max(240)
.regex(/^[^\r\n]*$/, "must be one line"),
});
+const LOCAL_PROVIDER_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
const AiSettingsSchema = z
.strictObject({
defaultProfileId: z.string().trim(),
@@ -82,6 +83,15 @@ const AiSettingsSchema = z
});
ids.add(profile.id);
const provider = AI_PROVIDER_CATALOG.find((candidate) => candidate.kind === profile.kind);
+ if (provider && !provider.cloud) {
+ const url = new URL(profile.baseUrl);
+ if (url.protocol !== "http:" || !LOCAL_PROVIDER_HOSTS.has(url.hostname))
+ ctx.addIssue({
+ code: "custom",
+ path: ["profiles", index, "baseUrl"],
+ message: `${provider.label} must use an address on this computer`,
+ });
+ }
if (
provider &&
!provider.customBaseUrl &&
diff --git a/apps/desktop/src/llm/bridgeFetch.ts b/apps/desktop/src/llm/bridgeFetch.ts
deleted file mode 100644
index 7f6cbda..0000000
--- a/apps/desktop/src/llm/bridgeFetch.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * A `fetch` that goes through the main process (LM Studio sends no CORS headers, so the window cannot call it
- * directly). It only needs to be good enough for the LM Studio client: GET and POST with a string body, and a
- * streamed response body.
- */
-let counter = 0;
-
-export const bridgeFetch: typeof fetch = async (input, init) => {
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- const id = ++counter;
- const signal = init?.signal ?? undefined;
- if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
-
- let stream!: ReadableStreamDefaultController;
- let finished = false;
- const body = new ReadableStream({
- start(controller) {
- stream = controller;
- },
- cancel() {
- void window.lucid.llm.abort(id);
- },
- });
- const stop = window.lucid.llm.onEvent((event) => {
- if (event.id !== id || finished) return;
- if (event.type === "chunk") stream.enqueue(event.data);
- else {
- finished = true;
- stop();
- if (event.type === "end") stream.close();
- else stream.error(new TypeError(event.message));
- }
- });
- const onAbort = () => {
- void window.lucid.llm.abort(id);
- if (!finished) {
- finished = true;
- stop();
- stream.error(new DOMException("Aborted", "AbortError"));
- }
- };
- signal?.addEventListener("abort", onAbort, { once: true });
-
- const head = await window.lucid.llm.start({
- id,
- url,
- method: init?.method ?? "GET",
- headers: Object.fromEntries(new Headers(init?.headers).entries()),
- body: typeof init?.body === "string" ? init.body : undefined,
- });
- if (!head.ok) {
- finished = true;
- stop();
- signal?.removeEventListener("abort", onAbort);
- throw new TypeError(head.message);
- }
- return new Response(body, { status: head.status, headers: head.headers });
-};
diff --git a/apps/desktop/src/llm/mainProcessClient.test.ts b/apps/desktop/src/llm/mainProcessClient.test.ts
new file mode 100644
index 0000000..9372572
--- /dev/null
+++ b/apps/desktop/src/llm/mainProcessClient.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it, vi } from "vitest";
+import { LlmError, type ChatRequest } from "@lucid/llm";
+import type {
+ AiEvent,
+ AiStartRequest,
+ AiStartResult,
+ ProviderConnectionResult,
+ ProviderProfile,
+} from "../../electron/ipc/types";
+import { MainProcessAiClient, type MainAiTransport } from "./mainProcessClient";
+
+const profile: ProviderProfile = {
+ id: "lmstudio",
+ name: "LM Studio",
+ kind: "lmstudio",
+ baseUrl: "http://127.0.0.1:1234",
+ model: "local/model",
+};
+const request: ChatRequest = {
+ messages: [{ role: "user", content: "hello" }],
+ lane: {
+ model: "local/model",
+ temperature: 0.1,
+ topP: 0.9,
+ topK: 20,
+ presencePenalty: 0,
+ maxTokens: 200,
+ },
+};
+const completion = {
+ usage: { prompt: 2, completion: 2, reasoning: 0, source: "provider" as const },
+ characters: { request: 5, response: 11 },
+ timings: { sentAt: 1, firstTokenAt: 2, doneAt: 3 },
+ model: "local/model",
+ provider: "lmstudio",
+ repairAttempts: 0,
+};
+
+function controlledTransport() {
+ let listener: ((event: AiEvent) => void) | undefined;
+ const unsubscribe = vi.fn();
+ const start = vi.fn<(request: AiStartRequest) => Promise>();
+ start.mockResolvedValue({ ok: true });
+ const abort = vi.fn<(id: number) => Promise>();
+ abort.mockResolvedValue(undefined);
+ const onEvent = vi.fn((next: (event: AiEvent) => void) => {
+ listener = next;
+ return unsubscribe;
+ });
+ const models = vi.fn<(providerId: string) => Promise>();
+ models.mockResolvedValue({ ok: true, message: "Connected.", models: ["b", "a"] });
+ const transport: MainAiTransport = {
+ start,
+ abort,
+ onEvent,
+ models,
+ };
+ return {
+ transport,
+ start,
+ abort,
+ models,
+ unsubscribe,
+ emit: (event: AiEvent) => {
+ listener?.(event);
+ },
+ };
+}
+
+describe("MainProcessAiClient", () => {
+ it("forwards structured controls and reconstructs ordered streamed content", async () => {
+ const { transport, start, unsubscribe, emit } = controlledTransport();
+ const onDelta = vi.fn();
+ const client = new MainProcessAiClient(profile, transport);
+ const promise = client.chat({
+ ...request,
+ onDelta,
+ jsonSchema: { name: "answer", schema: { type: "object" } },
+ reasoning: "none",
+ });
+ await vi.waitFor(() => {
+ expect(start).toHaveBeenCalledOnce();
+ });
+ const sent = start.mock.calls[0]?.[0];
+ expect(sent).toMatchObject({
+ providerId: "lmstudio",
+ model: "local/model",
+ messages: request.messages,
+ lane: {
+ temperature: 0.1,
+ topP: 0.9,
+ topK: 20,
+ presencePenalty: 0,
+ maxTokens: 200,
+ },
+ jsonSchema: { name: "answer", schema: { type: "object" } },
+ reasoning: "none",
+ });
+ emit({ id: sent?.id ?? -1, type: "delta", text: "hello " });
+ emit({ id: sent?.id ?? -1, type: "delta", text: "world" });
+ emit({ id: sent?.id ?? -1, type: "complete", result: completion });
+ await expect(promise).resolves.toEqual({ content: "hello world", ...completion });
+ expect(onDelta.mock.calls.flat()).toEqual(["hello ", "world"]);
+ expect(unsubscribe).toHaveBeenCalledOnce();
+ });
+
+ it("turns a rejected start into a user-facing LLM error and cleans up", async () => {
+ const { transport, start, unsubscribe } = controlledTransport();
+ start.mockResolvedValue({ ok: false, message: "LM Studio is offline." });
+ const promise = new MainProcessAiClient(profile, transport).chat(request);
+ await expect(promise).rejects.toBeInstanceOf(LlmError);
+ await expect(promise).rejects.toMatchObject({
+ code: "LmUnreachable",
+ message: "LM Studio is offline.",
+ });
+ expect(unsubscribe).toHaveBeenCalledOnce();
+ });
+
+ it("propagates known provider errors and maps unknown codes safely", async () => {
+ const { transport, start, emit } = controlledTransport();
+ const client = new MainProcessAiClient(profile, transport);
+ const known = client.chat(request);
+ await vi.waitFor(() => {
+ expect(start).toHaveBeenCalledTimes(1);
+ });
+ const knownId = start.mock.calls[0]?.[0].id ?? -1;
+ emit({ id: knownId, type: "error", code: "ModelNotLoaded", message: "Load it first." });
+ await expect(known).rejects.toMatchObject({ code: "ModelNotLoaded" });
+
+ const unknown = client.chat(request);
+ await vi.waitFor(() => {
+ expect(start).toHaveBeenCalledTimes(2);
+ });
+ const unknownId = start.mock.calls[1]?.[0].id ?? -1;
+ emit({ id: unknownId, type: "error", code: "ProviderError", message: "Bad reply." });
+ await expect(unknown).rejects.toMatchObject({ code: "BadResponse" });
+ });
+
+ it("aborts main exactly once and removes its listener", async () => {
+ const { transport, start, abort, unsubscribe } = controlledTransport();
+ const controller = new AbortController();
+ const promise = new MainProcessAiClient(profile, transport).chat({
+ ...request,
+ signal: controller.signal,
+ });
+ await vi.waitFor(() => {
+ expect(start).toHaveBeenCalledOnce();
+ });
+ controller.abort();
+ await expect(promise).rejects.toMatchObject({ code: "Aborted", message: "Cancelled." });
+ expect(abort).toHaveBeenCalledOnce();
+ expect(unsubscribe).toHaveBeenCalledOnce();
+ });
+
+ it("uses the main-process model discovery result for health", async () => {
+ const { transport, models } = controlledTransport();
+ await expect(new MainProcessAiClient(profile, transport).health()).resolves.toEqual({
+ reachable: true,
+ models: ["b", "a"],
+ });
+ expect(models).toHaveBeenCalledWith("lmstudio");
+ });
+});
diff --git a/apps/desktop/src/llm/mainProcessClient.ts b/apps/desktop/src/llm/mainProcessClient.ts
new file mode 100644
index 0000000..2fbdd52
--- /dev/null
+++ b/apps/desktop/src/llm/mainProcessClient.ts
@@ -0,0 +1,150 @@
+import {
+ LlmError,
+ type AiClient,
+ type ChatRequest,
+ type ChatResult,
+ type LlmErrorCode,
+ type ProviderCapabilities,
+ type ProviderHealth,
+} from "@lucid/llm";
+import type {
+ AiEvent,
+ AiStartRequest,
+ AiStartResult,
+ ProviderConnectionResult,
+ ProviderProfile,
+} from "../../electron/ipc/types";
+
+export interface MainAiTransport {
+ start(request: AiStartRequest): Promise;
+ abort(id: number): Promise;
+ onEvent(listener: (event: AiEvent) => void): () => void;
+ models(id: string): Promise;
+}
+
+const LM_STUDIO_CAPABILITIES: Readonly = Object.freeze({
+ streaming: true,
+ cancellation: true,
+ structuredOutput: "json-schema",
+ modelDiscovery: true,
+ usage: "provider-or-estimated",
+ reasoningControl: true,
+ localModelManagement: false,
+ customBaseUrl: true,
+});
+
+const ERROR_CODES = new Set([
+ "LmUnreachable",
+ "ModelNotLoaded",
+ "ReasoningLeak",
+ "Timeout",
+ "FirstTokenTimeout",
+ "ConnectionLost",
+ "BadResponse",
+ "Aborted",
+]);
+const errorCode = (value: string): LlmErrorCode =>
+ ERROR_CODES.has(value as LlmErrorCode) ? (value as LlmErrorCode) : "BadResponse";
+
+let requestId = 0;
+
+const defaultTransport = (): MainAiTransport => ({
+ ...window.lucid.ai,
+ models: (id) => window.lucid.providers.models(id),
+});
+
+/**
+ * Renderer-side AiClient facade. It carries only prompts, lane controls, and a persisted profile id;
+ * endpoint resolution, credentials, HTTP, retries, and response parsing stay in Electron main.
+ */
+export class MainProcessAiClient implements AiClient {
+ readonly provider: string;
+ readonly capabilities = LM_STUDIO_CAPABILITIES;
+ readonly localModels = undefined;
+
+ constructor(
+ private readonly profile: ProviderProfile,
+ private readonly transport: MainAiTransport = defaultTransport(),
+ ) {
+ this.provider = profile.kind;
+ }
+
+ async health(signal?: AbortSignal): Promise {
+ if (signal?.aborted) return { reachable: false, models: [] };
+ try {
+ const result = await this.transport.models(this.profile.id);
+ return { reachable: result.ok, models: result.models ?? [] };
+ } catch {
+ return { reachable: false, models: [] };
+ }
+ }
+
+ chat(request: ChatRequest): Promise {
+ const id = ++requestId;
+ const chunks: string[] = [];
+ return new Promise((resolve, reject) => {
+ let finished = false;
+ const cleanup = () => {
+ stop();
+ request.signal?.removeEventListener("abort", onAbort);
+ };
+ const fail = (code: LlmErrorCode, message: string, cause?: unknown) => {
+ if (finished) return;
+ finished = true;
+ cleanup();
+ reject(new LlmError(code, message, cause === undefined ? undefined : { cause }));
+ };
+ const onAbort = () => {
+ if (finished) return;
+ void this.transport.abort(id).catch(() => undefined);
+ fail("Aborted", "Cancelled.");
+ };
+ const stop = this.transport.onEvent((event) => {
+ if (event.id !== id || finished) return;
+ if (event.type === "delta") {
+ chunks.push(event.text);
+ request.onDelta?.(event.text);
+ return;
+ }
+ if (event.type === "error") {
+ fail(errorCode(event.code), event.message);
+ return;
+ }
+ finished = true;
+ cleanup();
+ resolve({ content: chunks.join(""), ...event.result });
+ });
+ request.signal?.addEventListener("abort", onAbort, { once: true });
+ if (request.signal?.aborted) {
+ onAbort();
+ return;
+ }
+ void this.transport
+ .start({
+ id,
+ providerId: this.profile.id,
+ model: request.lane.model,
+ messages: request.messages,
+ lane: {
+ temperature: request.lane.temperature,
+ topP: request.lane.topP,
+ topK: request.lane.topK,
+ presencePenalty: request.lane.presencePenalty,
+ maxTokens: request.lane.maxTokens,
+ },
+ jsonSchema: request.jsonSchema,
+ reasoning: request.reasoning,
+ })
+ .then((result) => {
+ if (!result.ok) fail("LmUnreachable", result.message);
+ })
+ .catch((error: unknown) => {
+ fail(
+ "ConnectionLost",
+ error instanceof Error ? error.message : "The AI request could not start.",
+ error,
+ );
+ });
+ });
+ }
+}
diff --git a/apps/desktop/src/llm/model.ts b/apps/desktop/src/llm/model.ts
index e48e2ba..a2b4348 100644
--- a/apps/desktop/src/llm/model.ts
+++ b/apps/desktop/src/llm/model.ts
@@ -1,5 +1,5 @@
-import { DEFAULT_LANES, LmStudioClient, type AiClient, type Lane } from "@lucid/llm";
-import { bridgeFetch } from "./bridgeFetch";
+import { DEFAULT_LANES, type AiClient, type Lane } from "@lucid/llm";
+import { MainProcessAiClient } from "./mainProcessClient";
export interface Model {
client: AiClient;
@@ -15,16 +15,22 @@ export function resetModel(): void {
cached = undefined;
}
-/** The LM Studio client and lanes for this window, built from the settings. */
+/** The selected provider and lanes for this window, built from public settings without secrets. */
export function getModel(): Promise {
- cached ??= window.lucid.settings.get().then((settings) => ({
- client: new LmStudioClient({ baseUrl: settings.llm.baseUrl, fetch: bridgeFetch }),
- lanes: {
- input: { ...DEFAULT_LANES.input, model: settings.llm.model },
- command: { ...DEFAULT_LANES.command, model: settings.llm.model },
- advisor: { ...DEFAULT_LANES.advisor, model: settings.llm.model },
- },
- name: settings.llm.model.split("/").pop() ?? settings.llm.model,
- }));
+ cached ??= window.lucid.settings.get().then((settings) => {
+ const profile =
+ settings.ai.profiles.find((candidate) => candidate.id === settings.ai.defaultProfileId) ??
+ settings.ai.profiles[0];
+ if (!profile) throw new Error("Choose an AI provider in Settings.");
+ return {
+ client: new MainProcessAiClient(profile),
+ lanes: {
+ input: { ...DEFAULT_LANES.input, model: profile.model },
+ command: { ...DEFAULT_LANES.command, model: profile.model },
+ advisor: { ...DEFAULT_LANES.advisor, model: profile.model },
+ },
+ name: profile.model.split("/").pop() ?? profile.model,
+ };
+ });
return cached;
}
diff --git a/docs/architecture.md b/docs/architecture.md
index 08cbd7d..a2f4f58 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -55,7 +55,7 @@ flowchart TD
## AI provider boundary
Input, Command, Advisor, the desktop job queue, and evals depend on Lucid's `AiClient` interface in
-`packages/llm/src/provider.ts`; only application composition constructs `LmStudioClient`. The contract covers streamed text,
+`packages/llm/src/provider.ts`; Electron main-process composition constructs `LmStudioClient`. The contract covers streamed text,
`AbortSignal` cancellation, optional JSON Schema output, usage and timing results, model discovery, and optional local-model
controls. Every adapter publishes a capability record, so unsupported behavior is data rather than an assumption. LM Studio
currently advertises JSON Schema, streaming, cancellation, discovery, reasoning controls, and custom base URLs; it does not
@@ -79,8 +79,9 @@ configuration and requests execute in Electron's main process. Credentials are n
can only save, delete, or ask whether one is present. Persistent values are encrypted with Electron `safeStorage`; Linux's
`basic_text` backend and temporary encryption failures use memory for that app session instead of writing plaintext. Provider
addresses require HTTPS except for loopback HTTP, and redirects are followed only after validation and only on the same origin.
-The old loopback-only LM Studio fetch bridge remains temporarily for compatibility and is removed when LM Studio moves onto
-this service.
+LM Studio uses this service too: the renderer implements `AiClient` over start/abort/events, while the existing
+`LmStudioClient` performs streaming, retries, structured output, and usage accounting in main. The earlier renderer-side
+HTTP-shaped bridge has been removed.
## PDF
From 6163a9204b712719de981163fc847814ea039eff Mon Sep 17 00:00:00 2001
From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com>
Date: Mon, 19 Oct 2026 13:21:52 +0530
Subject: [PATCH 06/13] feat(llm): add local Ollama provider (#39)
---
apps/desktop/e2e/settings.spec.ts | 23 ++
apps/desktop/electron/ai/providers.test.ts | 22 +-
apps/desktop/electron/ai/providers.ts | 17 +-
apps/desktop/electron/ai/runtime.test.ts | 32 ++
apps/desktop/electron/ai/runtime.ts | 17 +-
apps/desktop/electron/settings.test.ts | 16 +
apps/desktop/electron/settings.ts | 6 +
.../desktop/src/llm/mainProcessClient.test.ts | 19 ++
apps/desktop/src/llm/mainProcessClient.ts | 10 +-
apps/desktop/src/llm/useLmStatus.ts | 16 +-
.../desktop/src/settings/ProviderSettings.tsx | 7 +
docs/architecture.md | 5 +-
packages/llm/src/index.ts | 1 +
packages/llm/src/ollama.live.test.ts | 54 ++++
packages/llm/src/ollama.test.ts | 247 +++++++++++++++
packages/llm/src/ollama.ts | 299 ++++++++++++++++++
16 files changed, 772 insertions(+), 19 deletions(-)
create mode 100644 packages/llm/src/ollama.live.test.ts
create mode 100644 packages/llm/src/ollama.test.ts
create mode 100644 packages/llm/src/ollama.ts
diff --git a/apps/desktop/e2e/settings.spec.ts b/apps/desktop/e2e/settings.spec.ts
index 655c286..5cfe67b 100644
--- a/apps/desktop/e2e/settings.spec.ts
+++ b/apps/desktop/e2e/settings.spec.ts
@@ -136,6 +136,29 @@ test("provider profiles expose only relevant controls and keep cloud use explici
}
});
+test("selecting Ollama shows structured-output guidance and hides LM Studio status", async () => {
+ test.setTimeout(60_000);
+ const { app, page } = await launch();
+ try {
+ await page.getByRole("complementary").getByRole("button", { name: "New page" }).click();
+ const pill = page.getByRole("group", { name: "Model status" });
+ await expect(pill).toHaveAttribute("data-state", "ready");
+
+ await page.getByRole("button", { name: /Settings/ }).click();
+ const box = dialog(page);
+ await box.getByLabel("Provider to add").selectOption("ollama");
+ await box.getByRole("button", { name: "Add", exact: true }).click();
+ await expect(box).toContainText("Command Mode requires a current Ollama version");
+ await field(page, MODEL).fill("qwen3:8b");
+ await box.getByRole("button", { name: "Set default" }).click();
+ await box.getByRole("button", { name: "Save", exact: true }).click();
+ await expect(box).toBeHidden();
+ await expect(pill).toBeHidden();
+ } finally {
+ await app.close();
+ }
+});
+
test("saved settings apply at once: the model asked, the PDF footer, the words; and they are in the settings file", async () => {
test.setTimeout(120_000);
const notes = "## Caching\n- Redis keeps hot keys in memory.";
diff --git a/apps/desktop/electron/ai/providers.test.ts b/apps/desktop/electron/ai/providers.test.ts
index 671eee3..fc2086c 100644
--- a/apps/desktop/electron/ai/providers.test.ts
+++ b/apps/desktop/electron/ai/providers.test.ts
@@ -31,7 +31,10 @@ describe("inspectProvider", () => {
const request = vi
.fn()
.mockResolvedValue(
- new Response(JSON.stringify({ models: [{ name: "llama3.2" }] }), { status: 200 }),
+ new Response(
+ JSON.stringify({ models: [{ name: "llama3.2" }, { name: "deepseek:cloud" }] }),
+ { status: 200 },
+ ),
);
const result = await inspectProvider(
profile({ kind: "ollama", baseUrl: "http://localhost:11434" }),
@@ -42,6 +45,23 @@ describe("inspectProvider", () => {
expect(request.mock.calls[0]?.[0]).toEqual(new URL("http://localhost:11434/api/tags"));
});
+ it("gives actionable Ollama setup guidance", async () => {
+ const ollama = profile({ kind: "ollama", baseUrl: "http://localhost:11434" });
+ const empty = await inspectProvider(
+ ollama,
+ undefined,
+ vi.fn().mockResolvedValue(Response.json({ models: [] })),
+ );
+ expect(empty.message).toContain("ollama pull");
+
+ const offline = await inspectProvider(
+ ollama,
+ undefined,
+ vi.fn().mockRejectedValue(new Error("connection refused")),
+ );
+ expect(offline.message).toContain("ollama serve");
+ });
+
it("requires credentials before contacting a cloud provider", async () => {
const request = vi.fn();
const result = await inspectProvider(
diff --git a/apps/desktop/electron/ai/providers.ts b/apps/desktop/electron/ai/providers.ts
index ffc4011..4b31568 100644
--- a/apps/desktop/electron/ai/providers.ts
+++ b/apps/desktop/electron/ai/providers.ts
@@ -26,7 +26,7 @@ const headersFor = (profile: ProviderProfile, secret?: string): Record {
+const modelIds = (value: unknown, localModelsOnly = false): string[] => {
if (!value || typeof value !== "object") return [];
const body = value as { data?: unknown; models?: unknown };
const entries = Array.isArray(body.data)
@@ -43,7 +43,9 @@ const modelIds = (value: unknown): string[] => {
const id = item.id ?? item.name ?? item.model;
return typeof id === "string" && id.length <= 240 ? id : undefined;
})
- .filter((id): id is string => id !== undefined),
+ .filter(
+ (id): id is string => id !== undefined && (!localModelsOnly || !/:cloud$/i.test(id)),
+ ),
),
]
.sort((a, b) => a.localeCompare(b))
@@ -71,19 +73,24 @@ export async function inspectProvider(
ok: false,
message: `${provider.label} returned HTTP ${String(response.status)}. Check the address, API key, and account access.`,
};
- const models = modelIds(await response.json());
+ const models = modelIds(await response.json(), profile.kind === "ollama");
return {
ok: true,
message:
models.length > 0
? `Connected to ${provider.label}. ${String(models.length)} model${models.length === 1 ? "" : "s"} available.`
- : `Connected to ${provider.label}, but it reported no models.`,
+ : profile.kind === "ollama"
+ ? "Connected to Ollama, but no local models are installed. Run “ollama pull ”, then fetch models again."
+ : `Connected to ${provider.label}, but it reported no models.`,
models,
};
} catch (error) {
return {
ok: false,
- message: `Could not connect to ${provider.label}: ${redactSensitive(error, secret ? [secret] : [])}`,
+ message:
+ profile.kind === "ollama"
+ ? `Could not connect to Ollama. Start it with “ollama serve”, then try again. ${redactSensitive(error)}`
+ : `Could not connect to ${provider.label}: ${redactSensitive(error, secret ? [secret] : [])}`,
};
}
}
diff --git a/apps/desktop/electron/ai/runtime.test.ts b/apps/desktop/electron/ai/runtime.test.ts
index a81413c..2e9e4f7 100644
--- a/apps/desktop/electron/ai/runtime.test.ts
+++ b/apps/desktop/electron/ai/runtime.test.ts
@@ -30,6 +30,38 @@ describe("createRuntimeResolver", () => {
expect(runtime?.endpoint).toBe("http://127.0.0.1:4567");
});
+ it("builds the native Ollama adapter without applying the LM Studio override", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "lucid-runtime-"));
+ await mkdir(join(dir, ".lucid"));
+ await writeFile(
+ join(dir, ".lucid", "settings.json"),
+ JSON.stringify({
+ ai: {
+ defaultProfileId: "ollama",
+ profiles: [
+ {
+ id: "ollama",
+ name: "Ollama",
+ kind: "ollama",
+ baseUrl: "http://localhost:11434",
+ model: "qwen3:8b",
+ },
+ ],
+ },
+ }),
+ "utf8",
+ );
+ const runtime = await createRuntimeResolver(
+ dir,
+ vi.fn(),
+ "http://127.0.0.1:4567",
+ )("ollama");
+ expect(runtime).toMatchObject({
+ endpoint: "http://localhost:11434",
+ client: { provider: "ollama" },
+ });
+ });
+
it("does not resolve providers whose production adapter has not landed yet", async () => {
const dir = await mkdtemp(join(tmpdir(), "lucid-runtime-"));
await mkdir(join(dir, ".lucid"));
diff --git a/apps/desktop/electron/ai/runtime.ts b/apps/desktop/electron/ai/runtime.ts
index bb62713..8ea56e2 100644
--- a/apps/desktop/electron/ai/runtime.ts
+++ b/apps/desktop/electron/ai/runtime.ts
@@ -1,4 +1,4 @@
-import { LmStudioClient } from "@lucid/llm";
+import { LmStudioClient, OllamaClient } from "@lucid/llm";
import { loadSettings } from "../settings";
import type { AiRuntimeResolver } from "./service";
import { secureProviderFetch } from "./security";
@@ -12,14 +12,17 @@ export const createRuntimeResolver = (
return async (profileId) => {
const settings = await loadSettings(libraryDir);
const profile = settings.ai.profiles.find((candidate) => candidate.id === profileId);
- if (!profile || profile.kind !== "lmstudio") return undefined;
- const endpoint = (environmentUrl ?? profile.baseUrl).replace(/\/+$/, "");
+ if (!profile || (profile.kind !== "lmstudio" && profile.kind !== "ollama")) return undefined;
+ const endpoint = (
+ profile.kind === "lmstudio" ? (environmentUrl ?? profile.baseUrl) : profile.baseUrl
+ ).replace(/\/+$/, "");
+ const providerFetch: typeof fetch = (input, init) => secureProviderFetch(input, init, doFetch);
return {
endpoint,
- client: new LmStudioClient({
- baseUrl: endpoint,
- fetch: (input, init) => secureProviderFetch(input, init, doFetch),
- }),
+ client:
+ profile.kind === "lmstudio"
+ ? new LmStudioClient({ baseUrl: endpoint, fetch: providerFetch })
+ : new OllamaClient({ baseUrl: endpoint, fetch: providerFetch }),
};
};
};
diff --git a/apps/desktop/electron/settings.test.ts b/apps/desktop/electron/settings.test.ts
index e4dabef..2ad2ea7 100644
--- a/apps/desktop/electron/settings.test.ts
+++ b/apps/desktop/electron/settings.test.ts
@@ -191,6 +191,22 @@ describe("saveSettings", () => {
},
}),
).toBeInstanceOf(SettingsError);
+ expect(
+ await bad({
+ ai: {
+ defaultProfileId: "ollama",
+ profiles: [
+ {
+ id: "ollama",
+ name: "Ollama",
+ kind: "ollama",
+ baseUrl: "http://127.0.0.1:11434",
+ model: "remote:cloud",
+ },
+ ],
+ },
+ }),
+ ).toBeInstanceOf(SettingsError);
expect(
await bad({
ai: {
diff --git a/apps/desktop/electron/settings.ts b/apps/desktop/electron/settings.ts
index 20fc90f..f37d912 100644
--- a/apps/desktop/electron/settings.ts
+++ b/apps/desktop/electron/settings.ts
@@ -82,6 +82,12 @@ const AiSettingsSchema = z
message: "must be unique",
});
ids.add(profile.id);
+ if (profile.kind === "ollama" && /:cloud$/i.test(profile.model))
+ ctx.addIssue({
+ code: "custom",
+ path: ["profiles", index, "model"],
+ message: "Ollama Cloud models require a cloud provider profile",
+ });
const provider = AI_PROVIDER_CATALOG.find((candidate) => candidate.kind === profile.kind);
if (provider && !provider.cloud) {
const url = new URL(profile.baseUrl);
diff --git a/apps/desktop/src/llm/mainProcessClient.test.ts b/apps/desktop/src/llm/mainProcessClient.test.ts
index 9372572..978459c 100644
--- a/apps/desktop/src/llm/mainProcessClient.test.ts
+++ b/apps/desktop/src/llm/mainProcessClient.test.ts
@@ -160,4 +160,23 @@ describe("MainProcessAiClient", () => {
});
expect(models).toHaveBeenCalledWith("lmstudio");
});
+
+ it("declares Ollama structured output without claiming reasoning controls", () => {
+ const ollama = new MainProcessAiClient(
+ {
+ id: "ollama",
+ name: "Ollama",
+ kind: "ollama",
+ baseUrl: "http://127.0.0.1:11434",
+ model: "qwen3:8b",
+ },
+ controlledTransport().transport,
+ );
+ expect(ollama.provider).toBe("ollama");
+ expect(ollama.capabilities).toMatchObject({
+ structuredOutput: "json-schema",
+ usage: "provider-or-estimated",
+ reasoningControl: false,
+ });
+ });
});
diff --git a/apps/desktop/src/llm/mainProcessClient.ts b/apps/desktop/src/llm/mainProcessClient.ts
index 2fbdd52..b0d9172 100644
--- a/apps/desktop/src/llm/mainProcessClient.ts
+++ b/apps/desktop/src/llm/mainProcessClient.ts
@@ -22,13 +22,13 @@ export interface MainAiTransport {
models(id: string): Promise;
}
-const LM_STUDIO_CAPABILITIES: Readonly = Object.freeze({
+const LOCAL_CAPABILITIES: Readonly = Object.freeze({
streaming: true,
cancellation: true,
structuredOutput: "json-schema",
modelDiscovery: true,
usage: "provider-or-estimated",
- reasoningControl: true,
+ reasoningControl: false,
localModelManagement: false,
customBaseUrl: true,
});
@@ -59,7 +59,7 @@ const defaultTransport = (): MainAiTransport => ({
*/
export class MainProcessAiClient implements AiClient {
readonly provider: string;
- readonly capabilities = LM_STUDIO_CAPABILITIES;
+ readonly capabilities: Readonly;
readonly localModels = undefined;
constructor(
@@ -67,6 +67,10 @@ export class MainProcessAiClient implements AiClient {
private readonly transport: MainAiTransport = defaultTransport(),
) {
this.provider = profile.kind;
+ this.capabilities =
+ profile.kind === "lmstudio"
+ ? { ...LOCAL_CAPABILITIES, reasoningControl: true }
+ : LOCAL_CAPABILITIES;
}
async health(signal?: AbortSignal): Promise {
diff --git a/apps/desktop/src/llm/useLmStatus.ts b/apps/desktop/src/llm/useLmStatus.ts
index a2cfb84..001892b 100644
--- a/apps/desktop/src/llm/useLmStatus.ts
+++ b/apps/desktop/src/llm/useLmStatus.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import type { LmStatus } from "../../electron/ipc/types";
const POLL_MS = 8000;
@@ -9,10 +9,22 @@ export function useLmStatus() {
const [status, setStatus] = useState();
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
+ const refreshId = useRef(0);
const refresh = useCallback(async () => {
+ const id = ++refreshId.current;
try {
- setStatus(await window.lucid.lm.status());
+ const settings = await window.lucid.settings.get();
+ if (id !== refreshId.current) return;
+ const selected = settings.ai.profiles.find(
+ (profile) => profile.id === settings.ai.defaultProfileId,
+ );
+ if (selected?.kind !== "lmstudio") {
+ setStatus(undefined);
+ return;
+ }
+ const next = await window.lucid.lm.status();
+ if (id === refreshId.current) setStatus(next);
} catch {
// The status is a convenience; a failed check shows nothing new.
}
diff --git a/apps/desktop/src/settings/ProviderSettings.tsx b/apps/desktop/src/settings/ProviderSettings.tsx
index 4df60f1..5cbac6a 100644
--- a/apps/desktop/src/settings/ProviderSettings.tsx
+++ b/apps/desktop/src/settings/ProviderSettings.tsx
@@ -298,6 +298,13 @@ export function ProviderSettings({ value, lmStudioUrlLocked, onChange, onPersist
)}
+ {active.kind === "ollama" && (
+
+ Command Mode requires a current Ollama version and a model that accepts JSON Schema.
+ If Ollama rejects structured output, Lucid stops without applying the command.
+
+ )}
+
{
+ it("discovers the selected model and runs Input, Command, and Advisor controls", async () => {
+ const health = await client.health();
+ expect(health.reachable).toBe(true);
+ expect(health.models).toContain(model);
+
+ const input = await client.chat({
+ lane: { ...DEFAULT_LANES.input, model, maxTokens: 30 },
+ messages: [{ role: "user", content: "Say hello briefly." }],
+ });
+ expect(input.content.trim()).not.toBe("");
+
+ const deltas: string[] = [];
+ for (const lane of [DEFAULT_LANES.command, DEFAULT_LANES.advisor]) {
+ const result = await client.chat({
+ lane: { ...lane, model, maxTokens: 80 },
+ messages: [{ role: "user", content: 'Reply with JSON: {"answer":"yes"}.' }],
+ jsonSchema: {
+ name: "ollama_smoke",
+ schema: {
+ type: "object",
+ properties: { answer: { type: "string" } },
+ required: ["answer"],
+ additionalProperties: false,
+ },
+ },
+ onDelta: (piece) => deltas.push(piece),
+ });
+ expect(result.provider).toBe("ollama");
+ expect(result.usage.prompt).toBeGreaterThan(0);
+ expect(result.usage.completion).toBeGreaterThan(0);
+ expect(result.content).toBe(deltas.join(""));
+ const parsed = JSON.parse(result.content) as unknown;
+ expect(
+ parsed !== null &&
+ typeof parsed === "object" &&
+ "answer" in parsed &&
+ typeof parsed.answer === "string",
+ ).toBe(true);
+ deltas.length = 0;
+ }
+ }, 120_000);
+});
diff --git a/packages/llm/src/ollama.test.ts b/packages/llm/src/ollama.test.ts
new file mode 100644
index 0000000..f7d2c6c
--- /dev/null
+++ b/packages/llm/src/ollama.test.ts
@@ -0,0 +1,247 @@
+import { describe, expect, it } from "vitest";
+import { LlmError } from "./errors";
+import { DEFAULT_LANES } from "./lanes";
+import { OllamaClient } from "./ollama";
+
+const lane = { ...DEFAULT_LANES.input, model: "qwen3:8b" };
+const messages = [{ role: "user" as const, content: "hello" }];
+
+const failure = async (promise: Promise): Promise => {
+ try {
+ await promise;
+ } catch (error) {
+ if (error instanceof LlmError) return error;
+ throw error;
+ }
+ throw new Error("expected a failure");
+};
+
+const fetchWith =
+ (handler: (url: string, init?: RequestInit) => Response | Promise): typeof fetch =>
+ (input: Parameters[0], init?: Parameters[1]) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ return Promise.resolve(handler(url, init));
+ };
+
+const stream = (...chunks: object[]): Response =>
+ new Response(chunks.map((chunk) => JSON.stringify(chunk)).join("\n"), {
+ status: 200,
+ headers: { "Content-Type": "application/x-ndjson" },
+ });
+
+describe("OllamaClient", () => {
+ it("advertises the native local API capabilities honestly", () => {
+ const client = new OllamaClient();
+ expect(client.provider).toBe("ollama");
+ expect(client.capabilities).toEqual({
+ streaming: true,
+ cancellation: true,
+ structuredOutput: "json-schema",
+ modelDiscovery: true,
+ usage: "provider-or-estimated",
+ reasoningControl: false,
+ localModelManagement: false,
+ customBaseUrl: true,
+ });
+ });
+
+ it("discovers installed models from the tags endpoint", async () => {
+ const urls: string[] = [];
+ const client = new OllamaClient({
+ fetch: fetchWith((url) => {
+ urls.push(url);
+ return Response.json({
+ models: [{ model: "qwen3:8b" }, { name: "gemma3:4b" }, { name: "remote:cloud" }],
+ });
+ }),
+ });
+ await expect(client.health()).resolves.toEqual({
+ reachable: true,
+ models: ["qwen3:8b", "gemma3:4b"],
+ });
+ expect(urls).toEqual(["http://127.0.0.1:11434/api/tags"]);
+ });
+
+ it("streams content and maps schema, sampling, timing, and provider usage", async () => {
+ let sent: unknown;
+ const deltas: string[] = [];
+ const schema = { type: "object", properties: { answer: { type: "string" } } };
+ const client = new OllamaClient({
+ baseUrl: "http://localhost:11434/",
+ fetch: fetchWith((_url, init) => {
+ if (typeof init?.body !== "string") throw new Error("expected a JSON request body");
+ sent = JSON.parse(init.body) as unknown;
+ return stream(
+ { model: "qwen3:8b", message: { content: '{"answer":' }, done: false },
+ {
+ model: "qwen3:8b",
+ message: { content: '"yes"}' },
+ done: true,
+ prompt_eval_count: 12,
+ eval_count: 4,
+ },
+ );
+ }),
+ });
+ const result = await client.chat({
+ lane,
+ messages,
+ jsonSchema: { name: "answer", schema },
+ onDelta: (delta) => deltas.push(delta),
+ });
+ expect(result).toMatchObject({
+ content: '{"answer":"yes"}',
+ provider: "ollama",
+ model: "qwen3:8b",
+ usage: { prompt: 12, completion: 4, reasoning: 0, source: "provider" },
+ repairAttempts: 0,
+ });
+ expect(result.timings.firstTokenAt).not.toBeNull();
+ expect(deltas.join("")).toBe(result.content);
+ expect(sent).toMatchObject({
+ model: "qwen3:8b",
+ messages,
+ stream: true,
+ think: false,
+ format: schema,
+ options: {
+ temperature: lane.temperature,
+ top_p: lane.topP,
+ top_k: lane.topK,
+ num_predict: lane.maxTokens,
+ },
+ });
+ });
+
+ it("marks usage as estimated when Ollama omits counts", async () => {
+ const client = new OllamaClient({
+ fetch: fetchWith(() => stream({ message: { content: "hello" }, done: true })),
+ });
+ const result = await client.chat({ lane, messages });
+ expect(result.usage.source).toBe("estimated");
+ expect(result.usage.prompt).toBeGreaterThan(0);
+ expect(result.usage.completion).toBeGreaterThan(0);
+ });
+
+ it("gives actionable missing-model errors", async () => {
+ const client = new OllamaClient({
+ fetch: fetchWith(() => Response.json({ error: "model not found" }, { status: 404 })),
+ });
+ const error = await failure(client.chat({ lane, messages }));
+ expect(error.code).toBe("ModelNotLoaded");
+ expect(error.message).toContain("ollama pull qwen3:8b");
+ });
+
+ it("rejects Ollama Cloud aliases before making a local-profile request", async () => {
+ let requested = false;
+ const client = new OllamaClient({
+ fetch: fetchWith(() => {
+ requested = true;
+ return stream();
+ }),
+ });
+ const error = await failure(
+ client.chat({ lane: { ...lane, model: "remote:cloud" }, messages }),
+ );
+ expect(error.code).toBe("BadResponse");
+ expect(error.message).toContain("Ollama Cloud");
+ expect(requested).toBe(false);
+ });
+
+ it("fails safely when structured output is rejected", async () => {
+ const client = new OllamaClient({
+ fetch: fetchWith(() => Response.json({ error: "invalid format" }, { status: 400 })),
+ });
+ const error = await failure(
+ client.chat({
+ lane,
+ messages,
+ jsonSchema: { name: "answer", schema: { type: "object" } },
+ }),
+ );
+ expect(error.code).toBe("BadResponse");
+ expect(error.message).toContain("structured output");
+ expect(error.message).toContain("Command Mode");
+ });
+
+ it("reports NDJSON errors and truncated streams", async () => {
+ const midstream = new OllamaClient({
+ fetch: fetchWith(() => stream({ message: { content: "half" } }, { error: "out of memory" })),
+ });
+ expect((await failure(midstream.chat({ lane, messages }))).message).toContain("out of memory");
+
+ const truncated = new OllamaClient({
+ fetch: fetchWith(() => stream({ message: { content: "half" }, done: false })),
+ });
+ expect((await failure(truncated.chat({ lane, messages }))).code).toBe("ConnectionLost");
+ });
+
+ it("supports cancellation before a request and while waiting for the first token", async () => {
+ const early = new AbortController();
+ early.abort();
+ const client = new OllamaClient({ fetch: fetchWith(() => stream()) });
+ expect((await failure(client.chat({ lane, messages, signal: early.signal }))).code).toBe(
+ "Aborted",
+ );
+
+ const later = new AbortController();
+ const waiting = new OllamaClient({
+ fetch: fetchWith(
+ (_url, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => {
+ reject(new DOMException("aborted", "AbortError"));
+ });
+ }),
+ ),
+ }).chat({ lane, messages, signal: later.signal });
+ later.abort();
+ expect((await failure(waiting)).code).toBe("Aborted");
+ });
+
+ it("stops an active stream when cancelled after a delta", async () => {
+ const controller = new AbortController();
+ const client = new OllamaClient({
+ fetch: fetchWith((_url, init) => {
+ const body = new ReadableStream({
+ start(streamController) {
+ streamController.enqueue(
+ new TextEncoder().encode('{"message":{"content":"first"},"done":false}\n'),
+ );
+ init?.signal?.addEventListener("abort", () => {
+ streamController.error(new DOMException("aborted", "AbortError"));
+ });
+ },
+ });
+ return new Response(body);
+ }),
+ });
+ const error = await failure(
+ client.chat({
+ lane,
+ messages,
+ signal: controller.signal,
+ onDelta: () => {
+ controller.abort();
+ },
+ }),
+ );
+ expect(error.code).toBe("Aborted");
+ });
+
+ it("classifies a first-token timeout", async () => {
+ const client = new OllamaClient({
+ firstTokenTimeoutMs: 5,
+ requestTimeoutMs: 100,
+ fetch: fetchWith(
+ (_url, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => {
+ reject(new DOMException("aborted", "AbortError"));
+ });
+ }),
+ ),
+ });
+ expect((await failure(client.chat({ lane, messages }))).code).toBe("FirstTokenTimeout");
+ });
+});
diff --git a/packages/llm/src/ollama.ts b/packages/llm/src/ollama.ts
new file mode 100644
index 0000000..d6ed666
--- /dev/null
+++ b/packages/llm/src/ollama.ts
@@ -0,0 +1,299 @@
+import { LlmError, isLlmError, type LlmErrorCode } from "./errors";
+import type {
+ AiClient,
+ ChatRequest,
+ ChatResult,
+ ProviderCapabilities,
+ ProviderHealth,
+} from "./provider";
+
+export interface OllamaClientOptions {
+ baseUrl?: string;
+ fetch?: typeof fetch;
+ firstTokenTimeoutMs?: number;
+ requestTimeoutMs?: number;
+}
+
+interface OllamaChunk {
+ model?: unknown;
+ message?: { content?: unknown; thinking?: unknown };
+ done?: unknown;
+ prompt_eval_count?: unknown;
+ eval_count?: unknown;
+ error?: unknown;
+}
+
+const estimateTokens = (text: string): number => Math.max(1, Math.ceil(text.length / 4));
+const validCount = (value: unknown): value is number =>
+ typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
+const count = (value: unknown): number => (validCount(value) ? value : 0);
+
+async function* readNdjson(stream: ReadableStream): AsyncGenerator {
+ const decoder = new TextDecoder();
+ let buffer = "";
+ for await (const chunk of stream as unknown as AsyncIterable) {
+ buffer += decoder.decode(chunk, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() ?? "";
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed) yield trimmed;
+ }
+ }
+ buffer += decoder.decode();
+ const tail = buffer.trim();
+ if (tail) yield tail;
+}
+
+const responseError = async (response: Response): Promise => {
+ const text = await response.text().catch(() => "");
+ try {
+ const body = JSON.parse(text) as { error?: unknown };
+ return typeof body.error === "string" ? body.error : text;
+ } catch {
+ return text;
+ }
+};
+
+/** Ollama's native local API adapter. Network access is supplied by Electron main. */
+export class OllamaClient implements AiClient {
+ readonly provider = "ollama";
+ readonly localModels = undefined;
+ readonly capabilities: Readonly = Object.freeze({
+ streaming: true,
+ cancellation: true,
+ structuredOutput: "json-schema",
+ modelDiscovery: true,
+ usage: "provider-or-estimated",
+ reasoningControl: false,
+ localModelManagement: false,
+ customBaseUrl: true,
+ });
+ private readonly baseUrl: string;
+ private readonly doFetch: typeof fetch;
+ private readonly firstTokenTimeoutMs: number;
+ private readonly requestTimeoutMs: number;
+
+ constructor(options: OllamaClientOptions = {}) {
+ this.baseUrl = (options.baseUrl ?? "http://127.0.0.1:11434").replace(/\/+$/, "");
+ this.doFetch = options.fetch ?? ((input, init) => fetch(input, init));
+ this.firstTokenTimeoutMs = options.firstTokenTimeoutMs ?? 120_000;
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 180_000;
+ }
+
+ async health(signal?: AbortSignal): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => {
+ controller.abort();
+ }, 3000);
+ const stop = () => {
+ controller.abort();
+ };
+ signal?.addEventListener("abort", stop);
+ try {
+ const response = await this.doFetch(`${this.baseUrl}/api/tags`, {
+ signal: controller.signal,
+ });
+ if (!response.ok) return { reachable: true, models: [] };
+ const body = (await response.json()) as { models?: unknown };
+ const models = Array.isArray(body.models)
+ ? body.models
+ .map((entry) => {
+ if (!entry || typeof entry !== "object") return undefined;
+ const item = entry as { model?: unknown; name?: unknown };
+ const id = item.model ?? item.name;
+ return typeof id === "string" ? id : undefined;
+ })
+ .filter((id): id is string => id !== undefined && !/:cloud$/i.test(id))
+ : [];
+ return { reachable: true, models };
+ } catch {
+ return { reachable: false, models: [] };
+ } finally {
+ clearTimeout(timer);
+ signal?.removeEventListener("abort", stop);
+ }
+ }
+
+ async chat(request: ChatRequest): Promise {
+ try {
+ return await this.once(request);
+ } catch (error) {
+ if (isLlmError(error) && error.code === "ReasoningLeak") {
+ const repaired = await this.once(request);
+ return { ...repaired, repairAttempts: repaired.repairAttempts + 1 };
+ }
+ throw error;
+ }
+ }
+
+ private async once(request: ChatRequest): Promise {
+ if (/:cloud$/i.test(request.lane.model))
+ throw new LlmError(
+ "BadResponse",
+ "That model runs in Ollama Cloud. Choose an installed local model or configure Ollama Cloud explicitly in Settings.",
+ );
+ const body = {
+ model: request.lane.model,
+ messages: request.messages,
+ stream: true,
+ think: false,
+ keep_alive: "1h",
+ options: {
+ temperature: request.lane.temperature,
+ top_p: request.lane.topP,
+ top_k: request.lane.topK,
+ num_predict: request.lane.maxTokens,
+ },
+ ...(request.jsonSchema ? { format: request.jsonSchema.schema } : {}),
+ };
+ const requestCharacters = JSON.stringify(body).length;
+ const controller = new AbortController();
+ let reason: LlmErrorCode | undefined;
+ const abort = (why: LlmErrorCode) => {
+ reason ??= why;
+ controller.abort();
+ };
+ if (request.signal?.aborted) throw new LlmError("Aborted", "Cancelled.");
+ const onUserAbort = () => {
+ abort("Aborted");
+ };
+ request.signal?.addEventListener("abort", onUserAbort);
+ const requestTimer = setTimeout(() => {
+ abort("Timeout");
+ }, this.requestTimeoutMs);
+ let firstTokenTimer: ReturnType | undefined = setTimeout(() => {
+ abort("FirstTokenTimeout");
+ }, this.firstTokenTimeoutMs);
+ const clearFirstToken = () => {
+ if (firstTokenTimer !== undefined) clearTimeout(firstTokenTimer);
+ firstTokenTimer = undefined;
+ };
+ const fail = (fallback: LlmError): LlmError => {
+ if (reason === "Aborted") return new LlmError("Aborted", "Cancelled.");
+ if (reason === "Timeout")
+ return new LlmError(
+ "Timeout",
+ "Ollama is taking too long. Try again, or choose a smaller model.",
+ );
+ if (reason === "FirstTokenTimeout")
+ return new LlmError("FirstTokenTimeout", "Ollama did not start answering in time.");
+ return fallback;
+ };
+
+ const sentAt = Date.now();
+ let completed = false;
+ try {
+ let response: Response;
+ try {
+ response = await this.doFetch(`${this.baseUrl}/api/chat`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ });
+ } catch (error) {
+ throw fail(
+ new LlmError("LmUnreachable", `Can't reach Ollama at ${this.baseUrl}. Is it running?`, {
+ cause: error,
+ }),
+ );
+ }
+
+ if (!response.ok) {
+ const message = await responseError(response);
+ if (response.status === 404 || /model .*not found|pull model/i.test(message))
+ throw new LlmError(
+ "ModelNotLoaded",
+ `${request.lane.model} isn't installed in Ollama. Run “ollama pull ${request.lane.model}”, then try again.`,
+ );
+ if (request.jsonSchema && response.status === 400)
+ throw new LlmError(
+ "BadResponse",
+ "Ollama rejected Lucid’s structured output request. Update Ollama or choose a model that supports JSON Schema before using Command Mode.",
+ );
+ throw new LlmError(
+ "BadResponse",
+ `Ollama answered ${String(response.status)}: ${message.slice(0, 300)}`,
+ );
+ }
+ if (!response.body) throw new LlmError("BadResponse", "Ollama sent an empty response.");
+
+ let content = "";
+ let firstTokenAt: number | null = null;
+ let sawThinking = false;
+ let done = false;
+ let model = request.lane.model;
+ const usage = { prompt: 0, completion: 0, reasoning: 0 };
+ let usageReported = false;
+ try {
+ for await (const line of readNdjson(response.body)) {
+ let chunk: OllamaChunk;
+ try {
+ chunk = JSON.parse(line) as OllamaChunk;
+ } catch {
+ throw new LlmError("BadResponse", "Ollama sent an invalid streaming response.");
+ }
+ if (typeof chunk.error === "string")
+ throw new LlmError("BadResponse", `Ollama reported an error: ${chunk.error}`);
+ if (typeof chunk.message?.thinking === "string" && chunk.message.thinking)
+ sawThinking = true;
+ const piece = chunk.message?.content;
+ if (typeof piece === "string" && piece) {
+ if (firstTokenAt === null) {
+ firstTokenAt = Date.now();
+ clearFirstToken();
+ }
+ content += piece;
+ request.onDelta?.(piece);
+ }
+ if (typeof chunk.model === "string") model = chunk.model;
+ if (chunk.done === true) {
+ done = true;
+ usage.prompt = count(chunk.prompt_eval_count);
+ usage.completion = count(chunk.eval_count);
+ usageReported = validCount(chunk.prompt_eval_count) && validCount(chunk.eval_count);
+ }
+ }
+ } catch (error) {
+ if (isLlmError(error)) throw error;
+ throw fail(
+ new LlmError("ConnectionLost", "The connection to Ollama dropped mid-answer.", {
+ cause: error,
+ }),
+ );
+ }
+ if (reason) throw fail(new LlmError("ConnectionLost", "The connection to Ollama dropped."));
+ if (!done)
+ throw new LlmError("ConnectionLost", "The connection to Ollama dropped mid-answer.");
+ if (content.trim() === "") {
+ if (sawThinking)
+ throw new LlmError("ReasoningLeak", "The model only thought and gave no answer.");
+ throw new LlmError("BadResponse", "The model gave an empty answer.");
+ }
+
+ completed = true;
+ return {
+ content,
+ usage: usageReported
+ ? { ...usage, source: "provider" }
+ : {
+ prompt: estimateTokens(JSON.stringify(request.messages)),
+ completion: estimateTokens(content),
+ reasoning: 0,
+ source: "estimated",
+ },
+ characters: { request: requestCharacters, response: content.length },
+ timings: { sentAt, firstTokenAt, doneAt: Date.now() },
+ model,
+ provider: this.provider,
+ repairAttempts: 0,
+ };
+ } finally {
+ clearTimeout(requestTimer);
+ clearFirstToken();
+ request.signal?.removeEventListener("abort", onUserAbort);
+ if (!completed) controller.abort();
+ }
+ }
+}
From 455cebca7afac0b68993e4871e9a635f891d5f35 Mon Sep 17 00:00:00 2001
From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com>
Date: Mon, 19 Oct 2026 13:40:08 +0530
Subject: [PATCH 07/13] feat(llm): add native OpenAI and Anthropic adapters
(#40)
---
apps/desktop/electron/ai/runtime.test.ts | 51 ++-
apps/desktop/electron/ai/runtime.ts | 23 +-
apps/desktop/electron/ipc/ai.ts | 7 +-
apps/desktop/src/llm/mainProcessClient.ts | 4 +-
docs/architecture.md | 8 +-
packages/llm/src/cloud.live.test.ts | 49 +++
packages/llm/src/cloud.test.ts | 182 ++++++++++
packages/llm/src/cloud.ts | 397 ++++++++++++++++++++++
packages/llm/src/index.ts | 1 +
9 files changed, 715 insertions(+), 7 deletions(-)
create mode 100644 packages/llm/src/cloud.live.test.ts
create mode 100644 packages/llm/src/cloud.test.ts
create mode 100644 packages/llm/src/cloud.ts
diff --git a/apps/desktop/electron/ai/runtime.test.ts b/apps/desktop/electron/ai/runtime.test.ts
index 2e9e4f7..e88e31e 100644
--- a/apps/desktop/electron/ai/runtime.test.ts
+++ b/apps/desktop/electron/ai/runtime.test.ts
@@ -62,7 +62,7 @@ describe("createRuntimeResolver", () => {
});
});
- it("does not resolve providers whose production adapter has not landed yet", async () => {
+ it("resolves cloud providers only when main can read the saved credential", async () => {
const dir = await mkdtemp(join(tmpdir(), "lucid-runtime-"));
await mkdir(join(dir, ".lucid"));
await writeFile(
@@ -84,5 +84,54 @@ describe("createRuntimeResolver", () => {
"utf8",
);
expect(await createRuntimeResolver(dir)("openai")).toBeUndefined();
+ const read = vi.fn(() => Promise.resolve("main-only-secret"));
+ const runtime = await createRuntimeResolver(
+ dir,
+ vi.fn(),
+ undefined,
+ read,
+ )("openai");
+ expect(runtime).toMatchObject({
+ endpoint: "https://api.openai.com/v1",
+ client: { provider: "openai" },
+ secrets: ["main-only-secret"],
+ });
+ expect(read).toHaveBeenCalledWith("provider.openai");
+ });
+
+ it("resolves Anthropic with only its saved main-process key", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "lucid-runtime-"));
+ await mkdir(join(dir, ".lucid"));
+ await writeFile(
+ join(dir, ".lucid", "settings.json"),
+ JSON.stringify({
+ ai: {
+ defaultProfileId: "claude",
+ profiles: [
+ {
+ id: "claude",
+ name: "Claude",
+ kind: "anthropic",
+ baseUrl: "https://api.anthropic.com/v1",
+ model: "claude-sonnet-4-5",
+ },
+ ],
+ },
+ }),
+ "utf8",
+ );
+ const read = vi.fn(() => Promise.resolve("main-only-secret"));
+ const runtime = await createRuntimeResolver(
+ dir,
+ vi.fn(),
+ undefined,
+ read,
+ )("claude");
+ expect(runtime).toMatchObject({
+ endpoint: "https://api.anthropic.com/v1",
+ client: { provider: "anthropic" },
+ secrets: ["main-only-secret"],
+ });
+ expect(read).toHaveBeenCalledWith("provider.claude");
});
});
diff --git a/apps/desktop/electron/ai/runtime.ts b/apps/desktop/electron/ai/runtime.ts
index 8ea56e2..5b51392 100644
--- a/apps/desktop/electron/ai/runtime.ts
+++ b/apps/desktop/electron/ai/runtime.ts
@@ -1,5 +1,6 @@
-import { LmStudioClient, OllamaClient } from "@lucid/llm";
+import { AnthropicClient, LmStudioClient, OllamaClient, OpenAiClient } from "@lucid/llm";
import { loadSettings } from "../settings";
+import { providerCredentialId } from "../ipc/types";
import type { AiRuntimeResolver } from "./service";
import { secureProviderFetch } from "./security";
@@ -8,21 +9,37 @@ export const createRuntimeResolver = (
libraryDir: string,
doFetch: typeof fetch = fetch,
environmentUrl: string | undefined = process.env["LUCID_LLM_URL"],
+ readCredential: (id: string) => Promise = () => Promise.resolve(undefined),
): AiRuntimeResolver => {
return async (profileId) => {
const settings = await loadSettings(libraryDir);
const profile = settings.ai.profiles.find((candidate) => candidate.id === profileId);
- if (!profile || (profile.kind !== "lmstudio" && profile.kind !== "ollama")) return undefined;
+ if (!profile || !["lmstudio", "ollama", "openai", "anthropic"].includes(profile.kind))
+ return undefined;
const endpoint = (
profile.kind === "lmstudio" ? (environmentUrl ?? profile.baseUrl) : profile.baseUrl
).replace(/\/+$/, "");
const providerFetch: typeof fetch = (input, init) => secureProviderFetch(input, init, doFetch);
+ const secret =
+ profile.kind === "openai" || profile.kind === "anthropic"
+ ? await readCredential(providerCredentialId(profile.id))
+ : undefined;
+ if ((profile.kind === "openai" || profile.kind === "anthropic") && !secret) return undefined;
return {
endpoint,
+ ...(secret ? { secrets: [secret] } : {}),
client:
profile.kind === "lmstudio"
? new LmStudioClient({ baseUrl: endpoint, fetch: providerFetch })
- : new OllamaClient({ baseUrl: endpoint, fetch: providerFetch }),
+ : profile.kind === "ollama"
+ ? new OllamaClient({ baseUrl: endpoint, fetch: providerFetch })
+ : profile.kind === "openai"
+ ? new OpenAiClient({ apiKey: secret ?? "", baseUrl: endpoint, fetch: providerFetch })
+ : new AnthropicClient({
+ apiKey: secret ?? "",
+ baseUrl: endpoint,
+ fetch: providerFetch,
+ }),
};
};
};
diff --git a/apps/desktop/electron/ipc/ai.ts b/apps/desktop/electron/ipc/ai.ts
index 45a6f67..94a5f02 100644
--- a/apps/desktop/electron/ipc/ai.ts
+++ b/apps/desktop/electron/ipc/ai.ts
@@ -27,7 +27,12 @@ export function registerSecureAiIpc(
resolve?: AiRuntimeResolver,
): CredentialVault {
const vault = new CredentialVault(join(libraryDir, ".lucid"), encryption);
- const service = new MainAiService(resolve ?? createRuntimeResolver(libraryDir));
+ const service = new MainAiService(
+ resolve ??
+ createRuntimeResolver(libraryDir, fetch, process.env["LUCID_LLM_URL"], (id) =>
+ vault.read(id),
+ ),
+ );
ipcMain.handle(IPC.aiStart, async (event, request: AiStartRequest) =>
service.start(request, (aiEvent: AiEvent) => {
if (!event.sender.isDestroyed()) event.sender.send(IPC.aiEvent, aiEvent);
diff --git a/apps/desktop/src/llm/mainProcessClient.ts b/apps/desktop/src/llm/mainProcessClient.ts
index b0d9172..9db64ea 100644
--- a/apps/desktop/src/llm/mainProcessClient.ts
+++ b/apps/desktop/src/llm/mainProcessClient.ts
@@ -70,7 +70,9 @@ export class MainProcessAiClient implements AiClient {
this.capabilities =
profile.kind === "lmstudio"
? { ...LOCAL_CAPABILITIES, reasoningControl: true }
- : LOCAL_CAPABILITIES;
+ : profile.kind === "openai" || profile.kind === "anthropic"
+ ? { ...LOCAL_CAPABILITIES, customBaseUrl: false }
+ : LOCAL_CAPABILITIES;
}
async health(signal?: AbortSignal): Promise {
diff --git a/docs/architecture.md b/docs/architecture.md
index 0acb7d3..4ef4f09 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -55,7 +55,7 @@ flowchart TD
## AI provider boundary
Input, Command, Advisor, the desktop job queue, and evals depend on Lucid's `AiClient` interface in
-`packages/llm/src/provider.ts`; Electron main-process composition constructs `LmStudioClient`. The contract covers streamed text,
+`packages/llm/src/provider.ts`; Electron main-process composition constructs the selected provider adapter. The contract covers streamed text,
`AbortSignal` cancellation, optional JSON Schema output, usage and timing results, model discovery, and optional local-model
controls. Every adapter publishes a capability record, so unsupported behavior is data rather than an assumption. LM Studio
currently advertises JSON Schema, streaming, cancellation, discovery, reasoning controls, and custom base URLs; it does not
@@ -85,6 +85,12 @@ HTTP-shaped bridge has been removed. Ollama uses its native loopback API through
discovery, NDJSON streaming, cancellation, JSON Schema output, and provider-reported token counts; it never falls back to
LM Studio or a cloud provider. Local Ollama profiles exclude and reject `:cloud` model aliases; cloud use requires its own
explicit provider profile.
+Native OpenAI and Anthropic adapters use their respective Responses and Messages streaming APIs. Their request translations
+stay inside `packages/llm/src/cloud.ts`: both map the lane's model and output-token ceiling, while controls with uncertain
+model support are omitted. Command and Advisor send provider-specific JSON Schema settings; unsupported Anthropic schema
+constraints are removed from the wire copy, and Lucid still validates the original response before applying edits. A rejected
+schema is never retried as unstructured output. Cloud credentials are read from the encrypted/session vault only in Electron
+main, are included in main-only error redaction, and are never returned to the renderer.
## PDF
diff --git a/packages/llm/src/cloud.live.test.ts b/packages/llm/src/cloud.live.test.ts
new file mode 100644
index 0000000..a478f07
--- /dev/null
+++ b/packages/llm/src/cloud.live.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+import { AnthropicClient, OpenAiClient } from "./cloud";
+import { DEFAULT_LANES } from "./lanes";
+
+/** Opt-in, billable smoke tests. Set both a key and model for only the provider under test. */
+const cases = [
+ {
+ name: "OpenAI",
+ key: process.env["LUCID_OPENAI_TEST_KEY"],
+ model: process.env["LUCID_OPENAI_TEST_MODEL"],
+ create: (key: string) => new OpenAiClient({ apiKey: key }),
+ },
+ {
+ name: "Anthropic",
+ key: process.env["LUCID_ANTHROPIC_TEST_KEY"],
+ model: process.env["LUCID_ANTHROPIC_TEST_MODEL"],
+ create: (key: string) => new AnthropicClient({ apiKey: key }),
+ },
+] as const;
+
+for (const provider of cases) {
+ describe.skipIf(!provider.key || !provider.model)(`${provider.name} live API smoke`, () => {
+ it("runs Input, Command, and Advisor response shapes", async () => {
+ const client = provider.create(provider.key ?? "");
+ const model = provider.model ?? "";
+ const input = await client.chat({
+ lane: { ...DEFAULT_LANES.input, model, maxTokens: 80 },
+ messages: [{ role: "user", content: "Reply with a short greeting." }],
+ });
+ expect(input.content.trim()).not.toBe("");
+ for (const lane of [DEFAULT_LANES.command, DEFAULT_LANES.advisor]) {
+ const result = await client.chat({
+ lane: { ...lane, model, maxTokens: 120 },
+ messages: [{ role: "user", content: 'Reply with JSON: {"answer":"yes"}.' }],
+ jsonSchema: {
+ name: "lucid_smoke",
+ schema: {
+ type: "object",
+ additionalProperties: false,
+ properties: { answer: { type: "string" } },
+ required: ["answer"],
+ },
+ },
+ });
+ expect(JSON.parse(result.content)).toMatchObject({ answer: "yes" });
+ }
+ }, 120_000);
+ });
+}
diff --git a/packages/llm/src/cloud.test.ts b/packages/llm/src/cloud.test.ts
new file mode 100644
index 0000000..e28bdce
--- /dev/null
+++ b/packages/llm/src/cloud.test.ts
@@ -0,0 +1,182 @@
+import { describe, expect, it, vi } from "vitest";
+import { commandJsonSchema } from "@lucid/core";
+import { DEFAULT_LANES } from "./lanes";
+import { AnthropicClient, OpenAiClient, anthropicSchema } from "./cloud";
+import type { ChatRequest } from "./provider";
+
+const schema = {
+ name: "lucid_command",
+ schema: {
+ type: "object",
+ additionalProperties: false,
+ properties: { kind: { type: "string", enum: ["none"], maxLength: 20 } },
+ required: ["kind"],
+ },
+};
+const request = (over: Partial = {}): ChatRequest => ({
+ lane: { ...DEFAULT_LANES.command, model: "test-model" },
+ messages: [
+ { role: "system", content: "Be precise." },
+ { role: "user", content: "Change this note." },
+ ],
+ jsonSchema: schema,
+ ...over,
+});
+const sse = (...events: object[]): Response =>
+ new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
+ headers: { "Content-Type": "text/event-stream" },
+ });
+const openaiEvents = (content = '{"kind":"none"}') =>
+ sse(
+ { type: "response.output_text.delta", delta: content },
+ {
+ type: "response.completed",
+ response: {
+ model: "test-model",
+ usage: {
+ input_tokens: 31,
+ output_tokens: 8,
+ output_tokens_details: { reasoning_tokens: 2 },
+ },
+ },
+ },
+ );
+const anthropicEvents = (content = '{"kind":"none"}') =>
+ sse(
+ { type: "message_start", message: { model: "test-model", usage: { input_tokens: 31 } } },
+ { type: "content_block_delta", delta: { type: "text_delta", text: content } },
+ { type: "message_delta", usage: { output_tokens: 8 } },
+ { type: "message_stop" },
+ );
+
+describe("native cloud provider adapters", () => {
+ it.each([
+ ["openai", OpenAiClient, openaiEvents],
+ ["anthropic", AnthropicClient, anthropicEvents],
+ ] as const)("%s streams all three Lucid lanes with exact usage", async (kind, Client, events) => {
+ for (const laneName of ["input", "command", "advisor"] as const) {
+ const fetcher = vi.fn().mockResolvedValue(events());
+ const client = new Client({ apiKey: "secret", fetch: fetcher });
+ const chunks: string[] = [];
+ const result = await client.chat(
+ request({
+ lane: { ...DEFAULT_LANES[laneName], model: "test-model" },
+ jsonSchema: laneName === "input" ? undefined : schema,
+ onDelta: (text) => chunks.push(text),
+ }),
+ );
+ expect(result.content).toBe('{"kind":"none"}');
+ expect(chunks).toEqual(['{"kind":"none"}']);
+ expect(result.usage).toMatchObject({ prompt: 31, completion: 8, source: "provider" });
+ expect(result.provider).toBe(kind);
+ const call = fetcher.mock.calls[0];
+ if (!call || typeof call[1]?.body !== "string") throw new Error("Expected request body");
+ const [url, init] = call;
+ expect(url instanceof Request ? url.url : url.toString()).toContain(
+ kind === "openai" ? "/responses" : "/messages",
+ );
+ const body = JSON.parse(call[1].body) as Record;
+ expect(body.max_output_tokens ?? body.max_tokens).toBe(DEFAULT_LANES[laneName].maxTokens);
+ expect(body).not.toHaveProperty("top_k");
+ expect(body).not.toHaveProperty("top_p");
+ expect(body).not.toHaveProperty("temperature");
+ if (laneName === "input") {
+ expect(body).not.toHaveProperty(kind === "openai" ? "text" : "output_config");
+ } else if (kind === "openai") {
+ expect(body).toMatchObject({ text: { format: { type: "json_schema", strict: true } } });
+ expect(body.store).toBe(false);
+ } else {
+ expect(body).toMatchObject({ output_config: { format: { type: "json_schema" } } });
+ }
+ expect(JSON.stringify(init.headers)).toContain("secret");
+ }
+ });
+
+ it("moves Anthropic system text out of the message list and strips unsupported constraints", async () => {
+ const fetcher = vi.fn().mockResolvedValue(anthropicEvents());
+ await new AnthropicClient({ apiKey: "secret", fetch: fetcher }).chat(request());
+ const payload = fetcher.mock.calls[0]?.[1]?.body;
+ if (typeof payload !== "string") throw new Error("Expected request body");
+ const body = JSON.parse(payload) as Record;
+ expect(body.system).toBe("Be precise.");
+ expect(body.messages).toEqual([{ role: "user", content: "Change this note." }]);
+ expect(JSON.stringify(body.output_config)).not.toContain("maxLength");
+ expect(anthropicSchema(schema.schema)).toMatchObject({
+ properties: { kind: { type: "string", enum: ["none"] } },
+ });
+ });
+
+ it.each([
+ ["openai", OpenAiClient],
+ ["anthropic", AnthropicClient],
+ ] as const)("%s fails closed when a structured stream is incomplete", async (_kind, Client) => {
+ const fetcher = vi.fn().mockResolvedValue(sse({ type: "ping" }));
+ await expect(new Client({ apiKey: "secret", fetch: fetcher }).chat(request())).rejects.toThrow(
+ /did not complete/,
+ );
+ });
+
+ it("does not echo provider error bodies that may contain credentials or note text", async () => {
+ const fetcher = vi
+ .fn()
+ .mockResolvedValue(new Response("secret Change this note.", { status: 401 }));
+ await expect(
+ new OpenAiClient({ apiKey: "secret", fetch: fetcher }).chat(request()),
+ ).rejects.toThrow(/Check the API key/);
+ await expect(
+ new OpenAiClient({ apiKey: "secret", fetch: fetcher }).chat(request()),
+ ).rejects.not.toThrow(/secret Change this note/);
+ });
+
+ it.each([
+ [OpenAiClient, "openai"],
+ [AnthropicClient, "anthropic"],
+ ] as const)(
+ "%s rejects Command schema errors without an unstructured retry",
+ async (Client, kind) => {
+ const fetcher = vi
+ .fn()
+ .mockResolvedValue(new Response("secret: Change this note.", { status: 400 }));
+ const client = new Client({ apiKey: "secret", fetch: fetcher });
+ await expect(
+ client.chat(
+ request({ jsonSchema: { name: "lucid_command", schema: commandJsonSchema() } }),
+ ),
+ ).rejects.toThrow(/structured output schema/);
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ const payload = fetcher.mock.calls[0]?.[1]?.body;
+ if (typeof payload !== "string") throw new Error("Expected request body");
+ const body = JSON.parse(payload) as Record;
+ if (kind === "openai")
+ expect(body).toMatchObject({ text: { format: { name: "lucid_command", strict: true } } });
+ else
+ expect(body).toMatchObject({ output_config: { format: { schema: { type: "object" } } } });
+ },
+ );
+
+ it("rejects malformed SSE data instead of returning a partial edit", async () => {
+ const fetcher = vi
+ .fn()
+ .mockResolvedValue(new Response("data: {not-json}\n\n", { status: 200 }));
+ await expect(
+ new OpenAiClient({ apiKey: "secret", fetch: fetcher }).chat(request()),
+ ).rejects.toThrow(/malformed stream data/);
+ });
+
+ it("cancels an in-flight provider call", async () => {
+ const controller = new AbortController();
+ const fetcher = vi.fn().mockImplementation(
+ (_input, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => {
+ reject(new Error("cancelled"));
+ });
+ }),
+ );
+ const pending = new AnthropicClient({ apiKey: "secret", fetch: fetcher }).chat(
+ request({ signal: controller.signal }),
+ );
+ controller.abort();
+ await expect(pending).rejects.toMatchObject({ code: "Aborted" });
+ });
+});
diff --git a/packages/llm/src/cloud.ts b/packages/llm/src/cloud.ts
new file mode 100644
index 0000000..7c242dd
--- /dev/null
+++ b/packages/llm/src/cloud.ts
@@ -0,0 +1,397 @@
+import { LlmError, type LlmErrorCode } from "./errors";
+import type {
+ AiClient,
+ ChatRequest,
+ ChatResult,
+ ProviderCapabilities,
+ ProviderHealth,
+} from "./provider";
+import { readSse } from "./sse";
+
+export interface CloudClientOptions {
+ apiKey: string;
+ baseUrl?: string;
+ fetch?: typeof fetch;
+ firstTokenTimeoutMs?: number;
+ requestTimeoutMs?: number;
+}
+
+const count = (value: unknown): number | undefined =>
+ typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
+const estimate = (text: string): number => Math.max(1, Math.ceil(text.length / 4));
+const object = (value: unknown): Record =>
+ value && typeof value === "object" ? (value as Record) : {};
+
+/** Anthropic accepts a smaller JSON Schema subset. Lucid validates the original schema after receipt. */
+export const anthropicSchema = (value: unknown): unknown => {
+ if (Array.isArray(value)) return value.map(anthropicSchema);
+ if (!value || typeof value !== "object") return value;
+ const result: Record = {};
+ for (const [key, child] of Object.entries(value)) {
+ if (["minLength", "maxLength", "minimum", "maximum", "multipleOf", "maxItems"].includes(key))
+ continue;
+ if (key === "minItems" && child !== 0 && child !== 1) continue;
+ result[key] = anthropicSchema(child);
+ }
+ return result;
+};
+
+abstract class CloudClient implements AiClient {
+ abstract readonly provider: string;
+ abstract readonly capabilities: Readonly;
+ readonly localModels = undefined;
+ protected readonly baseUrl: string;
+ protected readonly apiKey: string;
+ protected readonly doFetch: typeof fetch;
+ protected readonly firstTokenTimeoutMs: number;
+ protected readonly requestTimeoutMs: number;
+
+ constructor(options: CloudClientOptions, defaultBaseUrl: string) {
+ this.baseUrl = (options.baseUrl ?? defaultBaseUrl).replace(/\/+$/, "");
+ this.apiKey = options.apiKey;
+ this.doFetch = options.fetch ?? ((input, init) => fetch(input, init));
+ this.firstTokenTimeoutMs = options.firstTokenTimeoutMs ?? 60_000;
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 180_000;
+ }
+
+ abstract health(signal?: AbortSignal): Promise;
+ abstract chat(request: ChatRequest): Promise;
+
+ protected async stream(
+ path: string,
+ headers: Record,
+ payload: object,
+ request: ChatRequest,
+ onEvent: (event: Record) => boolean,
+ ): Promise<{
+ sentAt: number;
+ firstTokenAt: number | null;
+ doneAt: number;
+ requestChars: number;
+ }> {
+ if (request.signal?.aborted) throw new LlmError("Aborted", "Cancelled.");
+ const controller = new AbortController();
+ let abortReason: LlmErrorCode | undefined;
+ const abort = (reason: LlmErrorCode) => {
+ abortReason ??= reason;
+ controller.abort();
+ };
+ const onAbort = () => {
+ abort("Aborted");
+ };
+ request.signal?.addEventListener("abort", onAbort, { once: true });
+ const requestTimer = setTimeout(() => {
+ abort("Timeout");
+ }, this.requestTimeoutMs);
+ let firstTokenAt: number | null = null;
+ const firstTokenTimer = setTimeout(() => {
+ abort("FirstTokenTimeout");
+ }, this.firstTokenTimeoutMs);
+ const body = JSON.stringify(payload);
+ const sentAt = Date.now();
+ try {
+ let response: Response;
+ try {
+ response = await this.doFetch(`${this.baseUrl}${path}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...headers },
+ body,
+ signal: controller.signal,
+ });
+ } catch {
+ throw this.abortError(abortReason, "Could not connect to the AI provider.");
+ }
+ if (!response.ok) {
+ const status = response.status;
+ // Provider bodies can echo prompts and credentials. Only expose status and useful generic guidance.
+ const hint =
+ status === 401 || status === 403
+ ? "Check the API key and account access."
+ : status === 404
+ ? "Check the selected model and provider address."
+ : status === 429
+ ? "The provider is rate limiting requests or the account is out of quota."
+ : status === 400 && request.jsonSchema
+ ? "The selected model rejected Lucid's structured output schema; choose a compatible model. No edit was applied."
+ : "Check the provider status and model configuration.";
+ throw new LlmError("BadResponse", `${this.provider} returned HTTP ${status}. ${hint}`);
+ }
+ if (!response.body)
+ throw new LlmError("BadResponse", `${this.provider} sent an empty response.`);
+ try {
+ for await (const data of readSse(response.body)) {
+ if (data === "[DONE]") continue;
+ let event: Record;
+ try {
+ event = object(JSON.parse(data) as unknown);
+ } catch {
+ throw new LlmError("BadResponse", `${this.provider} sent malformed stream data.`);
+ }
+ const textDelta = onEvent(event);
+ if (firstTokenAt === null && textDelta) {
+ firstTokenAt = Date.now();
+ clearTimeout(firstTokenTimer);
+ }
+ }
+ } catch (error) {
+ if (abortReason) throw this.abortError(abortReason, "The provider connection ended.");
+ if (error instanceof LlmError) throw error;
+ throw new LlmError(
+ "ConnectionLost",
+ `The connection to ${this.provider} dropped mid-answer.`,
+ );
+ }
+ if (abortReason) throw this.abortError(abortReason, "The provider connection ended.");
+ return { sentAt, firstTokenAt, doneAt: Date.now(), requestChars: body.length };
+ } finally {
+ clearTimeout(requestTimer);
+ clearTimeout(firstTokenTimer);
+ request.signal?.removeEventListener("abort", onAbort);
+ controller.abort();
+ }
+ }
+
+ private abortError(reason: LlmErrorCode | undefined, fallback: string): LlmError {
+ if (reason === "Aborted") return new LlmError("Aborted", "Cancelled.");
+ if (reason === "FirstTokenTimeout")
+ return new LlmError("FirstTokenTimeout", `${this.provider} did not start answering in time.`);
+ if (reason === "Timeout") return new LlmError("Timeout", `${this.provider} took too long.`);
+ return new LlmError("LmUnreachable", fallback);
+ }
+
+ protected result(
+ request: ChatRequest,
+ content: string,
+ model: string,
+ usage: { prompt?: number; completion?: number; reasoning?: number },
+ timing: { sentAt: number; firstTokenAt: number | null; doneAt: number; requestChars: number },
+ ): ChatResult {
+ if (!content.trim())
+ throw new LlmError("BadResponse", `${this.provider} returned no text answer.`);
+ const { prompt, completion } = usage;
+ const providerUsage = prompt !== undefined && completion !== undefined;
+ return {
+ content,
+ model,
+ provider: this.provider,
+ usage: providerUsage
+ ? {
+ prompt,
+ completion,
+ reasoning: usage.reasoning ?? 0,
+ source: "provider",
+ }
+ : {
+ prompt: estimate(JSON.stringify(request.messages)),
+ completion: estimate(content),
+ reasoning: 0,
+ source: "estimated",
+ },
+ characters: { request: timing.requestChars, response: content.length },
+ timings: {
+ sentAt: timing.sentAt,
+ firstTokenAt: timing.firstTokenAt,
+ doneAt: timing.doneAt,
+ },
+ repairAttempts: 0,
+ };
+ }
+}
+
+export class OpenAiClient extends CloudClient {
+ readonly provider = "openai";
+ readonly capabilities: Readonly = Object.freeze({
+ streaming: true,
+ cancellation: true,
+ structuredOutput: "json-schema",
+ modelDiscovery: true,
+ usage: "provider-or-estimated",
+ reasoningControl: false,
+ localModelManagement: false,
+ customBaseUrl: false,
+ });
+
+ constructor(options: CloudClientOptions) {
+ super(options, "https://api.openai.com/v1");
+ }
+
+ async health(signal?: AbortSignal): Promise {
+ try {
+ const response = await this.doFetch(`${this.baseUrl}/models`, {
+ headers: { Authorization: `Bearer ${this.apiKey}` },
+ ...(signal ? { signal } : {}),
+ });
+ if (!response.ok) return { reachable: false, models: [] };
+ const data = object(await response.json()).data;
+ return {
+ reachable: true,
+ models: Array.isArray(data)
+ ? data.map((item) => object(item).id).filter((id): id is string => typeof id === "string")
+ : [],
+ };
+ } catch {
+ return { reachable: false, models: [] };
+ }
+ }
+
+ async chat(request: ChatRequest): Promise {
+ let content = "";
+ let model = request.lane.model;
+ const state = { complete: false, failure: false };
+ const usage: { prompt?: number; completion?: number; reasoning?: number } = {};
+ const timing = await this.stream(
+ "/responses",
+ { Authorization: `Bearer ${this.apiKey}` },
+ {
+ model,
+ input: request.messages,
+ stream: true,
+ store: false,
+ max_output_tokens: request.lane.maxTokens,
+ ...(request.jsonSchema
+ ? {
+ text: {
+ format: {
+ type: "json_schema",
+ name: request.jsonSchema.name,
+ schema: request.jsonSchema.schema,
+ strict: true,
+ },
+ },
+ }
+ : {}),
+ },
+ request,
+ (event) => {
+ if (event.type === "response.output_text.delta" && typeof event.delta === "string") {
+ content += event.delta;
+ request.onDelta?.(event.delta);
+ return true;
+ } else if (event.type === "response.completed") {
+ state.complete = true;
+ const response = object(event.response);
+ if (typeof response.model === "string") model = response.model;
+ const counts = object(response.usage);
+ const prompt = count(counts.input_tokens);
+ const completion = count(counts.output_tokens);
+ const reasoning = count(object(counts.output_tokens_details).reasoning_tokens);
+ if (prompt !== undefined) usage.prompt = prompt;
+ if (completion !== undefined) usage.completion = completion;
+ if (reasoning !== undefined) usage.reasoning = reasoning;
+ } else if (event.type === "response.failed" || event.type === "error") {
+ state.failure = true;
+ }
+ return false;
+ },
+ );
+ if (state.failure || !state.complete)
+ throw new LlmError(
+ "BadResponse",
+ "OpenAI did not complete the response. No edit was applied.",
+ );
+ return this.result(request, content, model, usage, timing);
+ }
+}
+
+export class AnthropicClient extends CloudClient {
+ readonly provider = "anthropic";
+ readonly capabilities: Readonly = Object.freeze({
+ streaming: true,
+ cancellation: true,
+ structuredOutput: "json-schema",
+ modelDiscovery: true,
+ usage: "provider-or-estimated",
+ reasoningControl: false,
+ localModelManagement: false,
+ customBaseUrl: false,
+ });
+
+ constructor(options: CloudClientOptions) {
+ super(options, "https://api.anthropic.com/v1");
+ }
+
+ private headers(): Record {
+ return { "x-api-key": this.apiKey, "anthropic-version": "2023-06-01" };
+ }
+
+ async health(signal?: AbortSignal): Promise {
+ try {
+ const response = await this.doFetch(`${this.baseUrl}/models`, {
+ headers: this.headers(),
+ ...(signal ? { signal } : {}),
+ });
+ if (!response.ok) return { reachable: false, models: [] };
+ const data = object(await response.json()).data;
+ return {
+ reachable: true,
+ models: Array.isArray(data)
+ ? data.map((item) => object(item).id).filter((id): id is string => typeof id === "string")
+ : [],
+ };
+ } catch {
+ return { reachable: false, models: [] };
+ }
+ }
+
+ async chat(request: ChatRequest): Promise {
+ const system = request.messages
+ .filter((message) => message.role === "system")
+ .map((message) => message.content)
+ .join("\n\n");
+ const messages = request.messages.filter((message) => message.role !== "system");
+ if (!messages.length)
+ throw new LlmError("BadResponse", "Anthropic needs at least one user message.");
+ let content = "";
+ let model = request.lane.model;
+ const state = { complete: false, failure: false };
+ const usage: { prompt?: number; completion?: number } = {};
+ const timing = await this.stream(
+ "/messages",
+ this.headers(),
+ {
+ model,
+ messages,
+ ...(system ? { system } : {}),
+ stream: true,
+ max_tokens: request.lane.maxTokens,
+ ...(request.jsonSchema
+ ? {
+ output_config: {
+ format: { type: "json_schema", schema: anthropicSchema(request.jsonSchema.schema) },
+ },
+ }
+ : {}),
+ },
+ request,
+ (event) => {
+ if (event.type === "message_start") {
+ const message = object(event.message);
+ if (typeof message.model === "string") model = message.model;
+ const prompt = count(object(message.usage).input_tokens);
+ if (prompt !== undefined) usage.prompt = prompt;
+ } else if (event.type === "content_block_delta") {
+ const delta = object(event.delta);
+ if (delta.type === "text_delta" && typeof delta.text === "string") {
+ content += delta.text;
+ request.onDelta?.(delta.text);
+ return true;
+ }
+ } else if (event.type === "message_delta") {
+ const completion = count(object(event.usage).output_tokens);
+ if (completion !== undefined) usage.completion = completion;
+ } else if (event.type === "message_stop") {
+ state.complete = true;
+ } else if (event.type === "error") {
+ state.failure = true;
+ }
+ return false;
+ },
+ );
+ if (state.failure || !state.complete)
+ throw new LlmError(
+ "BadResponse",
+ "Anthropic did not complete the response. No edit was applied.",
+ );
+ return this.result(request, content, model, usage, timing);
+ }
+}
diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts
index 9afdd8d..5cbe66d 100644
--- a/packages/llm/src/index.ts
+++ b/packages/llm/src/index.ts
@@ -1,4 +1,5 @@
export * from "./client";
+export * from "./cloud";
export * from "./ollama";
export * from "./provider";
export * from "./errors";
From 11cdaa2d3f11d9a344d8d2467f35cf92e35b8907 Mon Sep 17 00:00:00 2001
From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com>
Date: Mon, 19 Oct 2026 13:53:54 +0530
Subject: [PATCH 08/13] feat(llm): add compatible hosted providers (#41)
---
apps/desktop/e2e/settings.spec.ts | 22 +++
apps/desktop/electron/ai/providers.test.ts | 50 +++++++
apps/desktop/electron/ai/providers.ts | 6 +-
apps/desktop/electron/ai/runtime.test.ts | 45 ++++++
apps/desktop/electron/ai/runtime.ts | 38 +++--
apps/desktop/src/llm/mainProcessClient.ts | 10 +-
.../desktop/src/settings/ProviderSettings.tsx | 16 +-
docs/architecture.md | 6 +
packages/llm/src/cloud.ts | 129 ++++++++++++++++
packages/llm/src/compatible.test.ts | 138 ++++++++++++++++++
10 files changed, 445 insertions(+), 15 deletions(-)
create mode 100644 packages/llm/src/compatible.test.ts
diff --git a/apps/desktop/e2e/settings.spec.ts b/apps/desktop/e2e/settings.spec.ts
index 5cfe67b..7bef98d 100644
--- a/apps/desktop/e2e/settings.spec.ts
+++ b/apps/desktop/e2e/settings.spec.ts
@@ -159,6 +159,28 @@ test("selecting Ollama shows structured-output guidance and hides LM Studio stat
}
});
+test("hosted compatibility presets explain capability limits and custom URLs stay editable", async () => {
+ test.setTimeout(60_000);
+ const { app, page } = await launch();
+ try {
+ await page.getByRole("button", { name: /Settings/ }).click();
+ const box = dialog(page);
+ await box.getByLabel("Provider to add").selectOption("ollama-cloud");
+ await box.getByRole("button", { name: "Add", exact: true }).click();
+ await expect(box).toContainText("Ollama Cloud currently lacks JSON Schema output");
+ await expect(field(page, ADDRESS)).toBeDisabled();
+
+ await box.getByLabel("Provider to add").selectOption("custom");
+ await box.getByRole("button", { name: "Add", exact: true }).click();
+ await expect(field(page, ADDRESS)).toBeEnabled();
+ await field(page, ADDRESS).fill("https://llm.example.test/v1");
+ await expect(box).toContainText("you can enter a model ID manually");
+ await field(page, MODEL).fill("my-model");
+ } finally {
+ await app.close();
+ }
+});
+
test("saved settings apply at once: the model asked, the PDF footer, the words; and they are in the settings file", async () => {
test.setTimeout(120_000);
const notes = "## Caching\n- Redis keeps hot keys in memory.";
diff --git a/apps/desktop/electron/ai/providers.test.ts b/apps/desktop/electron/ai/providers.test.ts
index fc2086c..b48256f 100644
--- a/apps/desktop/electron/ai/providers.test.ts
+++ b/apps/desktop/electron/ai/providers.test.ts
@@ -117,4 +117,54 @@ describe("inspectProvider", () => {
expect(result.message).toContain("Could not connect");
expect(result.message).not.toContain("top-secret");
});
+
+ it("uses Ollama Cloud's native model list with explicit bearer authentication", async () => {
+ const request = vi
+ .fn()
+ .mockResolvedValue(Response.json({ models: [{ model: "gemma4:31b" }] }));
+ const result = await inspectProvider(
+ profile({ kind: "ollama-cloud", baseUrl: "https://ollama.com/v1" }),
+ "cloud-secret",
+ request,
+ );
+ expect(result.models).toEqual(["gemma4:31b"]);
+ expect(request.mock.calls[0]?.[0]).toEqual(new URL("https://ollama.com/api/tags"));
+ expect(request.mock.calls[0]?.[1]?.headers).toMatchObject({
+ Authorization: "Bearer cloud-secret",
+ });
+ });
+
+ it("explains that custom model listing is optional", async () => {
+ const result = await inspectProvider(
+ profile({ kind: "custom", baseUrl: "https://llm.example.test/v1" }),
+ undefined,
+ vi.fn().mockResolvedValue(new Response(null, { status: 404 })),
+ );
+ expect(result).toMatchObject({ ok: false });
+ expect(result.message).toContain("Enter its model ID manually");
+ });
+
+ it("rejects unsafe custom addresses and cross-origin redirects before sending a key", async () => {
+ const request = vi.fn();
+ const unsafe = await inspectProvider(
+ profile({ kind: "custom", baseUrl: "http://private.example.test/v1" }),
+ "secret",
+ request,
+ );
+ expect(unsafe.ok).toBe(false);
+ expect(request).not.toHaveBeenCalled();
+
+ const redirect = await inspectProvider(
+ profile({ kind: "custom", baseUrl: "https://llm.example.test/v1" }),
+ "secret",
+ vi.fn().mockResolvedValue(
+ new Response(null, {
+ status: 302,
+ headers: { Location: "https://other.example.test/models" },
+ }),
+ ),
+ );
+ expect(redirect.ok).toBe(false);
+ expect(redirect.message).not.toContain("secret");
+ });
});
diff --git a/apps/desktop/electron/ai/providers.ts b/apps/desktop/electron/ai/providers.ts
index 4b31568..91db6c3 100644
--- a/apps/desktop/electron/ai/providers.ts
+++ b/apps/desktop/electron/ai/providers.ts
@@ -12,6 +12,7 @@ const modelsUrl = (profile: ProviderProfile): string => {
const base = profile.baseUrl.replace(/\/+$/, "");
if (profile.kind === "lmstudio") return `${base}/v1/models`;
if (profile.kind === "ollama") return `${base}/api/tags`;
+ if (profile.kind === "ollama-cloud") return `${new URL(base).origin}/api/tags`;
return `${base}/models`;
};
@@ -71,7 +72,10 @@ export async function inspectProvider(
if (!response.ok)
return {
ok: false,
- message: `${provider.label} returned HTTP ${String(response.status)}. Check the address, API key, and account access.`,
+ message:
+ profile.kind === "custom" && response.status === 404
+ ? "This endpoint has no model-list API. Enter its model ID manually; Test connection cannot verify it until a request runs."
+ : `${provider.label} returned HTTP ${String(response.status)}. Check the address, API key, and account access.`,
};
const models = modelIds(await response.json(), profile.kind === "ollama");
return {
diff --git a/apps/desktop/electron/ai/runtime.test.ts b/apps/desktop/electron/ai/runtime.test.ts
index e88e31e..ab31c3b 100644
--- a/apps/desktop/electron/ai/runtime.test.ts
+++ b/apps/desktop/electron/ai/runtime.test.ts
@@ -134,4 +134,49 @@ describe("createRuntimeResolver", () => {
});
expect(read).toHaveBeenCalledWith("provider.claude");
});
+
+ it("resolves hosted compatibility presets but never falls back to another provider", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "lucid-runtime-"));
+ await mkdir(join(dir, ".lucid"));
+ await writeFile(
+ join(dir, ".lucid", "settings.json"),
+ JSON.stringify({
+ ai: {
+ defaultProfileId: "router",
+ profiles: [
+ {
+ id: "router",
+ name: "Router",
+ kind: "openrouter",
+ baseUrl: "https://openrouter.ai/api/v1",
+ model: "openai/gpt-4.1-mini",
+ },
+ {
+ id: "cloud",
+ name: "Cloud",
+ kind: "ollama-cloud",
+ baseUrl: "https://ollama.com/v1",
+ model: "gemma4:31b",
+ },
+ {
+ id: "proxy",
+ name: "Proxy",
+ kind: "custom",
+ baseUrl: "https://llm.example.test/v1",
+ model: "custom/model",
+ },
+ ],
+ },
+ }),
+ "utf8",
+ );
+ const read = vi.fn((id: string) =>
+ Promise.resolve(id === "provider.router" ? "router-key" : undefined),
+ );
+ const resolve = createRuntimeResolver(dir, vi.fn(), undefined, read);
+ expect(await resolve("router")).toMatchObject({ client: { provider: "openrouter" } });
+ expect(await resolve("cloud")).toBeUndefined();
+ expect(await resolve("proxy")).toMatchObject({ client: { provider: "custom" } });
+ expect(await resolve("unknown")).toBeUndefined();
+ });
});
diff --git a/apps/desktop/electron/ai/runtime.ts b/apps/desktop/electron/ai/runtime.ts
index 5b51392..79dbf1d 100644
--- a/apps/desktop/electron/ai/runtime.ts
+++ b/apps/desktop/electron/ai/runtime.ts
@@ -1,4 +1,10 @@
-import { AnthropicClient, LmStudioClient, OllamaClient, OpenAiClient } from "@lucid/llm";
+import {
+ AnthropicClient,
+ LmStudioClient,
+ OllamaClient,
+ OpenAiClient,
+ OpenAiCompatibleClient,
+} from "@lucid/llm";
import { loadSettings } from "../settings";
import { providerCredentialId } from "../ipc/types";
import type { AiRuntimeResolver } from "./service";
@@ -14,17 +20,22 @@ export const createRuntimeResolver = (
return async (profileId) => {
const settings = await loadSettings(libraryDir);
const profile = settings.ai.profiles.find((candidate) => candidate.id === profileId);
- if (!profile || !["lmstudio", "ollama", "openai", "anthropic"].includes(profile.kind))
- return undefined;
+ if (!profile) return undefined;
const endpoint = (
profile.kind === "lmstudio" ? (environmentUrl ?? profile.baseUrl) : profile.baseUrl
).replace(/\/+$/, "");
const providerFetch: typeof fetch = (input, init) => secureProviderFetch(input, init, doFetch);
const secret =
- profile.kind === "openai" || profile.kind === "anthropic"
+ profile.kind !== "lmstudio" && profile.kind !== "ollama"
? await readCredential(providerCredentialId(profile.id))
: undefined;
- if ((profile.kind === "openai" || profile.kind === "anthropic") && !secret) return undefined;
+ if (
+ profile.kind !== "lmstudio" &&
+ profile.kind !== "ollama" &&
+ profile.kind !== "custom" &&
+ !secret
+ )
+ return undefined;
return {
endpoint,
...(secret ? { secrets: [secret] } : {}),
@@ -35,11 +46,18 @@ export const createRuntimeResolver = (
? new OllamaClient({ baseUrl: endpoint, fetch: providerFetch })
: profile.kind === "openai"
? new OpenAiClient({ apiKey: secret ?? "", baseUrl: endpoint, fetch: providerFetch })
- : new AnthropicClient({
- apiKey: secret ?? "",
- baseUrl: endpoint,
- fetch: providerFetch,
- }),
+ : profile.kind === "anthropic"
+ ? new AnthropicClient({
+ apiKey: secret ?? "",
+ baseUrl: endpoint,
+ fetch: providerFetch,
+ })
+ : new OpenAiCompatibleClient({
+ kind: profile.kind,
+ ...(secret ? { apiKey: secret } : {}),
+ baseUrl: endpoint,
+ fetch: providerFetch,
+ }),
};
};
};
diff --git a/apps/desktop/src/llm/mainProcessClient.ts b/apps/desktop/src/llm/mainProcessClient.ts
index 9db64ea..d650df9 100644
--- a/apps/desktop/src/llm/mainProcessClient.ts
+++ b/apps/desktop/src/llm/mainProcessClient.ts
@@ -70,9 +70,13 @@ export class MainProcessAiClient implements AiClient {
this.capabilities =
profile.kind === "lmstudio"
? { ...LOCAL_CAPABILITIES, reasoningControl: true }
- : profile.kind === "openai" || profile.kind === "anthropic"
- ? { ...LOCAL_CAPABILITIES, customBaseUrl: false }
- : LOCAL_CAPABILITIES;
+ : profile.kind === "ollama-cloud"
+ ? { ...LOCAL_CAPABILITIES, structuredOutput: "none", customBaseUrl: false }
+ : profile.kind === "openai" ||
+ profile.kind === "anthropic" ||
+ profile.kind === "openrouter"
+ ? { ...LOCAL_CAPABILITIES, customBaseUrl: false }
+ : LOCAL_CAPABILITIES;
}
async health(signal?: AbortSignal): Promise {
diff --git a/apps/desktop/src/settings/ProviderSettings.tsx b/apps/desktop/src/settings/ProviderSettings.tsx
index 5cbac6a..7c47c48 100644
--- a/apps/desktop/src/settings/ProviderSettings.tsx
+++ b/apps/desktop/src/settings/ProviderSettings.tsx
@@ -11,7 +11,8 @@ import {
const modelHint = (kind: AiProviderKind): string => {
if (kind === "lmstudio") return "qwen/qwen3.8-27b";
- if (kind === "ollama" || kind === "ollama-cloud") return "llama3.2";
+ if (kind === "ollama") return "llama3.2";
+ if (kind === "ollama-cloud") return "gemma4:31b";
if (kind === "anthropic") return "claude-sonnet-4-5";
if (kind === "openrouter") return "openai/gpt-4.1-mini";
if (kind === "openai") return "gpt-4.1-mini";
@@ -304,6 +305,19 @@ export function ProviderSettings({ value, lmStudioUrlLocked, onChange, onPersist
If Ollama rejects structured output, Lucid stops without applying the command.
)}
+ {active.kind === "ollama-cloud" && (
+
+ Ollama Cloud currently lacks JSON Schema output. Input can run here; Command and
+ Advisor stop safely without sending a schema request.
+
+ )}
+ {(active.kind === "openrouter" || active.kind === "custom") && (
+
+ Structured output depends on the selected model and endpoint. If it is not supported,
+ Lucid stops Command or Advisor without applying an edit. Model listing may also be
+ unavailable; you can enter a model ID manually.
+
+ )}
{
+ kind: CompatibleKind;
+ baseUrl: string;
+ apiKey?: string;
+}
+
+/** Chat Completions compatibility is narrower than native OpenAI Responses. Never infer full parity. */
+export class OpenAiCompatibleClient extends CloudClient {
+ readonly provider: CompatibleKind;
+ readonly capabilities: Readonly;
+
+ constructor(options: CompatibleClientOptions) {
+ super({ ...options, apiKey: options.apiKey ?? "" }, options.baseUrl);
+ this.provider = options.kind;
+ this.capabilities = Object.freeze({
+ streaming: true,
+ cancellation: true,
+ structuredOutput: options.kind === "ollama-cloud" ? "none" : "json-schema",
+ modelDiscovery: true,
+ usage: "provider-or-estimated",
+ reasoningControl: false,
+ localModelManagement: false,
+ customBaseUrl: options.kind === "custom",
+ });
+ }
+
+ private headers(): Record {
+ return this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {};
+ }
+
+ async health(signal?: AbortSignal): Promise {
+ try {
+ const url =
+ this.provider === "ollama-cloud"
+ ? `${new URL(this.baseUrl).origin}/api/tags`
+ : `${this.baseUrl}/models`;
+ const response = await this.doFetch(url, {
+ headers: this.headers(),
+ ...(signal ? { signal } : {}),
+ });
+ if (!response.ok) return { reachable: false, models: [] };
+ const body = object(await response.json());
+ const data = Array.isArray(body.data)
+ ? body.data
+ : Array.isArray(body.models)
+ ? body.models
+ : [];
+ return {
+ reachable: true,
+ models: data
+ .map((item) => {
+ const entry = object(item);
+ const id = entry.id ?? entry.model ?? entry.name;
+ return typeof id === "string" ? id : undefined;
+ })
+ .filter((id): id is string => id !== undefined),
+ };
+ } catch {
+ return { reachable: false, models: [] };
+ }
+ }
+
+ async chat(request: ChatRequest): Promise {
+ if (request.jsonSchema && this.provider === "ollama-cloud")
+ throw new LlmError(
+ "BadResponse",
+ "Ollama Cloud does not support JSON Schema output. Command and Advisor cannot run with this provider; choose a structured-output-capable provider. No edit was applied.",
+ );
+ let content = "";
+ let model = request.lane.model;
+ const state = { complete: false, failure: false };
+ const usage: { prompt?: number; completion?: number; reasoning?: number } = {};
+ const timing = await this.stream(
+ "/chat/completions",
+ this.headers(),
+ {
+ model,
+ messages: request.messages,
+ stream: true,
+ max_tokens: request.lane.maxTokens,
+ ...(this.provider === "openrouter" ? { stream_options: { include_usage: true } } : {}),
+ ...(request.jsonSchema
+ ? {
+ response_format: {
+ type: "json_schema",
+ json_schema: {
+ name: request.jsonSchema.name,
+ schema: request.jsonSchema.schema,
+ strict: true,
+ },
+ },
+ ...(this.provider === "openrouter" ? { provider: { require_parameters: true } } : {}),
+ }
+ : {}),
+ },
+ request,
+ (event) => {
+ if (event.error !== undefined) state.failure = true;
+ if (typeof event.model === "string") model = event.model;
+ const counts = object(event.usage);
+ const prompt = count(counts.prompt_tokens);
+ const completion = count(counts.completion_tokens);
+ const reasoning = count(object(counts.completion_tokens_details).reasoning_tokens);
+ if (prompt !== undefined) usage.prompt = prompt;
+ if (completion !== undefined) usage.completion = completion;
+ if (reasoning !== undefined) usage.reasoning = reasoning;
+ const choices = Array.isArray(event.choices) ? event.choices : [];
+ const choice = object(choices[0]);
+ if (choice.finish_reason === "stop") state.complete = true;
+ else if (typeof choice.finish_reason === "string") state.failure = true;
+ const delta = object(choice.delta).content;
+ if (typeof delta === "string" && delta) {
+ content += delta;
+ request.onDelta?.(delta);
+ return true;
+ }
+ return false;
+ },
+ );
+ if (state.failure || !state.complete)
+ throw new LlmError(
+ "BadResponse",
+ `${this.provider} did not complete a supported response. Check model capabilities; no edit was applied.`,
+ );
+ return this.result(request, content, model, usage, timing);
+ }
+}
diff --git a/packages/llm/src/compatible.test.ts b/packages/llm/src/compatible.test.ts
new file mode 100644
index 0000000..ec14a92
--- /dev/null
+++ b/packages/llm/src/compatible.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, it, vi } from "vitest";
+import { OpenAiCompatibleClient } from "./cloud";
+import { DEFAULT_LANES } from "./lanes";
+import type { ChatRequest } from "./provider";
+
+const request = (schema = false): ChatRequest => ({
+ lane: { ...DEFAULT_LANES.command, model: "example/model" },
+ messages: [{ role: "user", content: "Reply briefly." }],
+ ...(schema
+ ? {
+ jsonSchema: {
+ name: "lucid_answer",
+ schema: {
+ type: "object",
+ properties: { answer: { type: "string" } },
+ required: ["answer"],
+ additionalProperties: false,
+ },
+ },
+ }
+ : {}),
+});
+
+const stream = (answer = '{"answer":"yes"}'): Response =>
+ new Response(
+ [
+ `data: ${JSON.stringify({ choices: [{ delta: { content: answer } }] })}\n\n`,
+ `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] })}\n\n`,
+ `data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 12, completion_tokens: 7 } })}\n\n`,
+ "data: [DONE]\n\n",
+ ].join(""),
+ { headers: { "Content-Type": "text/event-stream" } },
+ );
+
+describe("OpenAI-compatible hosted endpoints", () => {
+ it("requires OpenRouter endpoints to honor the JSON Schema parameter", async () => {
+ const fetcher = vi.fn().mockResolvedValue(stream());
+ const client = new OpenAiCompatibleClient({
+ kind: "openrouter",
+ baseUrl: "https://openrouter.ai/api/v1",
+ apiKey: "secret",
+ fetch: fetcher,
+ });
+ const deltas: string[] = [];
+ const result = await client.chat({ ...request(true), onDelta: (piece) => deltas.push(piece) });
+ expect(result.content).toBe('{"answer":"yes"}');
+ expect(deltas).toEqual(['{"answer":"yes"}']);
+ expect(result.usage).toMatchObject({ prompt: 12, completion: 7, source: "provider" });
+ const call = fetcher.mock.calls[0];
+ if (!call || typeof call[1]?.body !== "string") throw new Error("Expected request body");
+ expect(call[0]).toBe("https://openrouter.ai/api/v1/chat/completions");
+ const body = JSON.parse(call[1].body) as Record;
+ expect(body).toMatchObject({
+ response_format: { type: "json_schema", json_schema: { name: "lucid_answer", strict: true } },
+ provider: { require_parameters: true },
+ stream_options: { include_usage: true },
+ });
+ expect(body).not.toHaveProperty("models");
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ });
+
+ it("lets custom endpoints use optional bearer authentication and manual models", async () => {
+ const fetcher = vi.fn().mockResolvedValue(stream("hello"));
+ const client = new OpenAiCompatibleClient({
+ kind: "custom",
+ baseUrl: "https://llm.example.test/v1",
+ fetch: fetcher,
+ });
+ expect((await client.chat(request())).content).toBe("hello");
+ expect(JSON.stringify(fetcher.mock.calls[0]?.[1]?.headers)).not.toContain("Authorization");
+ const payload = fetcher.mock.calls[0]?.[1]?.body;
+ if (typeof payload !== "string") throw new Error("Expected request body");
+ expect(JSON.parse(payload)).not.toHaveProperty("stream_options");
+ expect(client.capabilities.customBaseUrl).toBe(true);
+ });
+
+ it("allows Ollama Cloud Input but blocks Command and Advisor before a network request", async () => {
+ const fetcher = vi.fn().mockResolvedValue(stream("hello"));
+ const client = new OpenAiCompatibleClient({
+ kind: "ollama-cloud",
+ baseUrl: "https://ollama.com/v1",
+ apiKey: "secret",
+ fetch: fetcher,
+ });
+ expect(client.capabilities.structuredOutput).toBe("none");
+ await expect(client.chat(request(true))).rejects.toThrow(/does not support JSON Schema/);
+ expect(fetcher).not.toHaveBeenCalled();
+ expect((await client.chat(request())).content).toBe("hello");
+ });
+
+ it("discovers Ollama Cloud models through its native tags endpoint", async () => {
+ const fetcher = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(JSON.stringify({ models: [{ model: "gemma4:31b" }] }), { status: 200 }),
+ );
+ const client = new OpenAiCompatibleClient({
+ kind: "ollama-cloud",
+ baseUrl: "https://ollama.com/v1",
+ apiKey: "secret",
+ fetch: fetcher,
+ });
+ expect(await client.health()).toEqual({ reachable: true, models: ["gemma4:31b"] });
+ expect(fetcher.mock.calls[0]?.[0]).toBe("https://ollama.com/api/tags");
+ });
+
+ it("does not accept a truncated or failed stream as a completed edit", async () => {
+ const fetcher = vi
+ .fn()
+ .mockResolvedValue(new Response('data: {"choices":[{"delta":{"content":"partial"}}]}\n\n'));
+ const client = new OpenAiCompatibleClient({
+ kind: "custom",
+ baseUrl: "https://llm.example.test/v1",
+ fetch: fetcher,
+ });
+ await expect(client.chat(request(true))).rejects.toThrow(/did not complete/);
+ });
+
+ it("times out before the first token with no alternate-provider request", async () => {
+ const fetcher = vi.fn().mockImplementation(
+ (_input, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => {
+ reject(new Error("aborted"));
+ });
+ }),
+ );
+ const client = new OpenAiCompatibleClient({
+ kind: "custom",
+ baseUrl: "https://llm.example.test/v1",
+ fetch: fetcher,
+ firstTokenTimeoutMs: 10,
+ requestTimeoutMs: 100,
+ });
+ await expect(client.chat(request())).rejects.toMatchObject({ code: "FirstTokenTimeout" });
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ });
+});
From 87bdcded6273f40a74120e8bad1b58db3334baff Mon Sep 17 00:00:00 2001
From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com>
Date: Mon, 19 Oct 2026 19:27:06 +0530
Subject: [PATCH 09/13] feat(context): add rebuildable document index (#42)
---
apps/desktop/src/editor/PageEditor.tsx | 17 +-
docs/architecture.md | 9 +
.../core/src/context/documentIndex.test.ts | 257 ++++++++++++
packages/core/src/context/documentIndex.ts | 393 ++++++++++++++++++
packages/core/src/index.ts | 1 +
5 files changed, 676 insertions(+), 1 deletion(-)
create mode 100644 packages/core/src/context/documentIndex.test.ts
create mode 100644 packages/core/src/context/documentIndex.ts
diff --git a/apps/desktop/src/editor/PageEditor.tsx b/apps/desktop/src/editor/PageEditor.tsx
index 063fa68..c7566f4 100644
--- a/apps/desktop/src/editor/PageEditor.tsx
+++ b/apps/desktop/src/editor/PageEditor.tsx
@@ -21,7 +21,16 @@ import {
useBlockNoteEditor,
useCreateBlockNote,
} from "@blocknote/react";
-import { applyPatch, makeRevert, type BlockPatch, type LucidDoc, type Revert } from "@lucid/core";
+import {
+ applyPatch,
+ DocumentContextIndexStore,
+ makeRevert,
+ patchedIds,
+ type BlockPatch,
+ type DocumentContextIndex,
+ type LucidDoc,
+ type Revert,
+} from "@lucid/core";
import { useEffect, useMemo, useRef, useState } from "react";
import { BlockNumbers } from "./BlockNumbers";
import { fromBn, toBn } from "./adapter";
@@ -82,6 +91,8 @@ function Toolbar() {
export interface EditorApi {
/** The document as it is right now. */
getDoc(): LucidDoc;
+ /** Disposable structure used to select compact AI context for the current document. */
+ getContextIndex(): DocumentContextIndex;
/** The block the cursor is in (it may not be in the saved document yet, e.g. an empty paragraph). */
cursorBlockId(): string | undefined;
/** Applies an AI change as ONE undo step. Throws PatchError if a block it needs has gone. Returns the new document. */
@@ -104,6 +115,7 @@ interface Props {
/** One page's editor. Mounted once per page (keyed by page id); reports the whole document on every edit. */
export function PageEditor({ initial, onChange, onReady, editable = true }: Props) {
const current = useRef(initial);
+ const contextIndex = useRef(new DocumentContextIndexStore(initial));
// The page as it is now, for the block numbers beside it.
const [live, setLive] = useState(initial);
const rootRef = useRef(null);
@@ -126,6 +138,7 @@ export function PageEditor({ initial, onChange, onReady, editable = true }: Prop
useEffect(() => {
onReadyRef.current?.({
getDoc: () => current.current,
+ getContextIndex: () => contextIndex.current.current,
cursorBlockId: () => {
try {
return editor.getTextCursorPosition().block.id;
@@ -149,6 +162,7 @@ export function PageEditor({ initial, onChange, onReady, editable = true }: Prop
// status on a code block, say) would not make it announce anything by itself.
const actual = fromBn(editor.document, expected);
current.current = actual;
+ contextIndex.current.update(actual, patchedIds(patch));
setLive(actual);
lastRevert.current = makeRevert(previous, patch, actual);
onChangeRef.current(actual);
@@ -213,6 +227,7 @@ export function PageEditor({ initial, onChange, onReady, editable = true }: Prop
onChange={() => {
const next = fromBn(editor.document, current.current);
current.current = next;
+ contextIndex.current.update(next);
setLive(next);
onChange(next);
}}
diff --git a/docs/architecture.md b/docs/architecture.md
index 172d791..5f1dd00 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -41,6 +41,15 @@ flowchart TD
- A page is saved as one JSON file (validated with Zod, versioned and migrated). The `.lucid` export is the same file; its
importer checks every block and never overwrites.
+### Rebuildable document context
+
+`packages/core/src/context/documentIndex.ts` derives a compact index from the current `LucidDoc`: heading ranges, block
+positions and short summaries, table/chart/diagram component references, diagram relationships, and a bounded list of recent
+blocks. The document remains the only source of truth. The index has no database or independent persistence; loading a page
+builds it fresh, and every complete editor update rebuilds it in memory. That same update path covers typing, patch insert/update/remove/move,
+undo, redo, and Revert, so deleted IDs cannot survive. This metadata is the deterministic input to targeted prompt selection,
+not a second document model.
+
## From words to blocks
- **Input mode:** the model is given the page outline, the note format's section hints and your words, and writes Lucid
diff --git a/packages/core/src/context/documentIndex.test.ts b/packages/core/src/context/documentIndex.test.ts
new file mode 100644
index 0000000..bf43ae0
--- /dev/null
+++ b/packages/core/src/context/documentIndex.test.ts
@@ -0,0 +1,257 @@
+import { describe, expect, it } from "vitest";
+import { applyPatch, type BlockPatch } from "../model/patch";
+import type { Block, BlockType, LucidDoc } from "../model/doc";
+import {
+ buildDocumentContextIndex,
+ changedBlockIds,
+ DocumentContextIndexStore,
+} from "./documentIndex";
+
+const text = (value: string) => [{ type: "text" as const, text: value, styles: {} }];
+const block = (
+ id: string,
+ type: BlockType,
+ value = "",
+ props: Record = {},
+ children?: Block[],
+): Block => ({ id, type, props, content: text(value), ...(children ? { children } : {}) });
+const doc = (...blocks: Block[]): LucidDoc => ({ blocks });
+
+describe("document context index", () => {
+ it("indexes sections, stable component parts, and graph relationships", () => {
+ const source = doc(
+ block("intro", "paragraph", "Preamble"),
+ block("h1", "heading", "Architecture", { level: 1 }),
+ block("p1", "paragraph", "The request enters the gateway."),
+ block("h2", "heading", "Data flow", { level: 2 }),
+ block("t1", "table", "", {
+ caption: "Latency",
+ table: {
+ columns: [
+ { id: "c1", name: "Model", status: "normal" },
+ { id: "c2", name: "Time", status: "normal" },
+ ],
+ rows: [{ id: "r1", cells: ["Small", "10"], status: "normal" }],
+ headerColumn: false,
+ },
+ }),
+ block("d1", "diagram", "", {
+ graph: {
+ type: "flowchart",
+ direction: "LR",
+ title: "Request path",
+ nodes: [
+ { id: "client", label: "Client", kind: "client", status: "normal" },
+ { id: "api", label: "API", kind: "service", status: "normal" },
+ ],
+ edges: [
+ {
+ id: "e1",
+ from: "client",
+ to: "api",
+ label: "calls",
+ arrow: "directed",
+ style: "solid",
+ status: "normal",
+ },
+ ],
+ groups: [{ id: "system", label: "System", members: ["api"], status: "normal" }],
+ fragments: [],
+ },
+ }),
+ block("c1", "chart", "", {
+ chart: {
+ kind: "bar",
+ title: "Throughput",
+ xLabel: "Model",
+ yLabel: "Tokens",
+ categories: ["Small"],
+ series: [{ name: "Decode", values: [20] }],
+ },
+ }),
+ );
+
+ const index = buildDocumentContextIndex(source, ["d1", "missing", "d1", "t1"]);
+
+ expect(index.blocks.map(({ id, sectionId }) => [id, sectionId])).toEqual([
+ ["intro", null],
+ ["h1", "h1"],
+ ["p1", "h1"],
+ ["h2", "h2"],
+ ["t1", "h2"],
+ ["d1", "h2"],
+ ["c1", "h2"],
+ ]);
+ expect(index.sections).toMatchObject([
+ {
+ id: "h1",
+ parentSectionId: null,
+ title: "Architecture",
+ start: 1,
+ endExclusive: 7,
+ blockIds: ["h1", "p1", "h2", "t1", "d1", "c1"],
+ },
+ {
+ id: "h2",
+ parentSectionId: "h1",
+ title: "Data flow",
+ start: 3,
+ endExclusive: 7,
+ blockIds: ["h2", "t1", "d1", "c1"],
+ },
+ ]);
+ expect(index.components).toEqual([
+ expect.objectContaining({
+ id: "t1",
+ kind: "table",
+ partIds: ["t1:column:c1", "t1:column:c2", "t1:row:r1"],
+ }),
+ expect.objectContaining({
+ id: "d1",
+ kind: "diagram",
+ partIds: ["d1:node:client", "d1:node:api", "d1:edge:e1", "d1:group:system"],
+ }),
+ expect.objectContaining({
+ id: "c1",
+ kind: "chart",
+ partIds: ["c1:category:1", "c1:series:1"],
+ }),
+ ]);
+ expect(index.relationships).toEqual([
+ {
+ id: "d1:edge:e1",
+ diagramId: "d1",
+ kind: "edge",
+ fromId: "d1:node:client",
+ toId: "d1:node:api",
+ label: "calls",
+ },
+ {
+ id: "d1:group:system:member:api",
+ diagramId: "d1",
+ kind: "group-member",
+ fromId: "d1:group:system",
+ toId: "d1:node:api",
+ label: "System",
+ },
+ ]);
+ expect(index.recentTouched).toEqual(["d1", "t1"]);
+ });
+
+ it("rebuilds without stale block, component, relationship, or touch ids", () => {
+ const diagram = block("d1", "diagram", "", {
+ graph: {
+ type: "flowchart",
+ direction: "LR",
+ nodes: [{ id: "a", label: "A", kind: "process", status: "normal" }],
+ edges: [],
+ groups: [],
+ fragments: [],
+ },
+ });
+ const store = new DocumentContextIndexStore(doc(diagram, block("p1", "paragraph", "Keep")));
+ store.update(doc(block("p1", "paragraph", "Keep")), ["d1"]);
+
+ expect(store.current.blocks.map((entry) => entry.id)).toEqual(["p1"]);
+ expect(store.current.components).toEqual([]);
+ expect(store.current.relationships).toEqual([]);
+ expect(store.current.recentTouched).toEqual([]);
+ });
+
+ it("tracks insert, update, move, remove, undo, revert, and load rebuilds", () => {
+ const initial = doc(
+ block("h", "heading", "Title", { level: 1 }),
+ block("a", "paragraph", "A"),
+ block("b", "paragraph", "B"),
+ );
+ const store = new DocumentContextIndexStore(initial);
+ const insert: BlockPatch = {
+ steps: [
+ {
+ kind: "insert",
+ parentId: null,
+ afterId: "a",
+ blocks: [block("c", "paragraph", "C")],
+ },
+ ],
+ };
+ const inserted = applyPatch(initial, insert);
+ store.update(inserted);
+ expect(store.current.blocks.map((entry) => entry.id)).toEqual(["h", "a", "c", "b"]);
+ expect(store.current.recentTouched).toEqual(["c"]);
+
+ const updated = applyPatch(inserted, {
+ steps: [{ kind: "update", id: "a", block: block("a", "paragraph", "A updated") }],
+ });
+ store.update(updated);
+ expect(store.current.recentTouched.slice(0, 2)).toEqual(["a", "c"]);
+
+ const moved = applyPatch(updated, {
+ steps: [{ kind: "move", id: "b", parentId: null, afterId: null }],
+ });
+ store.update(moved);
+ expect(store.current.blocks.map((entry) => entry.id)).toEqual(["b", "h", "a", "c"]);
+ expect(store.current.recentTouched[0]).toBe("b");
+
+ const removed = applyPatch(moved, { steps: [{ kind: "remove", id: "c" }] });
+ store.update(removed, ["c"]);
+ expect(store.current.blocks.some((entry) => entry.id === "c")).toBe(false);
+ expect(store.current.recentTouched).not.toContain("c");
+
+ // Undo of the removal reports the complete restored document and rebuilds the index.
+ store.update(moved);
+ expect(store.current.blocks.some((entry) => entry.id === "c")).toBe(true);
+ expect(store.current.recentTouched[0]).toBe("c");
+
+ // A revert is an ordinary inverse patch and follows the same update path.
+ const reverted = applyPatch(moved, {
+ steps: [{ kind: "update", id: "a", block: block("a", "paragraph", "A") }],
+ });
+ store.update(reverted, ["a"]);
+ expect(store.current.blocks.find((entry) => entry.id === "a")?.summary).toBe("A");
+
+ store.load(doc(block("new", "paragraph", "Another page")));
+ expect(store.current.blocks.map((entry) => entry.id)).toEqual(["new"]);
+ expect(store.current.recentTouched).toEqual([]);
+ });
+
+ it("detects only the inserted or minimally moved blocks", () => {
+ const before = doc(
+ block("a", "paragraph", "A"),
+ block("b", "paragraph", "B"),
+ block("c", "paragraph", "C"),
+ );
+ const inserted = doc(
+ block("a", "paragraph", "A"),
+ block("x", "paragraph", "X"),
+ block("b", "paragraph", "B"),
+ block("c", "paragraph", "C"),
+ );
+ const moved = doc(
+ block("c", "paragraph", "C"),
+ block("a", "paragraph", "A"),
+ block("b", "paragraph", "B"),
+ );
+ expect(changedBlockIds(before, inserted)).toEqual(["x"]);
+ expect(changedBlockIds(before, moved)).toEqual(["c"]);
+ });
+
+ it("is deterministic for large documents", () => {
+ const blocks = Array.from({ length: 5_000 }, (_, index) =>
+ block(
+ `b${String(index)}`,
+ index % 100 === 0 ? "heading" : "paragraph",
+ `Block ${String(index)}`,
+ {
+ ...(index % 100 === 0 ? { level: 1 } : {}),
+ },
+ ),
+ );
+ const source = doc(...blocks);
+ const first = buildDocumentContextIndex(source, ["b4999", "b1"]);
+ const second = buildDocumentContextIndex(source, ["b4999", "b1"]);
+ expect(first.blocks).toHaveLength(5_000);
+ expect(first.sections).toHaveLength(50);
+ expect(second).toEqual(first);
+ });
+});
diff --git a/packages/core/src/context/documentIndex.ts b/packages/core/src/context/documentIndex.ts
new file mode 100644
index 0000000..c9f213c
--- /dev/null
+++ b/packages/core/src/context/documentIndex.ts
@@ -0,0 +1,393 @@
+import { ChartSchema, type Chart } from "../model/chart";
+import { type Block, type BlockType, type Inline, type LucidDoc } from "../model/doc";
+import { GraphSchema, type Graph } from "../model/graph";
+import { TableSchema, type Table } from "../model/table";
+
+export interface IndexedBlock {
+ id: string;
+ type: BlockType;
+ parentId: string | null;
+ sectionId: string | null;
+ depth: number;
+ order: number;
+ summary: string;
+}
+
+export interface IndexedSection {
+ /** The heading block id. */
+ id: string;
+ headingId: string;
+ parentSectionId: string | null;
+ level: number;
+ title: string;
+ start: number;
+ endExclusive: number;
+ blockIds: string[];
+}
+
+export type IndexedComponentKind = "table" | "chart" | "diagram";
+
+export interface IndexedComponent {
+ /** Stable while the source block exists: component blocks use their document block id. */
+ id: string;
+ blockId: string;
+ kind: IndexedComponentKind;
+ summary: string;
+ /** Deterministic, namespaced references for addressable parts of the component. */
+ partIds: string[];
+}
+
+export interface IndexedRelationship {
+ id: string;
+ diagramId: string;
+ kind: "edge" | "group-member";
+ fromId: string;
+ toId: string;
+ label: string;
+}
+
+export interface DocumentContextIndex {
+ blocks: IndexedBlock[];
+ sections: IndexedSection[];
+ components: IndexedComponent[];
+ relationships: IndexedRelationship[];
+ /** Existing block ids only, newest first. */
+ recentTouched: string[];
+}
+
+interface FlatBlock {
+ block: Block;
+ parentId: string | null;
+ depth: number;
+ order: number;
+}
+
+const MAX_SUMMARY = 160;
+const MAX_RECENT = 20;
+
+const clean = (value: string): string => value.replace(/\s+/g, " ").trim();
+const clipped = (value: string): string => {
+ const text = clean(value);
+ return text.length <= MAX_SUMMARY ? text : `${text.slice(0, MAX_SUMMARY - 1).trimEnd()}…`;
+};
+
+const inlineText = (content: readonly Inline[] | undefined): string =>
+ (content ?? [])
+ .map((inline) =>
+ inline.type === "text"
+ ? inline.text
+ : inline.type === "link"
+ ? inline.content.map((part) => part.text).join("")
+ : inline.latex,
+ )
+ .join("");
+
+function flatten(doc: LucidDoc): FlatBlock[] {
+ const out: FlatBlock[] = [];
+ const visit = (blocks: readonly Block[], parentId: string | null, depth: number) => {
+ blocks.forEach((block) => {
+ out.push({ block, parentId, depth, order: out.length });
+ if (block.children) visit(block.children, block.id, depth + 1);
+ });
+ };
+ visit(doc.blocks, null, 0);
+ return out;
+}
+
+function parsedTable(block: Block): Table | undefined {
+ const parsed = TableSchema.safeParse(block.props["table"]);
+ return parsed.success ? parsed.data : undefined;
+}
+
+function parsedChart(block: Block): Chart | undefined {
+ const parsed = ChartSchema.safeParse(block.props["chart"]);
+ return parsed.success ? parsed.data : undefined;
+}
+
+function parsedGraph(block: Block): Graph | undefined {
+ const parsed = GraphSchema.safeParse(block.props["graph"]);
+ return parsed.success ? parsed.data : undefined;
+}
+
+function blockSummary(block: Block): string {
+ const text = inlineText(block.content);
+ const title = typeof block.props["title"] === "string" ? block.props["title"] : "";
+ const caption = typeof block.props["caption"] === "string" ? block.props["caption"] : "";
+ const code = typeof block.props["code"] === "string" ? block.props["code"] : "";
+ const latex = typeof block.props["latex"] === "string" ? block.props["latex"] : "";
+ const table = parsedTable(block);
+ const chart = parsedChart(block);
+ const graph = parsedGraph(block);
+ const structural = table
+ ? `${String(table.rows.length)} rows × ${String(table.columns.length)} columns`
+ : chart
+ ? [chart.title, ...chart.series.map((series) => series.name)].filter(Boolean).join("; ")
+ : graph
+ ? [graph.title ?? "", ...graph.nodes.slice(0, 4).map((node) => node.label)]
+ .filter(Boolean)
+ .join("; ")
+ : "";
+ return clipped([text, title, caption, code, latex, structural].filter(Boolean).join(" — "));
+}
+
+function componentOf(block: Block): IndexedComponent | undefined {
+ if (block.type === "table") {
+ const table = parsedTable(block);
+ if (!table) return undefined;
+ return {
+ id: block.id,
+ blockId: block.id,
+ kind: "table",
+ summary: blockSummary(block),
+ partIds: [
+ ...table.columns.map((column) => `${block.id}:column:${column.id}`),
+ ...table.rows.map((row) => `${block.id}:row:${row.id}`),
+ ],
+ };
+ }
+ if (block.type === "chart") {
+ const chart = parsedChart(block);
+ if (!chart) return undefined;
+ return {
+ id: block.id,
+ blockId: block.id,
+ kind: "chart",
+ summary: blockSummary(block),
+ partIds: [
+ ...chart.categories.map((_, index) => `${block.id}:category:${String(index + 1)}`),
+ ...chart.series.map((_, index) => `${block.id}:series:${String(index + 1)}`),
+ ],
+ };
+ }
+ if (block.type === "diagram") {
+ const graph = parsedGraph(block);
+ if (!graph) return undefined;
+ return {
+ id: block.id,
+ blockId: block.id,
+ kind: "diagram",
+ summary: blockSummary(block),
+ partIds: [
+ ...graph.nodes.map((node) => `${block.id}:node:${node.id}`),
+ ...graph.edges.map((edge) => `${block.id}:edge:${edge.id}`),
+ ...graph.groups.map((group) => `${block.id}:group:${group.id}`),
+ ],
+ };
+ }
+ return undefined;
+}
+
+function relationshipsOf(block: Block): IndexedRelationship[] {
+ const graph = block.type === "diagram" ? parsedGraph(block) : undefined;
+ if (!graph) return [];
+ const relationships: IndexedRelationship[] = graph.edges.map((edge) => ({
+ id: `${block.id}:edge:${edge.id}`,
+ diagramId: block.id,
+ kind: "edge",
+ fromId: `${block.id}:node:${edge.from}`,
+ toId: `${block.id}:node:${edge.to}`,
+ label: edge.label ?? "",
+ }));
+ for (const group of graph.groups) {
+ for (const member of group.members) {
+ relationships.push({
+ id: `${block.id}:group:${group.id}:member:${member}`,
+ diagramId: block.id,
+ kind: "group-member",
+ fromId: `${block.id}:group:${group.id}`,
+ toId: `${block.id}:node:${member}`,
+ label: group.label,
+ });
+ }
+ }
+ return relationships;
+}
+
+/** Build disposable context metadata directly from the canonical document. */
+export function buildDocumentContextIndex(
+ doc: LucidDoc,
+ recentTouched: readonly string[] = [],
+): DocumentContextIndex {
+ const flat = flatten(doc);
+ const existing = new Set(flat.map(({ block }) => block.id));
+ const sections: IndexedSection[] = [];
+ const active: IndexedSection[] = [];
+ const blocks: IndexedBlock[] = [];
+ const components: IndexedComponent[] = [];
+ const relationships: IndexedRelationship[] = [];
+
+ for (const entry of flat) {
+ const { block } = entry;
+ if (block.type === "heading") {
+ const rawLevel = block.props["level"];
+ const level = rawLevel === 2 || rawLevel === 3 ? rawLevel : 1;
+ while ((active.at(-1)?.level ?? 0) >= level) active.pop();
+ const section: IndexedSection = {
+ id: block.id,
+ headingId: block.id,
+ parentSectionId: active.at(-1)?.id ?? null,
+ level,
+ title: clipped(inlineText(block.content)),
+ start: entry.order,
+ endExclusive: entry.order + 1,
+ blockIds: [],
+ };
+ sections.push(section);
+ active.push(section);
+ }
+ for (const section of active) {
+ section.blockIds.push(block.id);
+ section.endExclusive = entry.order + 1;
+ }
+ blocks.push({
+ id: block.id,
+ type: block.type,
+ parentId: entry.parentId,
+ sectionId: active.at(-1)?.id ?? null,
+ depth: entry.depth,
+ order: entry.order,
+ summary: blockSummary(block),
+ });
+ const component = componentOf(block);
+ if (component) components.push(component);
+ relationships.push(...relationshipsOf(block));
+ }
+
+ const touched: string[] = [];
+ for (const id of recentTouched) {
+ if (existing.has(id) && !touched.includes(id)) touched.push(id);
+ if (touched.length === MAX_RECENT) break;
+ }
+ return { blocks, sections, components, relationships, recentTouched: touched };
+}
+
+const stableValue = (value: unknown): string => {
+ if (value === undefined) return "undefined";
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
+ if (Array.isArray(value)) return `[${value.map(stableValue).join(",")}]`;
+ const object = value as Record;
+ return `{${Object.keys(object)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${stableValue(object[key])}`)
+ .join(",")}}`;
+};
+
+interface ComparableBlock {
+ parentId: string | null;
+ value: string;
+}
+
+const comparableBlocks = (doc: LucidDoc): Map =>
+ new Map(
+ flatten(doc).map(({ block, parentId }) => [
+ block.id,
+ {
+ parentId,
+ value: stableValue({ ...block, children: undefined }),
+ },
+ ]),
+ );
+
+function childIdsByParent(doc: LucidDoc): Map {
+ const result = new Map();
+ const visit = (blocks: readonly Block[], parentId: string | null) => {
+ result.set(
+ parentId,
+ blocks.map((block) => block.id),
+ );
+ for (const block of blocks) if (block.children) visit(block.children, block.id);
+ };
+ visit(doc.blocks, null);
+ return result;
+}
+
+/** Ids in one sibling list that form a longest increasing subsequence of their old positions. */
+function unchangedOrder(oldIds: readonly string[], newIds: readonly string[]): Set {
+ const oldPosition = new Map(oldIds.map((id, index) => [id, index]));
+ const ids = newIds.filter((id) => oldPosition.has(id));
+ const tails: number[] = [];
+ const tailAt: number[] = [];
+ const previous = ids.map(() => -1);
+ ids.forEach((id, index) => {
+ const position = oldPosition.get(id) as number;
+ let low = 0;
+ let high = tails.length;
+ while (low < high) {
+ const middle = (low + high) >> 1;
+ if ((tails[middle] as number) < position) low = middle + 1;
+ else high = middle;
+ }
+ if (low > 0) previous[index] = tailAt[low - 1] as number;
+ tails[low] = position;
+ tailAt[low] = index;
+ });
+ const result = new Set();
+ let cursor = tailAt.at(-1) ?? -1;
+ while (cursor >= 0) {
+ result.add(ids[cursor] as string);
+ cursor = previous[cursor] as number;
+ }
+ return result;
+}
+
+/** Existing ids whose content or position changed, plus inserted ids, in current document order. */
+export function changedBlockIds(previous: LucidDoc, next: LucidDoc): string[] {
+ const before = comparableBlocks(previous);
+ const after = comparableBlocks(next);
+ const changed = new Set();
+ for (const { block } of flatten(next)) {
+ const old = before.get(block.id);
+ const current = after.get(block.id);
+ if (!old || !current || old.parentId !== current.parentId || old.value !== current.value)
+ changed.add(block.id);
+ }
+ const oldChildren = childIdsByParent(previous);
+ const newChildren = childIdsByParent(next);
+ for (const [parentId, newIds] of newChildren) {
+ const commonOld = (oldChildren.get(parentId) ?? []).filter(
+ (id) => after.get(id)?.parentId === parentId,
+ );
+ const commonNew = newIds.filter((id) => before.get(id)?.parentId === parentId);
+ const stable = unchangedOrder(commonOld, commonNew);
+ for (const id of commonNew) if (!stable.has(id)) changed.add(id);
+ }
+ return flatten(next)
+ .map(({ block }) => block.id)
+ .filter((id) => changed.has(id));
+}
+
+/**
+ * In-memory lifecycle wrapper. It never persists document data: every update rebuilds from LucidDoc,
+ * which also guarantees that removed or otherwise stale ids disappear.
+ */
+export class DocumentContextIndexStore {
+ private document: LucidDoc;
+ private value: DocumentContextIndex;
+
+ constructor(doc: LucidDoc) {
+ this.document = doc;
+ this.value = buildDocumentContextIndex(doc);
+ }
+
+ get current(): DocumentContextIndex {
+ return this.value;
+ }
+
+ /** A newly loaded document starts with no touch history from the previous lifecycle. */
+ load(doc: LucidDoc): DocumentContextIndex {
+ this.document = doc;
+ this.value = buildDocumentContextIndex(doc);
+ return this.value;
+ }
+
+ update(doc: LucidDoc, touched: readonly string[] = []): DocumentContextIndex {
+ const inferred = changedBlockIds(this.document, doc);
+ this.value = buildDocumentContextIndex(doc, [
+ ...touched,
+ ...inferred,
+ ...this.value.recentTouched,
+ ]);
+ this.document = doc;
+ return this.value;
+ }
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 648b231..7c66ab2 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -23,3 +23,4 @@ export { titleCase, parseNumber } from "./guided/words";
export { inferKind, refineKind, refineNewNodeKinds, specificKind } from "./model/nodeKinds";
export * from "./guided/table";
export type { Step } from "./guided/step";
+export * from "./context/documentIndex";
From d7e4d7f2c97686d72feb30bfb902fa7dd5121cbd Mon Sep 17 00:00:00 2001
From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com>
Date: Mon, 19 Oct 2026 19:44:48 +0530
Subject: [PATCH 10/13] feat(context): target AI document context (#43)
---
apps/desktop/src/jobs/queue.ts | 4 +
docs/architecture.md | 7 +
docs/evals.md | 7 +
packages/core/src/context/documentIndex.ts | 12 +-
packages/kit/src/outline/context.test.ts | 139 ++++++++++
packages/kit/src/outline/context.ts | 293 +++++++++++++++++++++
packages/kit/src/outline/index.ts | 7 +
packages/kit/src/outline/outline.test.ts | 8 +
packages/kit/src/outline/outline.ts | 28 +-
packages/llm/src/prompts/command.ts | 59 +++--
packages/llm/src/prompts/context.test.ts | 75 ++++++
packages/llm/src/prompts/input.ts | 35 ++-
12 files changed, 627 insertions(+), 47 deletions(-)
create mode 100644 packages/kit/src/outline/context.test.ts
create mode 100644 packages/kit/src/outline/context.ts
create mode 100644 packages/llm/src/prompts/context.test.ts
diff --git a/apps/desktop/src/jobs/queue.ts b/apps/desktop/src/jobs/queue.ts
index 76f4155..ede8ad6 100644
--- a/apps/desktop/src/jobs/queue.ts
+++ b/apps/desktop/src/jobs/queue.ts
@@ -4,6 +4,7 @@ import {
type Block,
type BlockPatch,
type ChangeEntry,
+ type DocumentContextIndex,
type IdGenerator,
type LucidDoc,
type TranscriptEntry,
@@ -47,6 +48,7 @@ export type JobStatus =
/** The open page's editor, as the queue needs it. */
export interface EditorPort {
getDoc(): LucidDoc;
+ getContextIndex?(): DocumentContextIndex;
cursorBlockId(): string | undefined;
apply(patch: BlockPatch): LucidDoc;
}
@@ -318,6 +320,7 @@ export class JobQueue {
runInput(
{
doc,
+ contextIndex: editor.getContextIndex?.(),
utterance: text,
cursorBlockId: cursor,
title: this.options.title(),
@@ -377,6 +380,7 @@ export class JobQueue {
runCommand(
{
doc,
+ contextIndex: editor.getContextIndex?.(),
utterance: text,
cursorBlockId: editor.cursorBlockId(),
title: this.options.title(),
diff --git a/docs/architecture.md b/docs/architecture.md
index 5f1dd00..93cf1c0 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -50,6 +50,13 @@ builds it fresh, and every complete editor update rebuilds it in memory. That sa
undo, redo, and Revert, so deleted IDs cannot survive. This metadata is the deterministic input to targeted prompt selection,
not a second document model.
+Live Input and Command requests use that index to send a compact section/component overview plus the focused section, nearby
+blocks, explicitly named references, recent touches, and the full details of only the structured components they require.
+Whole-page language expands the bundle deterministically. Missing block references and unfocused references to one of several
+tables, charts, or diagrams are marked as unresolved with all candidates kept in summary form, so Command asks instead of
+guessing. The system prompt and measured few-shot examples stay byte-for-byte stable for provider prompt caching. Selection is
+lexical and structural only; it makes no router-model call and stores no vectors.
+
## From words to blocks
- **Input mode:** the model is given the page outline, the note format's section hints and your words, and writes Lucid
diff --git a/docs/evals.md b/docs/evals.md
index 6abaa99..cda6904 100644
--- a/docs/evals.md
+++ b/docs/evals.md
@@ -20,6 +20,13 @@ character-based estimates and marks them `estimated` rather than presenting them
counts and identifiers only—never API keys, full prompts, or document text. Failure examples shown by the eval reporter come
only from this repository's synthetic fixtures and scripted utterances.
+Targeted-context changes use those same request-character/token and edit-isolation fields for before/after runs. The offline
+regression in `packages/kit/src/outline/context.test.ts` also compares a narrow request against the legacy broad outline on a
+large synthetic page, requires a substantial prompt-size reduction, and verifies that unrelated body text is absent. Whole-page,
+ambiguous-target, missing-target, stable-prefix, and Input/Command prompt cases run in normal CI. A real-model comparison is
+still required before release whenever the configured model can run; mocked or structural tests are not reported as live-model
+quality evidence.
+
## The bars
Overall 90%. Each category 80%, and 95% for `non_destructive` and `mode_separation` (a mistake there loses the person's
diff --git a/packages/core/src/context/documentIndex.ts b/packages/core/src/context/documentIndex.ts
index c9f213c..38bf865 100644
--- a/packages/core/src/context/documentIndex.ts
+++ b/packages/core/src/context/documentIndex.ts
@@ -119,13 +119,17 @@ function blockSummary(block: Block): string {
const chart = parsedChart(block);
const graph = parsedGraph(block);
const structural = table
- ? `${String(table.rows.length)} rows × ${String(table.columns.length)} columns`
+ ? [
+ `${String(table.rows.length)} rows × ${String(table.columns.length)} columns`,
+ ...table.columns.map((column) => column.name),
+ ...table.rows.map((row) => row.cells[0] ?? ""),
+ ]
+ .filter(Boolean)
+ .join("; ")
: chart
? [chart.title, ...chart.series.map((series) => series.name)].filter(Boolean).join("; ")
: graph
- ? [graph.title ?? "", ...graph.nodes.slice(0, 4).map((node) => node.label)]
- .filter(Boolean)
- .join("; ")
+ ? [graph.title ?? "", ...graph.nodes.map((node) => node.label)].filter(Boolean).join("; ")
: "";
return clipped([text, title, caption, code, latex, structural].filter(Boolean).join(" — "));
}
diff --git a/packages/kit/src/outline/context.test.ts b/packages/kit/src/outline/context.test.ts
new file mode 100644
index 0000000..2421f0e
--- /dev/null
+++ b/packages/kit/src/outline/context.test.ts
@@ -0,0 +1,139 @@
+import type { Block, LucidDoc } from "@lucid/core";
+import { doc, h1, h2, p } from "@lucid/core/testing";
+import { describe, expect, it } from "vitest";
+import { registry } from "../defaultRegistry";
+import { buildDocumentContext } from "./context";
+import { buildOutline } from "./outline";
+
+const fixture = (type: Block["type"], fixtureId: string, id = fixtureId): Block => {
+ const found = registry.get(type).fixtures.find((candidate) => candidate.id === fixtureId);
+ if (!found) throw new Error(`no fixture ${fixtureId}`);
+ return { ...found, id };
+};
+
+const sectioned = (): LucidDoc =>
+ doc(
+ h1("System design", { id: "title" }),
+ h2("Requirements", { id: "requirements" }),
+ p("Handle one hundred million new URLs per day.", { id: "r1" }),
+ p("Reads far outnumber writes.", { id: "r2" }),
+ h2("Database options", { id: "database" }),
+ p("PostgreSQL supports relational transactions.", { id: "postgres" }),
+ p("MongoDB offers a flexible document model.", { id: "mongo" }),
+ h2("Caching", { id: "caching" }),
+ p("Redis stores hot keys.", { id: "redis" }),
+ );
+
+describe("targeted document context", () => {
+ it("keeps a compact overview but expands only the cursor's relevant section", () => {
+ const blocks = [h1("Large design", { id: "title" })];
+ for (let section = 0; section < 8; section++) {
+ blocks.push(h2(`Section ${String(section)}`, { id: `h${String(section)}` }));
+ for (let item = 0; item < 10; item++)
+ blocks.push(
+ p(`Section ${String(section)} paragraph ${String(item)} ${"detail ".repeat(30)}`, {
+ id: `p${String(section)}-${String(item)}`,
+ }),
+ );
+ }
+ const source = doc(...blocks);
+ const bundle = buildDocumentContext({
+ doc: source,
+ utterance: "Make this paragraph more concise",
+ cursorBlockId: "p4-5",
+ title: "Large",
+ });
+ const broad = buildOutline(source, { title: "Large" }).text;
+
+ expect(bundle.selection.scope).toBe("targeted");
+ expect(bundle.selection.selectedBlockIds).toContain("p4-5");
+ expect(bundle.selection.selectedBlockIds).toContain("p4-0");
+ expect(bundle.selection.selectedBlockIds).not.toContain("p2-5");
+ expect(bundle.text).toContain("Section 2"); // compact overview
+ expect(bundle.text).not.toContain("Section 2 paragraph 5"); // unrelated body omitted
+ expect(bundle.text.length).toBeLessThan(broad.length * 0.6);
+ });
+
+ it("uses explicit refs, named headings, lexical block names, and recent touches", () => {
+ const source = sectioned();
+ const named = buildDocumentContext({
+ doc: source,
+ utterance: "In Database options, cross out MongoDB",
+ recentTouched: ["redis"],
+ });
+ expect(named.selection.selectedBlockIds).toEqual(
+ expect.arrayContaining(["database", "postgres", "mongo", "redis"]),
+ );
+ expect(named.selection.fullBlockIds).toContain("mongo");
+ expect(named.text).toContain("RECENT_TOUCHED: [b9]");
+
+ const explicit = buildDocumentContext({ doc: source, utterance: "Replace b3" });
+ expect(explicit.selection.fullBlockIds).toContain("r1");
+ const spoken = buildDocumentContext({ doc: source, utterance: "Replace block 3" });
+ expect(spoken.selection.fullBlockIds).toContain("r1");
+ });
+
+ it("includes referenced component details and required diagram relationships", () => {
+ const source = doc(
+ h1("Architecture", { id: "heading" }),
+ fixture("diagram", "fx-diagram-1", "diagram"),
+ fixture("table", "fx-table-1", "table"),
+ );
+ const bundle = buildDocumentContext({
+ doc: source,
+ utterance: "Add a cache to the diagram",
+ cursorBlockId: "diagram",
+ });
+
+ expect(bundle.selection.fullBlockIds).toContain("diagram");
+ expect(bundle.text).toContain("CONTEXT: targeted");
+ expect(bundle.text).toMatch(/nodes:/);
+ expect(bundle.text).toMatch(/edges:/);
+ expect(bundle.text).not.toContain("Fixed, predefined");
+
+ const edge = buildDocumentContext({ doc: source, utterance: "Rename edge e5" });
+ expect(edge.selection.fullBlockIds).toContain("diagram");
+ });
+
+ it("marks multiple unfocused components and missing refs for clarification", () => {
+ const source = doc(
+ h1("Comparisons", { id: "heading" }),
+ fixture("table", "fx-table-1", "table-a"),
+ fixture("table", "fx-table-1", "table-b"),
+ );
+ const ambiguous = buildDocumentContext({
+ doc: source,
+ utterance: "Add a row to the table",
+ });
+ expect(ambiguous.selection.ambiguities).toEqual([
+ { kind: "table", blockIds: ["table-a", "table-b"] },
+ ]);
+ expect(ambiguous.selection.fullBlockIds).toEqual([]);
+ expect(ambiguous.text).toMatch(/TARGET_STATUS: ambiguous table: \[b2\] or \[b3\]/);
+
+ const missing = buildDocumentContext({ doc: source, utterance: "Delete b99" });
+ expect(missing.selection.missingRefs).toEqual(["b99"]);
+ expect(missing.selection.fullBlockIds).toEqual([]);
+ expect(missing.text).toContain("TARGET_STATUS: missing b99");
+ });
+
+ it("expands document-wide requests and remains deterministic", () => {
+ const source = sectioned();
+ const request = { doc: source, utterance: "Review the entire document" };
+ const first = buildDocumentContext(request);
+ const second = buildDocumentContext(request);
+
+ expect(first.selection.scope).toBe("document");
+ expect(first.selection.selectedBlockIds).toHaveLength(source.blocks.length);
+ expect(first.text).toContain("CONTEXT: whole document");
+ expect(second.text).toBe(first.text);
+ expect(second.selection).toEqual(first.selection);
+
+ const allTables = buildDocumentContext({
+ doc: doc(fixture("table", "fx-table-1", "one"), fixture("table", "fx-table-1", "two")),
+ utterance: "Add a note under every table",
+ });
+ expect(allTables.selection.scope).toBe("document");
+ expect(allTables.selection.ambiguities).toEqual([]);
+ });
+});
diff --git a/packages/kit/src/outline/context.ts b/packages/kit/src/outline/context.ts
new file mode 100644
index 0000000..9ef354c
--- /dev/null
+++ b/packages/kit/src/outline/context.ts
@@ -0,0 +1,293 @@
+import {
+ buildDocumentContextIndex,
+ buildRefMap,
+ type DocumentContextIndex,
+ type LucidDoc,
+ type RefMap,
+} from "@lucid/core";
+import { buildOutline } from "./outline";
+
+export type ContextScope = "targeted" | "document";
+
+export interface ContextSelection {
+ scope: ContextScope;
+ selectedBlockIds: string[];
+ fullBlockIds: string[];
+ missingRefs: string[];
+ ambiguities: { kind: "table" | "chart" | "diagram"; blockIds: string[] }[];
+ totalBlockCount: number;
+}
+
+export interface DocumentContextBundle {
+ text: string;
+ refMap: RefMap;
+ selection: ContextSelection;
+}
+
+export interface DocumentContextRequest {
+ doc: LucidDoc;
+ /** The live editor's disposable index. Tests and non-editor callers may omit it. */
+ index?: DocumentContextIndex | undefined;
+ utterance: string;
+ title?: string | undefined;
+ format?: string | undefined;
+ cursorBlockId?: string | undefined;
+ recentTouched?: readonly string[] | undefined;
+ /** Additional known targets, such as an Input request's focus ids. */
+ focusIds?: readonly string[] | undefined;
+}
+
+const WHOLE_DOCUMENT =
+ /\b(?:whole|entire)\s+(?:document|page|note|notes)\b|\b(?:all|every)\s+(?:section|sections|block|blocks|heading|headings|paragraph|paragraphs|table|tables|diagram|diagrams|chart|charts|item|items)\b|\b(?:summari[sz]e|rewrite|reorganize|review)\s+(?:the\s+)?(?:document|page|whole thing)\b/i;
+const COMPONENT_KINDS = ["table", "diagram", "chart"] as const;
+const STOP_WORDS = new Set([
+ "about",
+ "after",
+ "again",
+ "before",
+ "block",
+ "change",
+ "could",
+ "document",
+ "from",
+ "have",
+ "into",
+ "make",
+ "page",
+ "please",
+ "section",
+ "should",
+ "that",
+ "these",
+ "this",
+ "those",
+ "with",
+ "would",
+]);
+
+const normalized = (value: string): string =>
+ value
+ .toLowerCase()
+ .normalize("NFKD")
+ .replace(/[^a-z0-9]+/g, " ")
+ .trim();
+
+const words = (value: string): Set =>
+ new Set(
+ normalized(value)
+ .split(" ")
+ .filter((word) => word.length >= 4 && !STOP_WORDS.has(word)),
+ );
+
+const ordered = (index: DocumentContextIndex, ids: ReadonlySet): string[] =>
+ index.blocks.filter((block) => ids.has(block.id)).map((block) => block.id);
+
+const addResolved = (ids: Set, resolved: ReturnType): void => {
+ if (!resolved) return;
+ if (resolved.kind === "list") for (const id of resolved.ids) ids.add(id);
+ else ids.add(resolved.id);
+};
+
+function lexicalTargets(index: DocumentContextIndex, utterance: string): string[] {
+ const queryWords = words(utterance);
+ const quoted = [...utterance.matchAll(/["“]([^"”]+)["”]/g)]
+ .map((match) => normalized(match[1] ?? ""))
+ .filter(Boolean);
+ const summaries = index.blocks.map((block) => ({
+ id: block.id,
+ words: new Set(normalized(block.summary).split(" ").filter(Boolean)),
+ summary: normalized(block.summary),
+ }));
+ const unique = new Set();
+ for (const word of queryWords) {
+ const matches = summaries.filter((block) => block.words.has(word));
+ if (matches.length === 1 && matches[0]) unique.add(matches[0].id);
+ }
+ const scored = summaries.map((block) => {
+ let score = [...queryWords].filter((word) => block.words.has(word)).length;
+ if (quoted.some((phrase) => block.summary.includes(phrase))) score += 3;
+ return { id: block.id, score };
+ });
+ const best = Math.max(0, ...scored.map(({ score }) => score));
+ if (best >= 2) {
+ for (const { id, score } of scored) if (score === best) unique.add(id);
+ return [...unique];
+ }
+ if (unique.size > 0) return [...unique];
+ if (best !== 1) return [];
+ const hits = scored.filter(({ score }) => score === 1);
+ return hits.length === 1 ? hits.map(({ id }) => id) : [];
+}
+
+function overview(index: DocumentContextIndex, refMap: RefMap): string[] {
+ const lines = ["OVERVIEW"];
+ const sectionBlocks = new Set(index.sections.flatMap((section) => section.blockIds));
+ const loose = index.blocks.filter((block) => !sectionBlocks.has(block.id)).length;
+ if (loose > 0)
+ lines.push(`- ${String(loose)} block${loose === 1 ? "" : "s"} before or outside sections`);
+ for (const section of index.sections) {
+ const ref = refMap.refOf(section.headingId);
+ const indent = " ".repeat(Math.max(0, section.level - 1));
+ lines.push(
+ `${indent}- ${ref ? `[${ref}] ` : ""}${section.title || "Untitled section"} (${String(section.blockIds.length)} blocks)`,
+ );
+ }
+ if (index.sections.length === 0 && index.blocks.length === 0) lines.push("- empty document");
+ if (index.components.length > 0) {
+ lines.push("COMPONENTS");
+ for (const component of index.components) {
+ const ref = refMap.refOf(component.blockId);
+ const summary = component.summary ? ` — ${component.summary.slice(0, 100)}` : "";
+ lines.push(`- ${ref ? `[${ref}] ` : ""}${component.kind}${summary}`);
+ }
+ }
+ return lines;
+}
+
+/** Deterministically selects prompt context. It performs no model call, search, or persistence. */
+export function buildDocumentContext(request: DocumentContextRequest): DocumentContextBundle {
+ const { doc, utterance } = request;
+ const index = request.index ?? buildDocumentContextIndex(doc, request.recentTouched);
+ const refMap = buildRefMap(doc);
+ const allIds = new Set(index.blocks.map((block) => block.id));
+ const roots = new Set();
+ const missingRefs: string[] = [];
+ const ambiguities: ContextSelection["ambiguities"] = [];
+ const scope: ContextScope = WHOLE_DOCUMENT.test(utterance) ? "document" : "targeted";
+
+ for (const match of utterance.matchAll(/\bb\d+(?:\.\d+)*\b/gi)) {
+ const ref = match[0].toLowerCase();
+ const resolved = refMap.resolve(ref);
+ if (resolved) addResolved(roots, resolved);
+ else if (!missingRefs.includes(ref)) missingRefs.push(ref);
+ }
+ for (const match of utterance.matchAll(/\bblocks?\s+(\d+)\b/gi)) {
+ const ref = `b${match[1] ?? ""}`;
+ const resolved = refMap.resolve(ref);
+ if (resolved) addResolved(roots, resolved);
+ else if (!missingRefs.includes(ref)) missingRefs.push(ref);
+ }
+ const normalizedUtterance = normalized(utterance);
+ for (const section of index.sections) {
+ const title = normalized(section.title);
+ if (title.length >= 3 && normalizedUtterance.includes(title)) roots.add(section.headingId);
+ }
+ for (const id of request.focusIds ?? []) if (allIds.has(id)) roots.add(id);
+ for (const id of lexicalTargets(index, utterance)) roots.add(id);
+
+ const recent = [...(request.recentTouched ?? []), ...index.recentTouched].filter(
+ (id, position, list) => allIds.has(id) && list.indexOf(id) === position,
+ );
+
+ const utteranceIds = new Set(utterance.toLowerCase().match(/[a-z][a-z0-9_]*/g) ?? []);
+ for (const component of index.components) {
+ if (
+ component.partIds.some((partId) => {
+ const part = partId.split(":").at(-1);
+ return part !== undefined && utteranceIds.has(part.toLowerCase());
+ })
+ )
+ roots.add(component.blockId);
+ }
+
+ for (const kind of COMPONENT_KINDS) {
+ if (!new RegExp(`\\b${kind}s?\\b`, "i").test(utterance)) continue;
+ const candidates = index.components.filter((component) => component.kind === kind);
+ const focused = candidates.find(
+ (component) =>
+ component.blockId === request.cursorBlockId ||
+ roots.has(component.blockId) ||
+ recent.includes(component.blockId),
+ );
+ if (scope === "document") for (const candidate of candidates) roots.add(candidate.blockId);
+ else if (focused) roots.add(focused.blockId);
+ else if (candidates.length === 1) roots.add(candidates[0]?.blockId ?? "");
+ else if (candidates.length > 1)
+ ambiguities.push({ kind, blockIds: candidates.map((component) => component.blockId) });
+ }
+
+ const unresolved = missingRefs.length > 0 || ambiguities.length > 0;
+ if (roots.size === 0 && !unresolved && request.cursorBlockId && allIds.has(request.cursorBlockId))
+ roots.add(request.cursorBlockId);
+ if (roots.size === 0 && !unresolved && recent[0]) roots.add(recent[0]);
+ if (roots.size === 0 && !unresolved && index.blocks.at(-1))
+ roots.add(index.blocks.at(-1)?.id ?? "");
+
+ const selected = new Set();
+ if (scope === "document") {
+ for (const id of allIds) selected.add(id);
+ } else {
+ for (const root of roots) {
+ selected.add(root);
+ const entry = index.blocks.find((block) => block.id === root);
+ const section = entry?.sectionId
+ ? index.sections.find((candidate) => candidate.id === entry.sectionId)
+ : undefined;
+ if (section) for (const id of section.blockIds) selected.add(id);
+ const position = entry?.order;
+ if (position !== undefined) {
+ for (const nearby of index.blocks.slice(Math.max(0, position - 1), position + 2))
+ selected.add(nearby.id);
+ }
+ }
+ for (const id of recent.slice(0, 3)) selected.add(id);
+ for (const ambiguity of ambiguities) for (const id of ambiguity.blockIds) selected.add(id);
+ }
+
+ const selectedBlockIds = ordered(index, selected);
+ const full = new Set(
+ scope === "document" ? selectedBlockIds : unresolved ? [] : [...roots],
+ );
+ for (const component of index.components) {
+ if (
+ selected.has(component.blockId) &&
+ (scope === "document" || (!unresolved && roots.has(component.blockId)))
+ )
+ full.add(component.blockId);
+ }
+ const fullBlockIds = ordered(index, full);
+ const detail = buildOutline(doc, {
+ title: request.title,
+ format: request.format,
+ include: selectedBlockIds,
+ full: fullBlockIds,
+ maxChars: 24_000,
+ });
+ const header = detail.text.split("\n")[0] ?? `DOCUMENT "${request.title ?? "Untitled"}"`;
+ const details = detail.text.split("\n").slice(1);
+ const lines = [header, ...overview(index, refMap)];
+ lines.push(scope === "document" ? "CONTEXT: whole document" : "CONTEXT: targeted");
+ lines.push(...details);
+
+ const recentRefs = recent
+ .slice(0, 3)
+ .map((id) => refMap.refOf(id))
+ .filter((ref): ref is string => Boolean(ref));
+ if (recentRefs.length > 0)
+ lines.push(`RECENT_TOUCHED: ${recentRefs.map((ref) => `[${ref}]`).join(", ")}`);
+ for (const ambiguity of ambiguities) {
+ const refs = ambiguity.blockIds
+ .map((id) => refMap.refOf(id))
+ .filter((ref): ref is string => Boolean(ref));
+ lines.push(
+ `TARGET_STATUS: ambiguous ${ambiguity.kind}: ${refs.map((ref) => `[${ref}]`).join(" or ")}. Ask which one; do not guess.`,
+ );
+ }
+ if (missingRefs.length > 0)
+ lines.push(
+ `TARGET_STATUS: missing ${missingRefs.join(", ")}. Say the target is not in the document; do not guess.`,
+ );
+
+ return {
+ text: lines.join("\n"),
+ refMap,
+ selection: {
+ scope,
+ selectedBlockIds,
+ fullBlockIds,
+ missingRefs,
+ ambiguities,
+ totalBlockCount: index.blocks.length,
+ },
+ };
+}
diff --git a/packages/kit/src/outline/index.ts b/packages/kit/src/outline/index.ts
index 53f3ad1..2e50c27 100644
--- a/packages/kit/src/outline/index.ts
+++ b/packages/kit/src/outline/index.ts
@@ -1,4 +1,11 @@
export { buildOutline, type Outline, type OutlineOptions } from "./outline";
+export {
+ buildDocumentContext,
+ type ContextScope,
+ type ContextSelection,
+ type DocumentContextBundle,
+ type DocumentContextRequest,
+} from "./context";
export { buildHints, type HintInput } from "./hints";
export {
blockMentionNotes,
diff --git a/packages/kit/src/outline/outline.test.ts b/packages/kit/src/outline/outline.test.ts
index 1b64e9f..f5915a8 100644
--- a/packages/kit/src/outline/outline.test.ts
+++ b/packages/kit/src/outline/outline.test.ts
@@ -71,6 +71,14 @@ describe("buildOutline", () => {
expect(refMap.resolve("b8")).toEqual({ kind: "block", id: "fx-table-1" });
});
+ it("can render a selected entry without renumbering document references", () => {
+ const d = sample();
+ const { text, refMap } = buildOutline(d, { include: ["p1"], full: ["p1"] });
+ expect(text).toContain('[b4] p "Redis stores hot keys');
+ expect(text).not.toContain("Database options");
+ expect(refMap.resolve("b8")).toEqual({ kind: "block", id: "fx-table-1" });
+ });
+
it("shows nested list items and checkbox state", () => {
const d = doc(bullets({ text: "Caches", children: ["CDN", "Redis"] }), {
id: "c1",
diff --git a/packages/kit/src/outline/outline.ts b/packages/kit/src/outline/outline.ts
index 0e1d2b7..2117e01 100644
--- a/packages/kit/src/outline/outline.ts
+++ b/packages/kit/src/outline/outline.ts
@@ -12,6 +12,8 @@ export interface OutlineOptions {
format?: string | undefined;
/** Block ids shown in full detail (the cursor block, last-touched blocks, blocks the utterance mentions). */
full?: Iterable | undefined;
+ /** Render only entries containing one of these ids. References still come from the complete document. */
+ include?: Iterable | undefined;
/** Size budget in characters (about 4 per token). Default 24000, i.e. roughly 6000 tokens. */
maxChars?: number | undefined;
registry?: Registry | undefined;
@@ -37,6 +39,8 @@ interface Entry {
summary: string;
}
+const idsOf = (block: Block): string[] => [block.id, ...(block.children ?? []).flatMap(idsOf)];
+
function itemText(block: Block, max: number): string {
const checked =
block.type === "checkListItem" ? (block.props["checked"] === true ? "[x] " : "[ ] ") : "";
@@ -55,6 +59,7 @@ export function buildOutline(doc: LucidDoc, options: OutlineOptions = {}): Outli
const registry = options.registry ?? defaultRegistry;
const refMap = buildRefMap(doc);
const fullIds = new Set(options.full ?? []);
+ const includeIds = options.include ? new Set(options.include) : undefined;
const maxChars = options.maxChars ?? 24_000;
const entries: Entry[] = [];
@@ -73,7 +78,7 @@ export function buildOutline(doc: LucidDoc, options: OutlineOptions = {}): Outli
`[${groupRef}] ${label}: ${run.map((b) => itemWithChildren(refMap, b, max)).join(" | ")}`;
entries.push({
ref: groupRef,
- blockIds: run.map((b) => b.id),
+ blockIds: run.flatMap(idsOf),
heading: false,
full: render(300),
summary: render(80),
@@ -83,7 +88,7 @@ export function buildOutline(doc: LucidDoc, options: OutlineOptions = {}): Outli
const spec = registry.get(block.type);
entries.push({
ref,
- blockIds: [block.id],
+ blockIds: idsOf(block),
heading: block.type === "heading",
full: `[${ref}] ${spec.outline(block, "full")}`,
summary: `[${ref}] ${spec.outline(block, "summary")}`,
@@ -92,20 +97,23 @@ export function buildOutline(doc: LucidDoc, options: OutlineOptions = {}): Outli
}
}
+ const visible = includeIds
+ ? entries.filter((entry) => entry.blockIds.some((id) => includeIds.has(id)))
+ : entries;
const isFull = (e: Entry) => e.blockIds.some((id) => fullIds.has(id));
const header = `DOCUMENT "${options.title ?? "Untitled"}"${options.format ? ` (format: ${options.format})` : ""}`;
- const chosen = entries.map((e) => (isFull(e) ? e.full : e.summary));
+ const chosen = visible.map((e) => (isFull(e) ? e.full : e.summary));
const size = () => header.length + chosen.reduce((n, line) => n + line.length + 1, 0);
// Over budget: collapse the blocks farthest from the focus first. Headings always stay.
const collapsed = new Set();
if (size() > maxChars) {
- const anchors = entries.flatMap((e, i) => (isFull(e) ? [i] : []));
- if (anchors.length === 0) anchors.push(entries.length - 1);
+ const anchors = visible.flatMap((e, i) => (isFull(e) ? [i] : []));
+ if (anchors.length === 0) anchors.push(visible.length - 1);
const distance = (i: number) => Math.min(...anchors.map((a) => Math.abs(a - i)));
- const order = entries
+ const order = visible
.map((_, i) => i)
- .filter((i) => !entries[i]?.heading && !isFull(entries[i] as Entry))
+ .filter((i) => !visible[i]?.heading && !isFull(visible[i] as Entry))
.sort((a, b) => distance(b) - distance(a));
let saved = 0;
for (const i of order) {
@@ -116,15 +124,15 @@ export function buildOutline(doc: LucidDoc, options: OutlineOptions = {}): Outli
}
const lines: string[] = [header];
- for (let i = 0; i < entries.length; i++) {
+ for (let i = 0; i < visible.length; i++) {
if (!collapsed.has(i)) {
lines.push(chosen[i] ?? "");
continue;
}
let j = i;
while (collapsed.has(j + 1)) j += 1;
- const first = entries[i]?.ref ?? "";
- const last = entries[j]?.ref ?? "";
+ const first = visible[i]?.ref ?? "";
+ const last = visible[j]?.ref ?? "";
lines.push(`[${first}–${last}] ${String(j - i + 1)} blocks (collapsed)`);
i = j;
}
diff --git a/packages/llm/src/prompts/command.ts b/packages/llm/src/prompts/command.ts
index ea38335..a73246d 100644
--- a/packages/llm/src/prompts/command.ts
+++ b/packages/llm/src/prompts/command.ts
@@ -1,16 +1,17 @@
-import type { LucidDoc } from "@lucid/core";
+import type { DocumentContextIndex, LucidDoc } from "@lucid/core";
import {
LMD_REFERENCE,
OPS_REFERENCE,
SAMPLE_DOC,
blockMentionNotes,
+ buildDocumentContext,
buildHints,
buildOutline,
registry,
} from "@lucid/kit";
import type { ChatMessage } from "../client";
-export const COMMAND_PROMPT_VERSION = 1;
+export const COMMAND_PROMPT_VERSION = 2;
/** Static for every request, so the model server can reuse its cache of this part. */
export function commandSystemPrompt(): string {
@@ -58,6 +59,7 @@ ${(
export interface CommandContext {
doc: LucidDoc;
+ contextIndex?: DocumentContextIndex | undefined;
title?: string | undefined;
format?: string | undefined;
cursorBlockId?: string | undefined;
@@ -69,6 +71,8 @@ export interface CommandContext {
collisions?: boolean | undefined;
/** Extra lines for the model after the hints (the guided builder tells it how the person numbers things). */
extra?: string | undefined;
+ /** Internal: the measured few-shot prefix retains its legacy document rendering. */
+ targetedContext?: boolean | undefined;
}
/**
@@ -84,27 +88,35 @@ export function commandUserMessage(ctx: CommandContext): {
text: string;
refMap: ReturnType["refMap"];
} {
- // The outline must depend on the DOCUMENT only, never on the cursor or the words just said: then consecutive requests
- // share everything up to the hints, and the model server can reuse its work on the long prefix (a request whose
- // outline differs from the last one's pays for the whole prompt again, ~12 s). So the blocks the model needs to
- // address precisely (diagrams, tables, charts) are always shown in full; only a very large page falls back to
- // summaries, and then the cursor and the last changes decide what stays in detail.
- const always = ctx.doc.blocks
- .filter((b) => b.type === "diagram" || b.type === "table" || b.type === "chart")
- .map((b) => b.id);
- const near = [
- ...(ctx.cursorBlockId ? [ctx.cursorBlockId] : []),
- ...(ctx.touched ?? []).slice(0, 3).map((t) => t.blockId),
- ];
- const small = buildOutline(ctx.doc, {
- title: ctx.title,
- format: ctx.format,
- full: always,
- maxChars: 24_000,
- });
- const outline = small.text.includes("(collapsed)")
- ? buildOutline(ctx.doc, { title: ctx.title, format: ctx.format, full: [...always, ...near] })
- : small;
+ let outline: ReturnType;
+ if (ctx.targetedContext === false) {
+ // Keep the examples byte-for-byte stable: they form the measured, cacheable prefix.
+ const always = ctx.doc.blocks
+ .filter(
+ (block) => block.type === "diagram" || block.type === "table" || block.type === "chart",
+ )
+ .map((block) => block.id);
+ const small = buildOutline(ctx.doc, {
+ title: ctx.title,
+ format: ctx.format,
+ full: always,
+ maxChars: 24_000,
+ });
+ outline = small.text.includes("(collapsed)")
+ ? buildOutline(ctx.doc, { title: ctx.title, format: ctx.format, full: always })
+ : small;
+ } else {
+ const bundle = buildDocumentContext({
+ doc: ctx.doc,
+ index: ctx.contextIndex,
+ utterance: ctx.utterance,
+ title: ctx.title,
+ format: ctx.format,
+ cursorBlockId: ctx.cursorBlockId,
+ recentTouched: (ctx.touched ?? []).map((touch) => touch.blockId),
+ });
+ outline = { text: bundle.text, refMap: bundle.refMap };
+ }
const hints = buildHints({
doc: ctx.doc,
refMap: outline.refMap,
@@ -265,6 +277,7 @@ export function buildCommandMessages(ctx: CommandContext): {
title: "Example",
// The examples stay as they were measured: the server reuses its work on them (see prefix.ts).
collisions: false,
+ targetedContext: false,
});
messages.push(
{ role: "user", content: user.text },
diff --git a/packages/llm/src/prompts/context.test.ts b/packages/llm/src/prompts/context.test.ts
new file mode 100644
index 0000000..50cb07e
--- /dev/null
+++ b/packages/llm/src/prompts/context.test.ts
@@ -0,0 +1,75 @@
+import type { Block } from "@lucid/core";
+import { doc, h1, h2, p } from "@lucid/core/testing";
+import { registry } from "@lucid/kit";
+import { describe, expect, it } from "vitest";
+import { buildCommandMessages } from "./command";
+import { buildInputMessages } from "./input";
+
+const last = (messages: ReturnType["messages"]): string =>
+ messages.at(-1)?.content ?? "";
+
+const table = (id: string): Block => {
+ const found = registry.get("table").fixtures.find((fixture) => fixture.id === "fx-table-1");
+ if (!found) throw new Error("missing table fixture");
+ return { ...found, id };
+};
+
+describe("prompt context selection", () => {
+ it("targets the cursor section in live Command and Input requests", () => {
+ const source = doc(
+ h1("Design", { id: "title" }),
+ h2("Requirements", { id: "requirements" }),
+ p("This requirement body must stay out of caching context.", { id: "requirement-body" }),
+ h2("Caching", { id: "caching" }),
+ p("Redis stores hot keys.", { id: "redis" }),
+ p("A cache miss reads from the database.", { id: "miss" }),
+ );
+ const command = last(
+ buildCommandMessages({
+ doc: source,
+ utterance: "Make this sentence shorter",
+ cursorBlockId: "redis",
+ }).messages,
+ );
+ const input = last(
+ buildInputMessages({
+ doc: source,
+ utterance: "Redis entries expire after thirty days",
+ cursorBlockId: "redis",
+ }).messages,
+ );
+
+ for (const prompt of [command, input]) {
+ expect(prompt).toContain("CONTEXT: targeted");
+ expect(prompt).toContain("Redis stores hot keys");
+ expect(prompt).not.toContain("requirement body must stay out");
+ }
+ });
+
+ it("tells Command to clarify ambiguous components and missing refs", () => {
+ const source = doc(h1("Tables", { id: "heading" }), table("one"), table("two"));
+ const ambiguous = last(
+ buildCommandMessages({ doc: source, utterance: "Add a row to the table" }).messages,
+ );
+ const missing = last(buildCommandMessages({ doc: source, utterance: "Remove b80" }).messages);
+
+ expect(ambiguous).toContain("TARGET_STATUS: ambiguous table");
+ expect(ambiguous).toContain("Ask which one; do not guess.");
+ expect(missing).toContain("TARGET_STATUS: missing b80");
+ expect(missing).toContain("do not guess");
+ });
+
+ it("expands explicit whole-document requests", () => {
+ const source = doc(
+ h1("Design", { id: "heading" }),
+ p("First body", { id: "first" }),
+ p("Second body", { id: "second" }),
+ );
+ const prompt = last(
+ buildCommandMessages({ doc: source, utterance: "Review the entire document" }).messages,
+ );
+ expect(prompt).toContain("CONTEXT: whole document");
+ expect(prompt).toContain("First body");
+ expect(prompt).toContain("Second body");
+ });
+});
diff --git a/packages/llm/src/prompts/input.ts b/packages/llm/src/prompts/input.ts
index 8db3215..092184d 100644
--- a/packages/llm/src/prompts/input.ts
+++ b/packages/llm/src/prompts/input.ts
@@ -1,8 +1,8 @@
-import { LMD_REFERENCE, buildOutline, getFormat, registry } from "@lucid/kit";
-import type { Block, LucidDoc } from "@lucid/core";
+import { LMD_REFERENCE, buildDocumentContext, buildOutline, getFormat, registry } from "@lucid/kit";
+import type { Block, DocumentContextIndex, LucidDoc } from "@lucid/core";
import type { ChatMessage } from "../client";
-export const INPUT_PROMPT_VERSION = 1;
+export const INPUT_PROMPT_VERSION = 2;
const BLOCK_ORDER = [
"heading",
@@ -177,6 +177,7 @@ $$`,
export interface InputContext {
doc: LucidDoc;
+ contextIndex?: DocumentContextIndex | undefined;
title?: string | undefined;
format?: string | undefined;
cursorBlockId?: string | undefined;
@@ -185,6 +186,8 @@ export interface InputContext {
utterance: string;
/** Block ids the utterance or the user's recent work points at; shown in full in the outline. */
focusIds?: readonly string[] | undefined;
+ /** Internal: few-shot examples retain their stable legacy outline. */
+ targetedContext?: boolean | undefined;
}
/** The part of the request that changes every time: the document as an outline, where the cursor is, what was said. */
@@ -192,14 +195,25 @@ export function inputUserMessage(ctx: InputContext): {
text: string;
refMap: ReturnType["refMap"];
} {
- // The outline depends on the document only (not on the cursor or the words), so the model server can reuse its work on
- // everything before the cursor line; the Input lane only needs references, not details.
+ // Few-shot examples retain their document-only outline for a stable prefix. The live request intentionally uses the
+ // cursor and utterance to select a smaller context bundle.
const format = getFormat(ctx.format);
- const outline = buildOutline(ctx.doc, {
- title: ctx.title,
- format: format?.name ?? ctx.format,
- full: ctx.focusIds,
- });
+ const outline =
+ ctx.targetedContext === false
+ ? buildOutline(ctx.doc, {
+ title: ctx.title,
+ format: format?.name ?? ctx.format,
+ full: ctx.focusIds,
+ })
+ : buildDocumentContext({
+ doc: ctx.doc,
+ index: ctx.contextIndex,
+ utterance: ctx.utterance,
+ title: ctx.title,
+ format: format?.name ?? ctx.format,
+ cursorBlockId: ctx.cursorBlockId,
+ focusIds: ctx.focusIds,
+ });
const cursorRef = ctx.cursorBlockId ? outline.refMap.refOf(ctx.cursorBlockId) : undefined;
const lines = [
"OUTLINE",
@@ -233,6 +247,7 @@ export function buildInputMessages(ctx: InputContext): {
utterance: ex.utterance,
cursorBlockId: ex.focus,
title: "Example",
+ targetedContext: false,
});
messages.push({ role: "user", content: user.text }, { role: "assistant", content: ex.answer });
}
From f2b571df5858ae528ea1cbf40d310b59500d92da Mon Sep 17 00:00:00 2001
From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com>
Date: Tue, 20 Oct 2026 00:30:46 +0530
Subject: [PATCH 11/13] feat(llm): harden structured command execution (#44)
---
apps/desktop/electron/ai/service.test.ts | 2 +-
apps/desktop/src/dictation/DictationBar.tsx | 13 ++++
apps/desktop/src/jobs/queue.test.ts | 49 ++++++++++++---
apps/desktop/src/jobs/queue.ts | 6 ++
.../desktop/src/llm/mainProcessClient.test.ts | 2 +-
apps/desktop/src/llm/mainProcessClient.ts | 8 ++-
docs/architecture.md | 10 +++-
docs/evals.md | 1 +
packages/evals/src/cli.test.ts | 14 ++++-
packages/evals/src/cli.ts | 32 ++++++++--
packages/evals/src/report.ts | 5 +-
packages/llm/src/client.test.ts | 2 +-
packages/llm/src/client.ts | 2 +-
packages/llm/src/cloud.ts | 11 +++-
packages/llm/src/commandPipeline.test.ts | 59 +++++++++++++++++++
packages/llm/src/commandPipeline.ts | 24 +++++++-
packages/llm/src/compatible.test.ts | 4 +-
packages/llm/src/ollama.test.ts | 2 +-
packages/llm/src/ollama.ts | 2 +-
packages/llm/src/provider.ts | 7 ++-
20 files changed, 222 insertions(+), 33 deletions(-)
diff --git a/apps/desktop/electron/ai/service.test.ts b/apps/desktop/electron/ai/service.test.ts
index e00da8a..2f6aa94 100644
--- a/apps/desktop/electron/ai/service.test.ts
+++ b/apps/desktop/electron/ai/service.test.ts
@@ -16,7 +16,7 @@ const client = (chat: AiClient["chat"]): AiClient => ({
capabilities: {
streaming: true,
cancellation: true,
- structuredOutput: "json-schema",
+ structuredOutput: "native",
modelDiscovery: false,
usage: "estimated",
reasoningControl: false,
diff --git a/apps/desktop/src/dictation/DictationBar.tsx b/apps/desktop/src/dictation/DictationBar.tsx
index 6ea9250..640d9a2 100644
--- a/apps/desktop/src/dictation/DictationBar.tsx
+++ b/apps/desktop/src/dictation/DictationBar.tsx
@@ -276,6 +276,19 @@ export function DictationBar({
>
)}
+ {status.kind === "unavailable" && (
+ <>
+
+ !
+
+
+ Command unavailable. {status.message}
+
+
+ Dismiss
+
+ >
+ )}
{status.kind === "proposal" && (
<>
diff --git a/apps/desktop/src/jobs/queue.test.ts b/apps/desktop/src/jobs/queue.test.ts
index 85ad210..119807a 100644
--- a/apps/desktop/src/jobs/queue.test.ts
+++ b/apps/desktop/src/jobs/queue.test.ts
@@ -7,7 +7,13 @@ import {
type LucidDoc,
type TranscriptEntry,
} from "@lucid/core";
-import { COMMAND_DOC, DEFAULT_LANES, LmStudioClient } from "@lucid/llm";
+import {
+ COMMAND_DOC,
+ DEFAULT_LANES,
+ LmStudioClient,
+ type AiClient,
+ type ProviderCapabilities,
+} from "@lucid/llm";
import { startMockLmStudio, type Behaviour, type MockLmStudio } from "@lucid/mock-lmstudio";
import { afterEach, describe, expect, it } from "vitest";
import { JobQueue, type EditorPort, type JobStatus, type Utterance } from "./queue";
@@ -47,7 +53,12 @@ class FakeEditor implements EditorPort {
async function setup(
script: Behaviour[] | undefined,
- options: { fetch?: typeof fetch; models?: string[]; firstTokenTimeoutMs?: number } = {},
+ options: {
+ fetch?: typeof fetch;
+ models?: string[];
+ firstTokenTimeoutMs?: number;
+ structuredOutput?: ProviderCapabilities["structuredOutput"];
+ } = {},
) {
mock = await startMockLmStudio({
script: script ?? [],
@@ -57,12 +68,22 @@ async function setup(
const statuses: JobStatus[] = [];
const transcripts: TranscriptEntry[] = [];
const changes: ChangeEntry[] = [];
+ const nativeClient = new LmStudioClient({
+ baseUrl: mock.url,
+ ...(options.fetch ? { fetch: options.fetch } : {}),
+ ...(options.firstTokenTimeoutMs ? { firstTokenTimeoutMs: options.firstTokenTimeoutMs } : {}),
+ });
+ const client: AiClient = options.structuredOutput
+ ? {
+ provider: nativeClient.provider,
+ capabilities: { ...nativeClient.capabilities, structuredOutput: options.structuredOutput },
+ localModels: nativeClient.localModels,
+ chat: (request) => nativeClient.chat(request),
+ health: (signal) => nativeClient.health(signal),
+ }
+ : nativeClient;
const queue = new JobQueue({
- client: new LmStudioClient({
- baseUrl: mock.url,
- ...(options.fetch ? { fetch: options.fetch } : {}),
- ...(options.firstTokenTimeoutMs ? { firstTokenTimeoutMs: options.firstTokenTimeoutMs } : {}),
- }),
+ client,
lanes: { input: DEFAULT_LANES.input, command: DEFAULT_LANES.command },
modelName: "qwen3.8-27b",
editor: () => editor,
@@ -106,6 +127,20 @@ async function setup(
}
describe("JobQueue", () => {
+ it("shows an explicit unavailable state and applies nothing when Command is unsupported", async () => {
+ const t = await setup([], { structuredOutput: "unavailable" });
+ t.editor.doc = COMMAND_DOC;
+ t.say("delete the table", "typed", "command");
+ await t.settled();
+ const last = t.last();
+ expect(last?.kind).toBe("unavailable");
+ if (last?.kind !== "unavailable") throw new Error("Expected unavailable status");
+ expect(last.message).toContain("No edit was applied");
+ expect(t.editor.applied).toHaveLength(0);
+ expect(t.mock.requests).toHaveLength(0);
+ expect(t.transcripts.at(-1)?.status).toBe("rejected");
+ });
+
it("turns an utterance into notes in the page and records what happened", async () => {
const t = await setup([reply(good)]);
t.say(said, "wispr");
diff --git a/apps/desktop/src/jobs/queue.ts b/apps/desktop/src/jobs/queue.ts
index ede8ad6..2445927 100644
--- a/apps/desktop/src/jobs/queue.ts
+++ b/apps/desktop/src/jobs/queue.ts
@@ -43,6 +43,8 @@ export type JobStatus =
| { kind: "clarify"; question: string; options: { label: string; ref: string | null }[] }
/** A Command Mode utterance that was really notes. */
| { kind: "content"; text: string }
+ /** Command is disabled because the selected provider cannot meet the checked-operations contract. */
+ | { kind: "unavailable"; message: string }
| { kind: "failed"; message: string; text: string };
/** The open page's editor, as the queue needs it. */
@@ -401,6 +403,10 @@ export class JobQueue {
);
this.report("command", outcome.calls);
switch (outcome.kind) {
+ case "unavailable":
+ this.log(job, "rejected");
+ this.options.onStatus({ kind: "unavailable", message: outcome.message });
+ return;
case "edit": {
editor.apply(outcome.patch);
const notice = outcome.warnings.length > 0 ? outcome.warnings.join(" ") : undefined;
diff --git a/apps/desktop/src/llm/mainProcessClient.test.ts b/apps/desktop/src/llm/mainProcessClient.test.ts
index 978459c..8629280 100644
--- a/apps/desktop/src/llm/mainProcessClient.test.ts
+++ b/apps/desktop/src/llm/mainProcessClient.test.ts
@@ -174,7 +174,7 @@ describe("MainProcessAiClient", () => {
);
expect(ollama.provider).toBe("ollama");
expect(ollama.capabilities).toMatchObject({
- structuredOutput: "json-schema",
+ structuredOutput: "native",
usage: "provider-or-estimated",
reasoningControl: false,
});
diff --git a/apps/desktop/src/llm/mainProcessClient.ts b/apps/desktop/src/llm/mainProcessClient.ts
index d650df9..b47b6ec 100644
--- a/apps/desktop/src/llm/mainProcessClient.ts
+++ b/apps/desktop/src/llm/mainProcessClient.ts
@@ -25,7 +25,7 @@ export interface MainAiTransport {
const LOCAL_CAPABILITIES: Readonly
= Object.freeze({
streaming: true,
cancellation: true,
- structuredOutput: "json-schema",
+ structuredOutput: "native",
modelDiscovery: true,
usage: "provider-or-estimated",
reasoningControl: false,
@@ -71,12 +71,14 @@ export class MainProcessAiClient implements AiClient {
profile.kind === "lmstudio"
? { ...LOCAL_CAPABILITIES, reasoningControl: true }
: profile.kind === "ollama-cloud"
- ? { ...LOCAL_CAPABILITIES, structuredOutput: "none", customBaseUrl: false }
+ ? { ...LOCAL_CAPABILITIES, structuredOutput: "unavailable", customBaseUrl: false }
: profile.kind === "openai" ||
profile.kind === "anthropic" ||
profile.kind === "openrouter"
? { ...LOCAL_CAPABILITIES, customBaseUrl: false }
- : LOCAL_CAPABILITIES;
+ : profile.kind === "custom"
+ ? { ...LOCAL_CAPABILITIES, structuredOutput: "prompted" }
+ : LOCAL_CAPABILITIES;
}
async health(signal?: AbortSignal): Promise {
diff --git a/docs/architecture.md b/docs/architecture.md
index 93cf1c0..d5c8ffb 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -73,9 +73,13 @@ lexical and structural only; it makes no router-model call and stores no vectors
Input, Command, Advisor, the desktop job queue, and evals depend on Lucid's `AiClient` interface in
`packages/llm/src/provider.ts`; Electron main-process composition constructs the selected provider adapter. The contract covers streamed text,
`AbortSignal` cancellation, optional JSON Schema output, usage and timing results, model discovery, and optional local-model
-controls. Every adapter publishes a capability record, so unsupported behavior is data rather than an assumption. LM Studio
-currently advertises JSON Schema, streaming, cancellation, discovery, reasoning controls, and custom base URLs; it does not
-advertise in-app model loading because Lucid has not implemented that control yet.
+controls. Every adapter publishes a capability record, so unsupported behavior is data rather than an assumption. Structured
+output is classified as `native`, `prompted`, or `unavailable`: native adapters receive the JSON Schema at the provider API;
+prompted adapters receive the same schema as an explicit prompt contract and Lucid still parses, validates, compiles, and
+repairs at most once; unavailable adapters stop Command before the request and show a dedicated UI state. There is no automatic
+downgrade after a native schema request fails. LM Studio currently advertises native JSON Schema, streaming, cancellation,
+discovery, reasoning controls, and custom base URLs; it does not advertise in-app model loading because Lucid has not
+implemented that control yet.
### Vercel AI SDK compatibility spike
diff --git a/docs/evals.md b/docs/evals.md
index cda6904..5449166 100644
--- a/docs/evals.md
+++ b/docs/evals.md
@@ -6,6 +6,7 @@ and says which ones still work. Prompt changes must not make it worse.
```bash
pnpm eval --label my-change # every case, one request at a time (~15 minutes)
pnpm eval --category diagram_edit,table_edit # some categories
+pnpm eval --mode command --structured prompted # exercise prompt-plus-validation providers
pnpm eval --id diag-add-redis --no-variants # one case
pnpm eval --compare packages/evals/reports/2026-10-11-baseline packages/evals/reports/2026-10-12-my-change
```
diff --git a/packages/evals/src/cli.test.ts b/packages/evals/src/cli.test.ts
index e80346e..f17105f 100644
--- a/packages/evals/src/cli.test.ts
+++ b/packages/evals/src/cli.test.ts
@@ -66,13 +66,17 @@ describe("main", () => {
c("a", { variants: ["variant of a"] }),
c("b", { expect: { doc: { hasHeading: [{ text: "kafka" }] } } }),
];
- const code = await main(["--url", mock.url, "--out", out, "--label", "t"], { cases });
+ const code = await main(
+ ["--url", mock.url, "--out", out, "--label", "t", "--structured", "prompted"],
+ { cases },
+ );
expect(code).toBe(0);
const results = JSON.parse(readFileSync(join(out, "results.json"), "utf8")) as {
- meta: { label: string };
+ meta: { label: string; structuredOutput: string };
trials: { caseId: string; pass: boolean }[];
};
expect(results.meta.label).toBe("t");
+ expect(results.meta.structuredOutput).toBe("prompted");
expect(results.trials.map((t) => [t.caseId, t.pass])).toEqual([
["a", true],
["a", true],
@@ -100,6 +104,12 @@ describe("main", () => {
expect(mock.requests).toHaveLength(0);
});
+ it("rejects an unknown structured-output eval configuration before connecting", async () => {
+ const err = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ expect(await main(["--structured", "maybe"], { cases: [c("a")] })).toBe(2);
+ expect(String(err.mock.calls[0]?.[0])).toContain("native or prompted");
+ });
+
it("compares two reports", async () => {
mock = await startMockLmStudio({ script: [notes, notes] });
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
diff --git a/packages/evals/src/cli.ts b/packages/evals/src/cli.ts
index 8065dff..d5a966f 100644
--- a/packages/evals/src/cli.ts
+++ b/packages/evals/src/cli.ts
@@ -1,6 +1,11 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
-import { DEFAULT_LANES, LmStudioClient } from "@lucid/llm";
+import {
+ DEFAULT_LANES,
+ LmStudioClient,
+ type AiClient,
+ type ProviderCapabilities,
+} from "@lucid/llm";
import { ALL_CASES, validateCases } from "./cases";
import { comparisonToMarkdown, summarise, toMarkdown, type Results } from "./report";
import { runTrial, trialsOf, type Trial } from "./run";
@@ -9,7 +14,8 @@ import type { Case } from "./types";
/**
* `pnpm eval`: runs the cases against the model in LM Studio, one request at a time, and writes a report.
* pnpm eval [--category a,b] [--id x,y] [--mode input|command] [--no-variants] [--label name]
- * [--model key] [--url http://127.0.0.1:1234] [--out dir] [--strict]
+ * [--model key] [--url http://127.0.0.1:1234] [--structured native|prompted]
+ * [--out dir] [--strict]
* pnpm eval --compare
*/
function arg(args: string[], name: string): string | undefined {
@@ -72,8 +78,24 @@ export async function main(args: string[], options: MainOptions = {}): Promise nativeClient.chat(request),
+ health: (signal) => nativeClient.health(signal),
+ };
+ const health = await nativeClient.health();
if (!health.reachable || !health.models.includes(model)) {
console.error(
!health.reachable
@@ -101,7 +123,7 @@ export async function main(args: string[], options: MainOptions = {}): Promise {
expect(client.capabilities).toEqual({
streaming: true,
cancellation: true,
- structuredOutput: "json-schema",
+ structuredOutput: "native",
modelDiscovery: true,
usage: "provider-or-estimated",
reasoningControl: true,
diff --git a/packages/llm/src/client.ts b/packages/llm/src/client.ts
index 398660e..87268a3 100644
--- a/packages/llm/src/client.ts
+++ b/packages/llm/src/client.ts
@@ -41,7 +41,7 @@ export class LmStudioClient implements AiClient {
readonly capabilities: Readonly = Object.freeze({
streaming: true,
cancellation: true,
- structuredOutput: "json-schema",
+ structuredOutput: "native",
modelDiscovery: true,
usage: "provider-or-estimated",
reasoningControl: true,
diff --git a/packages/llm/src/cloud.ts b/packages/llm/src/cloud.ts
index afad9a8..4348d8b 100644
--- a/packages/llm/src/cloud.ts
+++ b/packages/llm/src/cloud.ts
@@ -203,7 +203,7 @@ export class OpenAiClient extends CloudClient {
readonly capabilities: Readonly = Object.freeze({
streaming: true,
cancellation: true,
- structuredOutput: "json-schema",
+ structuredOutput: "native",
modelDiscovery: true,
usage: "provider-or-estimated",
reasoningControl: false,
@@ -298,7 +298,7 @@ export class AnthropicClient extends CloudClient {
readonly capabilities: Readonly = Object.freeze({
streaming: true,
cancellation: true,
- structuredOutput: "json-schema",
+ structuredOutput: "native",
modelDiscovery: true,
usage: "provider-or-estimated",
reasoningControl: false,
@@ -414,7 +414,12 @@ export class OpenAiCompatibleClient extends CloudClient {
this.capabilities = Object.freeze({
streaming: true,
cancellation: true,
- structuredOutput: options.kind === "ollama-cloud" ? "none" : "json-schema",
+ structuredOutput:
+ options.kind === "ollama-cloud"
+ ? "unavailable"
+ : options.kind === "custom"
+ ? "prompted"
+ : "native",
modelDiscovery: true,
usage: "provider-or-estimated",
reasoningControl: false,
diff --git a/packages/llm/src/commandPipeline.test.ts b/packages/llm/src/commandPipeline.test.ts
index f59b30b..9b563ee 100644
--- a/packages/llm/src/commandPipeline.test.ts
+++ b/packages/llm/src/commandPipeline.test.ts
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { LmStudioClient } from "./client";
import { runCommand, type CommandJob } from "./commandPipeline";
import { COMMAND_DOC, COMMAND_EXAMPLES, buildCommandMessages } from "./prompts/command";
+import type { AiClient, ProviderCapabilities } from "./provider";
let mock: MockLmStudio | undefined;
afterEach(async () => {
@@ -14,6 +15,16 @@ async function setup(script: Parameters[0]) {
mock = await startMockLmStudio(script);
return { mock, client: new LmStudioClient({ baseUrl: mock.url }) };
}
+const withStructuredOutput = (
+ client: AiClient,
+ structuredOutput: ProviderCapabilities["structuredOutput"],
+): AiClient => ({
+ provider: client.provider,
+ capabilities: { ...client.capabilities, structuredOutput },
+ localModels: client.localModels,
+ chat: (request) => client.chat(request),
+ health: (signal) => client.health(signal),
+});
const ids = () => createSequentialIds("n");
const json = (over: Record) =>
JSON.stringify({
@@ -48,6 +59,54 @@ describe("the few-shot examples", () => {
});
describe("runCommand", () => {
+ it("uses a prompted-and-validated contract without claiming native schema enforcement", async () => {
+ const { client, mock } = await setup({
+ script: [
+ reply(
+ json({
+ summary: "Struck it",
+ ops: [{ op: "set_status", target: "b6.2", in: null, status: "struck" }],
+ }),
+ ),
+ ],
+ });
+ const out = await runCommand(job("cross out MongoDB"), {
+ client: withStructuredOutput(client, "prompted"),
+ ids: ids(),
+ });
+ expect(out).toMatchObject({ kind: "edit", repaired: false });
+ const body = mock.requests[0]?.body;
+ expect(body).not.toHaveProperty("response_format");
+ const messages = body?.["messages"] as { role: string; content: string }[];
+ expect(messages.at(-1)?.content).toContain("STRUCTURED OUTPUT CONTRACT");
+ expect(messages.at(-1)?.content).toContain('"required"');
+ });
+
+ it.each([
+ ["malformed", "{ not json"],
+ ["partial", "{}"],
+ ["schema-violating", json({ kind: "clarify", question: 42, options: [] })],
+ ])("rejects %s prompted responses after one repair", async (_name, answer) => {
+ const { client, mock } = await setup({ script: [reply(answer)] });
+ const out = await runCommand(job("cross out MongoDB"), {
+ client: withStructuredOutput(client, "prompted"),
+ ids: ids(),
+ });
+ expect(out.kind).toBe("failed");
+ expect(mock.requests).toHaveLength(2);
+ expect(mock.requests.every((request) => !("response_format" in request.body))).toBe(true);
+ });
+
+ it("stops an unavailable structured provider before sending a request", async () => {
+ const { client, mock } = await setup({ script: [] });
+ const out = await runCommand(job("delete the table"), {
+ client: withStructuredOutput(client, "unavailable"),
+ ids: ids(),
+ });
+ expect(out).toMatchObject({ kind: "unavailable", calls: [], raw: "" });
+ expect(mock.requests).toHaveLength(0);
+ });
+
it("returns a patch that applies the change to the document", async () => {
const { client, mock } = await setup({
script: [
diff --git a/packages/llm/src/commandPipeline.ts b/packages/llm/src/commandPipeline.ts
index 7fdb800..5bdfb38 100644
--- a/packages/llm/src/commandPipeline.ts
+++ b/packages/llm/src/commandPipeline.ts
@@ -44,6 +44,8 @@ interface Change {
}
export type CommandOutcome =
+ /** The selected provider cannot safely produce checked Command operations. No request was sent. */
+ | { kind: "unavailable"; message: string; calls: []; raw: "" }
/** A change, ready to apply. */
| (Common & Change & { kind: "edit" })
/** A change that throws something away without the user having said so: show it and wait for a yes. */
@@ -64,6 +66,16 @@ const repairMessage = (problems: string[]): ChatMessage => ({
content: `Your answer had these problems:\n${problems.map((p) => `- ${p}`).join("\n")}\nReturn the corrected, complete JSON object and nothing else.`,
});
+const promptedSchemaMessage = (schema: { name: string; schema: object }): ChatMessage => ({
+ role: "user",
+ content: [
+ "STRUCTURED OUTPUT CONTRACT:",
+ "Return exactly one JSON object and no prose or markdown.",
+ "It must satisfy this JSON Schema. Lucid will reject the response before making any change if it does not.",
+ JSON.stringify(schema.schema),
+ ].join("\n"),
+});
+
/**
* Command Mode for one utterance: ask the model for ops, compile them on a copy of the document, and
* report an edit, a proposal (destructive without a removal word), a question, or "nothing to do". At most one
@@ -78,11 +90,21 @@ export async function runCommand(job: CommandJob, deps: CommandDeps): Promise => {
const result = await deps.client.chat({
lane,
messages,
- jsonSchema: schema,
+ jsonSchema: structuredOutput === "native" ? schema : undefined,
signal: deps.signal,
onDelta: deps.onDelta,
});
diff --git a/packages/llm/src/compatible.test.ts b/packages/llm/src/compatible.test.ts
index ec14a92..9b2d938 100644
--- a/packages/llm/src/compatible.test.ts
+++ b/packages/llm/src/compatible.test.ts
@@ -56,6 +56,7 @@ describe("OpenAI-compatible hosted endpoints", () => {
stream_options: { include_usage: true },
});
expect(body).not.toHaveProperty("models");
+ expect(client.capabilities.structuredOutput).toBe("native");
expect(fetcher).toHaveBeenCalledTimes(1);
});
@@ -72,6 +73,7 @@ describe("OpenAI-compatible hosted endpoints", () => {
if (typeof payload !== "string") throw new Error("Expected request body");
expect(JSON.parse(payload)).not.toHaveProperty("stream_options");
expect(client.capabilities.customBaseUrl).toBe(true);
+ expect(client.capabilities.structuredOutput).toBe("prompted");
});
it("allows Ollama Cloud Input but blocks Command and Advisor before a network request", async () => {
@@ -82,7 +84,7 @@ describe("OpenAI-compatible hosted endpoints", () => {
apiKey: "secret",
fetch: fetcher,
});
- expect(client.capabilities.structuredOutput).toBe("none");
+ expect(client.capabilities.structuredOutput).toBe("unavailable");
await expect(client.chat(request(true))).rejects.toThrow(/does not support JSON Schema/);
expect(fetcher).not.toHaveBeenCalled();
expect((await client.chat(request())).content).toBe("hello");
diff --git a/packages/llm/src/ollama.test.ts b/packages/llm/src/ollama.test.ts
index f7d2c6c..2187e68 100644
--- a/packages/llm/src/ollama.test.ts
+++ b/packages/llm/src/ollama.test.ts
@@ -36,7 +36,7 @@ describe("OllamaClient", () => {
expect(client.capabilities).toEqual({
streaming: true,
cancellation: true,
- structuredOutput: "json-schema",
+ structuredOutput: "native",
modelDiscovery: true,
usage: "provider-or-estimated",
reasoningControl: false,
diff --git a/packages/llm/src/ollama.ts b/packages/llm/src/ollama.ts
index d6ed666..cb93330 100644
--- a/packages/llm/src/ollama.ts
+++ b/packages/llm/src/ollama.ts
@@ -62,7 +62,7 @@ export class OllamaClient implements AiClient {
readonly capabilities: Readonly = Object.freeze({
streaming: true,
cancellation: true,
- structuredOutput: "json-schema",
+ structuredOutput: "native",
modelDiscovery: true,
usage: "provider-or-estimated",
reasoningControl: false,
diff --git a/packages/llm/src/provider.ts b/packages/llm/src/provider.ts
index 4479d32..12faa0a 100644
--- a/packages/llm/src/provider.ts
+++ b/packages/llm/src/provider.ts
@@ -53,7 +53,12 @@ export interface LocalModelControl {
export interface ProviderCapabilities {
streaming: boolean;
cancellation: boolean;
- structuredOutput: "json-schema" | "json-object" | "none";
+ /**
+ * `native` means the provider enforces Lucid's JSON Schema. `prompted` means Lucid must include the
+ * schema in the prompt and validate the answer itself. `unavailable` must stop structured work before
+ * any request is sent; adapters never silently downgrade between these modes.
+ */
+ structuredOutput: "native" | "prompted" | "unavailable";
modelDiscovery: boolean;
usage: "provider-or-estimated" | "estimated" | "none";
reasoningControl: boolean;
From 8902c2067cb3d937d8f6188e3e19d4b06469e791 Mon Sep 17 00:00:00 2001
From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com>
Date: Tue, 20 Oct 2026 00:52:02 +0530
Subject: [PATCH 12/13] fix(command): stabilize clarification cards (#33)
---
apps/desktop/e2e/command.spec.ts | 60 +++++++++++++++++++++
apps/desktop/src/dictation/DictationBar.tsx | 8 ++-
apps/desktop/src/styles.css | 25 +++++++++
3 files changed, 91 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/e2e/command.spec.ts b/apps/desktop/e2e/command.spec.ts
index 013b2b3..25ecc2b 100644
--- a/apps/desktop/e2e/command.spec.ts
+++ b/apps/desktop/e2e/command.spec.ts
@@ -196,6 +196,66 @@ test("an unclear command asks which one, and the answer is sent back to the mode
}
});
+test("a long clarification with four long choices stays readable in a narrow window", async () => {
+ test.setTimeout(90_000);
+ const ask = json({
+ kind: "clarify",
+ question:
+ "Which of these similarly named connections should be removed from the request-processing diagram?",
+ options: [
+ { label: "The web server connection to the primary Redis cache", ref: "e2" },
+ { label: "The web server connection to the fallback Redis cache", ref: "e3" },
+ { label: "The API gateway connection to the primary application server", ref: "e4" },
+ { label: "The API gateway connection to the secondary application server", ref: "e5" },
+ ],
+ });
+ const { app, page } = await launch([reply(ask)]);
+ try {
+ await modeButton(page, "Command").click();
+ await say(page, "remove the connection");
+ const card = page.locator('.dictation-card[data-card="clarify"]');
+ await expect(card).toBeVisible({ timeout: 20_000 });
+ await app.evaluate(({ BrowserWindow }) => {
+ const win = BrowserWindow.getAllWindows()[0];
+ win?.unmaximize();
+ win?.setSize(820, 760);
+ });
+ await expect.poll(() => page.evaluate(() => window.innerWidth)).toBeLessThanOrEqual(820);
+ await expect(
+ card.getByRole("group", { name: "Choose an answer" }).getByRole("button"),
+ ).toHaveCount(5);
+ await expect(card).toContainText("secondary application server");
+
+ const layout = await card.evaluate((element) => {
+ const text = element.querySelector(".card-text")?.getBoundingClientRect();
+ const actions = element.querySelector(".card-actions")?.getBoundingClientRect();
+ const buttons = [...element.querySelectorAll("button")].map((button) => {
+ const box = button.getBoundingClientRect();
+ return { left: box.left, right: box.right, top: box.top, bottom: box.bottom };
+ });
+ return {
+ contained: element.scrollWidth <= element.clientWidth,
+ separated: Boolean(text && actions && text.bottom <= actions.top),
+ buttons,
+ };
+ });
+ expect(layout.contained).toBe(true);
+ expect(layout.separated).toBe(true);
+ for (let i = 0; i < layout.buttons.length; i++) {
+ for (let j = i + 1; j < layout.buttons.length; j++) {
+ const a = layout.buttons[i];
+ const b = layout.buttons[j];
+ if (!a || !b) continue;
+ const overlaps =
+ a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
+ expect(overlaps).toBe(false);
+ }
+ }
+ } finally {
+ await app.close();
+ }
+});
+
test("'Lucid, ...' in Input mode and holding the right Option key both send one command", async () => {
test.setTimeout(90_000);
const { app, page } = await launch([
diff --git a/apps/desktop/src/dictation/DictationBar.tsx b/apps/desktop/src/dictation/DictationBar.tsx
index 640d9a2..9bfc14a 100644
--- a/apps/desktop/src/dictation/DictationBar.tsx
+++ b/apps/desktop/src/dictation/DictationBar.tsx
@@ -225,7 +225,11 @@ export function DictationBar({
return (
-
+
{status.kind === "working" && (
<>
@@ -313,7 +317,7 @@ export function DictationBar({
{status.question}
Click one, or say “the first one”, “the second one”…
-
+
{status.options.map((o, i) => (
Date: Tue, 20 Oct 2026 00:53:12 +0530
Subject: [PATCH 13/13] feat(command): add conservative fast paths (#45)
---
apps/desktop/e2e/command.spec.ts | 21 ++++++
apps/desktop/src/jobs/queue.test.ts | 11 ++++
apps/desktop/src/jobs/queue.ts | 25 +++++++-
docs/evals.md | 5 +-
packages/evals/src/report.ts | 4 +-
packages/evals/src/run.ts | 6 ++
packages/llm/src/commandPipeline.test.ts | 29 +++++++++
packages/llm/src/commandPipeline.ts | 34 +++++++++-
packages/llm/src/deterministicCommand.test.ts | 47 ++++++++++++++
packages/llm/src/deterministicCommand.ts | 64 +++++++++++++++++++
packages/llm/src/index.ts | 1 +
11 files changed, 239 insertions(+), 8 deletions(-)
create mode 100644 packages/llm/src/deterministicCommand.test.ts
create mode 100644 packages/llm/src/deterministicCommand.ts
diff --git a/apps/desktop/e2e/command.spec.ts b/apps/desktop/e2e/command.spec.ts
index 25ecc2b..925f60f 100644
--- a/apps/desktop/e2e/command.spec.ts
+++ b/apps/desktop/e2e/command.spec.ts
@@ -137,6 +137,27 @@ test("a command crosses out a list item on the page, is saved, and the voice com
}
});
+test("an exact empty-section command skips the model and still supports one-step undo", async () => {
+ test.setTimeout(60_000);
+ const { app, page } = await launch([]);
+ try {
+ await modeButton(page, "Command").click();
+ await say(page, 'create an empty section named "Failure modes"');
+ await expect(
+ page.locator(".page-editor h2").filter({ hasText: "Failure modes" }),
+ ).toBeVisible();
+ await expect(page.getByText("Created empty section “Failure modes”.")).toBeVisible();
+ expect(mock?.requests).toHaveLength(0);
+
+ await page.getByRole("button", { name: "Undo" }).click();
+ await expect(page.locator(".page-editor h2").filter({ hasText: "Failure modes" })).toHaveCount(
+ 0,
+ );
+ } finally {
+ await app.close();
+ }
+});
+
test("a command that throws something away waits for a yes: by button, or by voice", async () => {
test.setTimeout(90_000);
const remove = json({
diff --git a/apps/desktop/src/jobs/queue.test.ts b/apps/desktop/src/jobs/queue.test.ts
index 119807a..260f73a 100644
--- a/apps/desktop/src/jobs/queue.test.ts
+++ b/apps/desktop/src/jobs/queue.test.ts
@@ -359,6 +359,17 @@ describe("JobQueue in Command mode", () => {
const props = (t: Awaited>, id: string) =>
t.editor.doc.blocks.find((b) => b.id === id)?.props;
+ it("routes an exact selected-block command without a model call and records its source", async () => {
+ const t = await withDoc([]);
+ t.editor.cursor = "t1";
+ t.say("delete this block", "typed", "command");
+ await t.settled();
+ expect(t.mock.requests).toHaveLength(0);
+ expect(t.editor.doc.blocks.some((block) => block.id === "t1")).toBe(false);
+ expect(t.last()).toMatchObject({ kind: "done", summary: "Deleted selected block" });
+ expect(t.changes.at(-1)).toMatchObject({ source: "deterministic" });
+ });
+
it("carries out a command on the page and records it", async () => {
const t = await withDoc([reply(strike)]);
t.say("cross out MongoDB", "typed", "command");
diff --git a/apps/desktop/src/jobs/queue.ts b/apps/desktop/src/jobs/queue.ts
index 2445927..bc9ccda 100644
--- a/apps/desktop/src/jobs/queue.ts
+++ b/apps/desktop/src/jobs/queue.ts
@@ -204,7 +204,16 @@ export class JobQueue {
}
throw error;
}
- this.finish(p.job, "applied", p.outcome.summary, undefined, p.outcome.affected, "command");
+ this.finish(
+ p.job,
+ "applied",
+ p.outcome.summary,
+ undefined,
+ p.outcome.affected,
+ "command",
+ undefined,
+ p.outcome.execution === "deterministic" ? "deterministic" : "local-model",
+ );
}
/** The user said no. */
@@ -410,7 +419,16 @@ export class JobQueue {
case "edit": {
editor.apply(outcome.patch);
const notice = outcome.warnings.length > 0 ? outcome.warnings.join(" ") : undefined;
- this.finish(job, "applied", outcome.summary, notice, outcome.affected, "command");
+ this.finish(
+ job,
+ "applied",
+ outcome.summary,
+ notice,
+ outcome.affected,
+ "command",
+ undefined,
+ outcome.execution === "deterministic" ? "deterministic" : "local-model",
+ );
return;
}
case "proposal":
@@ -520,6 +538,7 @@ export class JobQueue {
affected: readonly string[] | undefined,
mode: Mode,
recentText?: string,
+ source = "local-model",
): void {
this.log(job, status);
this.recent = [...this.recent, { mode, text: recentText ?? joined(job) }].slice(-3);
@@ -531,7 +550,7 @@ export class JobQueue {
id: job.utterances[0]?.id ?? (this.options.ids ?? newId)(),
at: new Date(at).toISOString(),
summary,
- source: "local-model",
+ source,
affected: [...(affected ?? [])],
reverted: false,
});
diff --git a/docs/evals.md b/docs/evals.md
index 5449166..f3a5caa 100644
--- a/docs/evals.md
+++ b/docs/evals.md
@@ -15,8 +15,9 @@ The model must be loaded first (`lms load qwen/qwen3.8-27b --context-length 3276
app). A run writes `packages/evals/reports/-/results.json` and `report.md` (pass rates per category against the
bars, and every failure with what was said, what was expected and what the model wrote). `--strict` exits 1 below a bar.
-Each trial also records its provider, model, lane, time to first token, total time, model and repair call counts, final outcome,
-affected blocks, and unrelated blocks changed. Token totals use provider-reported usage when available; otherwise Lucid stores
+Each trial also records its provider, model, lane, execution route (`deterministic`, `model`, or `none`), time to first token,
+total time, model and repair call counts, final outcome, affected blocks, and unrelated blocks changed. Reports count
+deterministic trials separately from model calls. Token totals use provider-reported usage when available; otherwise Lucid stores
character-based estimates and marks them `estimated` rather than presenting them as exact. This measurement data contains
counts and identifiers only—never API keys, full prompts, or document text. Failure examples shown by the eval reporter come
only from this repository's synthetic fixtures and scripted utterances.
diff --git a/packages/evals/src/report.ts b/packages/evals/src/report.ts
index c6d9afd..2bbff12 100644
--- a/packages/evals/src/report.ts
+++ b/packages/evals/src/report.ts
@@ -53,6 +53,7 @@ export interface Summary {
outputTokens: number;
estimatedTokenTrials: number;
modelCalls: number;
+ deterministicTrials: number;
repairCalls: number;
affectedBlocks: number;
unrelatedBlocksModified: number;
@@ -83,6 +84,7 @@ export function summarise(results: Results): Summary {
outputTokens: trials.reduce((n, t) => n + t.outputTokens, 0),
estimatedTokenTrials: trials.filter((t) => t.tokenSource === "estimated").length,
modelCalls: trials.reduce((n, t) => n + t.modelCalls, 0),
+ deterministicTrials: trials.filter((t) => t.execution === "deterministic").length,
repairCalls: trials.reduce((n, t) => n + t.repairCalls, 0),
affectedBlocks: trials.reduce((n, t) => n + t.affectedBlocks, 0),
unrelatedBlocksModified: trials.reduce((n, t) => n + (t.unrelatedBlocksModified ?? 0), 0),
@@ -102,7 +104,7 @@ export function toMarkdown(results: Results): string {
"",
`**Overall ${pct(s.overall.rate)}** (${String(s.overall.pass)} of ${String(s.overall.total)}), needs ${pct(OVERALL_THRESHOLD)}: ${s.overall.ok ? "met" : "NOT met"}. Median ${secs(s.p50Ms)} per trial. Pipeline errors: ${String(s.errors)}; cancellations: ${String(s.cancellations)}.`,
"",
- `Model work: ${String(s.modelCalls)} calls (${String(s.repairCalls)} repairs), ${String(s.promptTokens)} input tokens and ${String(s.outputTokens)} output tokens${s.estimatedTokenTrials > 0 ? `; ${String(s.estimatedTokenTrials)} trial(s) used clearly marked estimates` : ""}. Median first token ${secs(s.p50FirstTokenMs)}.`,
+ `Model work: ${String(s.modelCalls)} calls (${String(s.repairCalls)} repairs), ${String(s.promptTokens)} input tokens and ${String(s.outputTokens)} output tokens${s.estimatedTokenTrials > 0 ? `; ${String(s.estimatedTokenTrials)} trial(s) used clearly marked estimates` : ""}. Median first token ${secs(s.p50FirstTokenMs)}. Deterministic execution: ${String(s.deterministicTrials)} trial(s).`,
"",
`Edit isolation: ${String(s.affectedBlocks)} affected blocks; ${String(s.unrelatedBlocksModified)} unrelated blocks modified in cases that define an allowed change set.`,
"",
diff --git a/packages/evals/src/run.ts b/packages/evals/src/run.ts
index 34503a3..0ed4769 100644
--- a/packages/evals/src/run.ts
+++ b/packages/evals/src/run.ts
@@ -37,6 +37,8 @@ export interface Trial {
unrelatedBlocksModified: number | null;
provider: string;
model: string;
+ /** How the command ran. Optional so older saved eval reports remain comparable. */
+ execution?: "model" | "deterministic" | "none";
/** The model's last answer, as it wrote it. */
raw: string;
/** What Lucid said it did, for a change. */
@@ -73,6 +75,7 @@ export async function runTrial(
let raw = "";
let summary: string | undefined;
let affectedBlocks = 0;
+ let execution: NonNullable = "model";
if (c.mode === "advisor") {
const advice = await runAdvisor(utterance, {
client: deps.client,
@@ -111,6 +114,7 @@ export async function runTrial(
},
{ client: deps.client, lane: deps.lanes?.command ?? DEFAULT_LANES.command },
);
+ execution = out.execution;
calls = out.calls;
raw = out.raw;
const after = out.kind === "edit" || out.kind === "proposal" ? out.next : before;
@@ -158,6 +162,7 @@ export async function runTrial(
: null,
provider: calls.at(-1)?.provider ?? "none",
model: calls.at(-1)?.model ?? "none",
+ execution,
raw,
summary,
};
@@ -185,6 +190,7 @@ export async function runTrial(
unrelatedBlocksModified: null,
provider: "none",
model: "none",
+ execution: "none",
raw: "",
error: message,
};
diff --git a/packages/llm/src/commandPipeline.test.ts b/packages/llm/src/commandPipeline.test.ts
index 9b563ee..2b1a108 100644
--- a/packages/llm/src/commandPipeline.test.ts
+++ b/packages/llm/src/commandPipeline.test.ts
@@ -59,6 +59,35 @@ describe("the few-shot examples", () => {
});
describe("runCommand", () => {
+ it("runs an exact selected-block command without contacting the provider", async () => {
+ const { client, mock } = await setup({ script: [] });
+ const out = await runCommand(job("delete this block", { cursorBlockId: "t1" }), {
+ client: withStructuredOutput(client, "unavailable"),
+ ids: ids(),
+ });
+ expect(out).toMatchObject({
+ kind: "edit",
+ execution: "deterministic",
+ calls: [],
+ summary: "Deleted selected block",
+ });
+ if (out.kind !== "edit") throw new Error(out.kind);
+ expect(out.next.blocks.some((block) => block.id === "t1")).toBe(false);
+ expect(mock.requests).toHaveLength(0);
+ });
+
+ it("falls through to the model for wording outside the exact allowlist", async () => {
+ const { client, mock } = await setup({
+ script: [reply(json({ kind: "none", reason: "ambiguous wording" }))],
+ });
+ const out = await runCommand(job("delete this block please", { cursorBlockId: "t1" }), {
+ client,
+ ids: ids(),
+ });
+ expect(out).toMatchObject({ kind: "none", execution: "model" });
+ expect(mock.requests).toHaveLength(1);
+ });
+
it("uses a prompted-and-validated contract without claiming native schema enforcement", async () => {
const { client, mock } = await setup({
script: [
diff --git a/packages/llm/src/commandPipeline.ts b/packages/llm/src/commandPipeline.ts
index 5bdfb38..2347235 100644
--- a/packages/llm/src/commandPipeline.ts
+++ b/packages/llm/src/commandPipeline.ts
@@ -1,5 +1,6 @@
import {
commandJsonSchema,
+ buildRefMap,
destructiveOps,
diagramLabels,
inventedNumbers,
@@ -17,6 +18,7 @@ import { compileOps, OpError } from "@lucid/kit";
import type { AiClient, ChatMessage, ChatResult } from "./provider";
import { DEFAULT_LANES, type Lane } from "./lanes";
import { buildCommandMessages, endsWithAnswer, type CommandContext } from "./prompts/command";
+import { deterministicCommand } from "./deterministicCommand";
export interface CommandDeps {
client: AiClient;
@@ -31,6 +33,7 @@ export type CommandJob = CommandContext & { vocabulary?: readonly string[] | und
interface Common {
calls: ChatResult[];
raw: string;
+ execution: "deterministic" | "model";
}
interface Change {
@@ -45,7 +48,7 @@ interface Change {
export type CommandOutcome =
/** The selected provider cannot safely produce checked Command operations. No request was sent. */
- | { kind: "unavailable"; message: string; calls: []; raw: "" }
+ | { kind: "unavailable"; message: string; calls: []; raw: ""; execution: "none" }
/** A change, ready to apply. */
| (Common & Change & { kind: "edit" })
/** A change that throws something away without the user having said so: show it and wait for a yes. */
@@ -83,6 +86,32 @@ const promptedSchemaMessage = (schema: { name: string; schema: object }): ChatMe
*/
export async function runCommand(job: CommandJob, deps: CommandDeps): Promise {
const lane = deps.lane ?? DEFAULT_LANES.command;
+ const fast = deterministicCommand(job.doc, job.utterance, job.cursorBlockId);
+ if (fast) {
+ const compiled = compileOps(job.doc, fast.ops, { ids: deps.ids });
+ const change: Change = {
+ summary: fast.summary,
+ ops: fast.ops,
+ patch: compiled.patch,
+ next: compiled.next,
+ affected: compiled.affected,
+ warnings: compiled.warnings,
+ repaired: false,
+ };
+ const common = { calls: [] as ChatResult[], raw: "", execution: "deterministic" as const };
+ const refMap = buildRefMap(job.doc);
+ const destructive = destructiveOps(fast.ops, {
+ textOf: (ref) => {
+ const resolved = refMap.resolve(ref);
+ if (!resolved) return undefined;
+ const ids = resolved.kind === "list" ? resolved.ids : [resolved.id];
+ return plainTextOfBlocks(job.doc.blocks.filter((block) => ids.includes(block.id)));
+ },
+ });
+ if (needsConfirmation(job.utterance, destructive))
+ return { kind: "proposal", destructive, ...change, ...common };
+ return { kind: "edit", ...change, ...common };
+ }
const { messages, refMap } = buildCommandMessages(job);
const calls: ChatResult[] = [];
// Once the person has answered a question, the model must act: "clarify" is not an answer it can give.
@@ -97,6 +126,7 @@ export async function runCommand(job: CommandJob, deps: CommandDeps): Promise {
+ it("maps exact selected-block commands to stable document refs", () => {
+ expect(deterministicCommand(page, "delete this block", "redis")?.ops).toEqual([
+ { op: "delete", target: "b3" },
+ ]);
+ expect(deterministicCommand(page, "Move this block up.", "redis")?.ops).toEqual([
+ { op: "move", target: "b3", to: { before: "b2" } },
+ ]);
+ expect(deterministicCommand(page, "move this block down", "caching")?.ops).toEqual([
+ { op: "move", target: "b2", to: { after: "b3" } },
+ ]);
+ });
+
+ it("creates only an explicitly quoted, safe empty section", () => {
+ expect(deterministicCommand(page, 'create an empty section named "Failure modes"')).toEqual({
+ ops: [{ op: "insert", at: { end: true }, lmd: "## Failure modes" }],
+ summary: "Created empty section “Failure modes”",
+ });
+ });
+
+ it.each([
+ ["delete this block please", "redis"],
+ ["delete the block", "redis"],
+ ["move this block up", "title"],
+ ["move this block down", "redis"],
+ ["move this block up", "nested-or-missing"],
+ ["create an empty section named Failure modes", undefined],
+ ['create an empty section named "# injected"', undefined],
+ ])("leaves a near miss for the model: %s", (utterance, cursor) => {
+ expect(deterministicCommand(page, utterance, cursor)).toBeUndefined();
+ });
+
+ it("keeps references stable after routing", () => {
+ expect(buildRefMap(page).refOf("redis")).toBe("b3");
+ });
+});
diff --git a/packages/llm/src/deterministicCommand.ts b/packages/llm/src/deterministicCommand.ts
new file mode 100644
index 0000000..869f4e6
--- /dev/null
+++ b/packages/llm/src/deterministicCommand.ts
@@ -0,0 +1,64 @@
+import { buildRefMap, type LucidDoc, type Op } from "@lucid/core";
+
+export interface DeterministicCommand {
+ ops: Op[];
+ summary: string;
+}
+
+const exact = (utterance: string): string =>
+ utterance
+ .trim()
+ .replace(/[.!?]$/, "")
+ .toLowerCase();
+
+/**
+ * Commands that are safe to interpret without a model. The grammar is intentionally tiny: anything
+ * outside these exact forms falls through to the normal checked model pipeline.
+ */
+export function deterministicCommand(
+ doc: LucidDoc,
+ utterance: string,
+ cursorBlockId?: string,
+): DeterministicCommand | undefined {
+ const words = exact(utterance);
+ const selected = cursorBlockId ? doc.blocks.findIndex((block) => block.id === cursorBlockId) : -1;
+ const refs = buildRefMap(doc);
+ const target = selected >= 0 && cursorBlockId ? refs.refOf(cursorBlockId) : undefined;
+
+ if (words === "delete this block") {
+ return target
+ ? { ops: [{ op: "delete", target }], summary: "Deleted selected block" }
+ : undefined;
+ }
+
+ if (words === "move this block up") {
+ if (!target || selected <= 0) return undefined;
+ const before = refs.refOf(doc.blocks[selected - 1]?.id ?? "");
+ return before
+ ? { ops: [{ op: "move", target, to: { before } }], summary: "Moved selected block up" }
+ : undefined;
+ }
+
+ if (words === "move this block down") {
+ if (!target || selected < 0 || selected >= doc.blocks.length - 1) return undefined;
+ const after = refs.refOf(doc.blocks[selected + 1]?.id ?? "");
+ return after
+ ? { ops: [{ op: "move", target, to: { after } }], summary: "Moved selected block down" }
+ : undefined;
+ }
+
+ const section = /^create an empty section named "([^"]{1,80})"[.!?]?$/iu.exec(utterance.trim());
+ const title = section?.[1];
+ if (
+ title &&
+ title.trim() === title &&
+ /^[\p{L}\p{N}][\p{L}\p{N} &'’()+,./:-]{0,79}$/u.test(title)
+ ) {
+ return {
+ ops: [{ op: "insert", at: { end: true }, lmd: `## ${title}` }],
+ summary: `Created empty section “${title}”`,
+ };
+ }
+
+ return undefined;
+}
diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts
index 5cbe66d..0eb3326 100644
--- a/packages/llm/src/index.ts
+++ b/packages/llm/src/index.ts
@@ -9,5 +9,6 @@ export * from "./prompts/input";
export * from "./input";
export * from "./prompts/command";
export * from "./commandPipeline";
+export * from "./deterministicCommand";
export * from "./advisor";
export { ADVISOR_EXAMPLES, advisorSystemPrompt } from "./prompts/advisor";