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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,19 @@ Versions follow [SemVer](https://semver.org/) (`0.1.0-alpha.x` while the public
- Dedicated **Desktop Installers** and **Release** GitHub Actions workflows
- Public-release checklist (`docs/PUBLIC_RELEASE.md`) and clearer vulnerability reporting guidance
- Default **PE Engineering Career Framework** (Graduate → CTO) seeded on empty and demo workspaces
- **Cursor Cloud Agents** AI provider — use dashboard `crsr_…` keys via the Cloud Agents API (no-repo agents for digests/drafts)

### Changed

- Settings → Check for updates explains missing GitHub Releases (404) instead of a generic failure
- MCP / AGENTS docs no longer imply a silent `PRM_PASSWORD=workbench` default
- Settings → AI: OpenAI-compatible gateways are a separate provider from Cursor Cloud Agents

### Fixed

- Committed Tauri `gen/schemas/capabilities.json` aligned with source capabilities (no `shell:allow-open`)
- Windows packaging uses NSIS only — WiX `.msi` rejects semver pre-releases like `0.1.0-alpha.1`
- Desktop AI: stop implying Cursor `crsr_` keys are a chat API; clearer network errors; Test saves then probes with visible status
- Desktop AI: clearer network errors; Test saves then probes with visible status
- Empty-workspace onboarding: optional EM name at create, single setup checklist, less Home clutter
- Settings / long-page scroll jank: section nav no longer forces layout on scroll; drop sticky chrome backdrop blur

Expand Down
105 changes: 104 additions & 1 deletion apps/api/src/ai.cursor.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { defaultCursorBaseUrl, resolveCursorBaseUrl } from "../src/ai.js";
import { defaultCursorBaseUrl, normalizeAiProvider, resolveCursorBaseUrl } from "../src/ai.js";
import { formatCloudAgentPrompt, runCursorCloudAgent } from "../src/cursorCloud.js";

describe("cursor / openai-compatible provider base URL", () => {
it("does not invent a silent local gateway when unset", () => {
Expand Down Expand Up @@ -34,3 +35,105 @@ describe("cursor / openai-compatible provider base URL", () => {
}
});
});

describe("normalizeAiProvider", () => {
it("maps bare cursor to Cloud Agents", () => {
assert.equal(normalizeAiProvider("cursor", null), "cursor");
});

it("maps legacy cursor + gateway to openai_compatible", () => {
assert.equal(normalizeAiProvider("cursor", "https://gw.example/v1"), "openai_compatible");
});

it("keeps explicit openai_compatible", () => {
assert.equal(normalizeAiProvider("openai_compatible", "https://gw.example/v1"), "openai_compatible");
});
});

describe("Cursor Cloud Agents client", () => {
it("formats a grounded prompt", () => {
const text = formatCloudAgentPrompt(
[
{ role: "system", content: "Cite ids." },
{ role: "user", content: "Summarize [ach_1]." },
],
"evidence_digest",
);
assert.match(text, /feature: evidence_digest/);
assert.match(text, /=== SYSTEM ===/);
assert.match(text, /Cite ids/);
assert.match(text, /Summarize \[ach_1\]/);
});

it("creates a no-repo agent, polls to FINISHED, archives", async () => {
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
let poll = 0;
const fetchFn: typeof fetch = async (input, init) => {
const url = String(input);
const method = (init?.method ?? "GET").toUpperCase();
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
calls.push({ url, method, body });

if (method === "POST" && url.endsWith("/agents")) {
assert.equal(body.prompt.text.includes("ping"), true);
assert.equal(body.repos, undefined);
return new Response(
JSON.stringify({
agent: {
id: "bc-1",
url: "https://cursor.com/agents/bc-1",
latestRunId: "run-1",
},
run: { id: "run-1", agentId: "bc-1", status: "CREATING" },
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (method === "GET" && url.includes("/runs/run-1")) {
poll += 1;
const status = poll === 1 ? "RUNNING" : "FINISHED";
return new Response(
JSON.stringify({
id: "run-1",
agentId: "bc-1",
status,
result: status === "FINISHED" ? "OK from cloud" : null,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (method === "POST" && url.endsWith("/archive")) {
return new Response(JSON.stringify({ id: "bc-1", status: "ARCHIVED" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response("unexpected", { status: 500 });
};

let t = 0;
const result = await runCursorCloudAgent(
{
apiKey: "crsr_test",
messages: [{ role: "user", content: "ping" }],
feature: "evidence_digest",
model: "composer-2",
pollMs: 1,
timeoutMs: 5_000,
},
{
fetchFn,
sleep: async () => undefined,
now: () => {
t += 10;
return t;
},
},
);

assert.equal(result.text, "OK from cloud");
assert.equal(result.model, "cursor-cloud:composer-2");
assert.equal(result.agentId, "bc-1");
assert.ok(calls.some((c) => c.method === "POST" && c.url.endsWith("/archive")));
});
});
102 changes: 85 additions & 17 deletions apps/api/src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm";
import { aiSettings } from "@prm/db";
import { decryptSecret, getDb, getWorkspaceSecret, id, logActivity, nowIso } from "./store.js";
import { aiGenerations } from "@prm/db";
import { CURSOR_CLOUD_API_BASE, cursorCloudMe, runCursorCloudAgent } from "./cursorCloud.js";

export type AiFeature =
| "evidence_digest"
Expand All @@ -17,6 +18,9 @@ export type AiFeature =

export type ChatMessage = { role: "system" | "user" | "assistant"; content: string };

/** Providers stored in ai_settings.provider (plus legacy aliases). */
export type AiProviderId = "anthropic" | "openai" | "cursor" | "openai_compatible" | "ollama";

function formatFetchFailure(label: string, baseUrl: string, err: unknown): Error {
const detail = err instanceof Error ? err.message : String(err);
const cause =
Expand All @@ -30,9 +34,7 @@ function formatFetchFailure(label: string, baseUrl: string, err: unknown): Error
/fetch failed|ECONNREFUSED|ENOTFOUND|ECONNRESET|network|socket|timed out/i.test(combined);
if (isNetwork) {
return new Error(
`${label} could not reach ${baseUrl} (${combined || "network error"}). ` +
`Cursor dashboard keys (crsr_…) are for Cloud Agents — they are not a chat API. ` +
`Use Anthropic, OpenAI, or Ollama, or point this field at a running OpenAI-compatible /v1 gateway.`,
`${label} could not reach ${baseUrl} (${combined || "network error"}). Check the URL and that the service is running.`,
);
}
return new Error(`${label} request failed: ${combined || "unknown error"}`);
Expand Down Expand Up @@ -137,11 +139,43 @@ export function resolveCursorBaseUrl(stored: string | null | undefined): string
return defaultCursorBaseUrl();
}

/**
* Normalize stored provider id.
* Legacy: `provider=cursor` + a custom gateway URL meant OpenAI-compatible chat —
* map that to `openai_compatible`. Bare `cursor` means Cloud Agents (`crsr_` keys).
*/
export function normalizeAiProvider(
stored: string | null | undefined,
gatewayBaseUrl: string | null,
): AiProviderId {
const raw = (stored ?? "anthropic").trim();
if (raw === "openai_compatible") return "openai_compatible";
if (raw === "openai") return "openai";
if (raw === "ollama") return "ollama";
if (raw === "anthropic") return "anthropic";
if (raw === "cursor") {
if (gatewayBaseUrl) return "openai_compatible";
return "cursor";
}
return "anthropic";
}

function defaultModelsFor(provider: AiProviderId): { digest: string; draft: string } {
if (provider === "cursor") return { digest: "", draft: "" }; // Cursor account default
if (provider === "openai" || provider === "openai_compatible") {
return { digest: "gpt-4o-mini", draft: "gpt-4o-mini" };
}
if (provider === "ollama") return { digest: "llama3.1", draft: "llama3.1" };
return { digest: "claude-sonnet-4-5", draft: "claude-sonnet-4-5" };
}

export function getAiConfig() {
const db = getDb();
const row = db.select().from(aiSettings).where(eq(aiSettings.id, "default")).all()[0];
const secret = getWorkspaceSecret();
const provider = row?.provider ?? "anthropic";
const gatewayBaseUrl = resolveCursorBaseUrl(row?.ollamaBaseUrl);
const provider = normalizeAiProvider(row?.provider, gatewayBaseUrl);
const defaults = defaultModelsFor(provider);
let apiKey: string | null = null;
if (row?.apiKeyEncrypted && secret) {
try {
Expand All @@ -150,23 +184,42 @@ export function getAiConfig() {
apiKey = null;
}
}
if (!apiKey && provider === "cursor") {
if (!apiKey && (provider === "cursor" || provider === "openai_compatible")) {
apiKey = process.env.CURSOR_API_KEY?.trim() || null;
}
return {
enabled: Boolean(row?.enabled),
/** Effective provider after legacy migration. */
provider,
modelDigest: row?.modelDigest ?? (provider === "cursor" ? "gpt-4o-mini" : "claude-sonnet-4-5"),
modelDraft: row?.modelDraft ?? (provider === "cursor" ? "gpt-4o-mini" : "claude-sonnet-4-5"),
/** Raw value in DB (may still be legacy `cursor` + gateway). */
storedProvider: row?.provider ?? "anthropic",
modelDigest: row?.modelDigest ?? defaults.digest,
modelDraft: row?.modelDraft ?? defaults.draft,
localOnly: Boolean(row?.localOnly),
ollamaBaseUrl: row?.ollamaBaseUrl ?? DEFAULT_OLLAMA_BASE,
cursorBaseUrl: resolveCursorBaseUrl(row?.ollamaBaseUrl),
/** OpenAI-compatible gateway base (null when using Cloud Agents / Anthropic / etc.). */
openaiCompatibleBaseUrl: provider === "openai_compatible" ? gatewayBaseUrl : null,
/** @deprecated alias — same as openaiCompatibleBaseUrl */
cursorBaseUrl: provider === "openai_compatible" ? gatewayBaseUrl : null,
privateNotesEgress: Boolean(row?.privateNotesEgress),
meetingSummaryEgress: Boolean(row?.meetingSummaryEgress),
documentExtractEgress: Boolean(row?.documentExtractEgress),
apiKey,
hasApiKey: Boolean(apiKey),
apiKeyFromEnv: provider === "cursor" && !row?.apiKeyEncrypted && Boolean(process.env.CURSOR_API_KEY?.trim()),
apiKeyFromEnv:
(provider === "cursor" || provider === "openai_compatible") &&
!row?.apiKeyEncrypted &&
Boolean(process.env.CURSOR_API_KEY?.trim()),
};
}

export async function probeCursorCloudKey(apiKey: string) {
const me = await cursorCloudMe(apiKey);
return {
ok: true as const,
model: `cursor-cloud:${me.apiKeyName ?? "api-key"}`,
sample: me.userEmail ? `Authenticated as ${me.userEmail}` : `Key OK (${me.apiKeyName ?? "unnamed"})`,
baseUrlTried: CURSOR_CLOUD_API_BASE,
};
}

Expand All @@ -183,30 +236,45 @@ export async function runChat(feature: AiFeature, messages: ChatMessage[]) {
if (!cfg.apiKey) {
throw new Error(
cfg.provider === "cursor"
? "No Cursor API key — paste one in Settings or set CURSOR_API_KEY"
: "No API key configured",
? "No Cursor API key — paste a crsr_… key from cursor.com/dashboard/api (or set CURSOR_API_KEY)"
: cfg.provider === "openai_compatible"
? "No API key — paste a gateway key in Settings or set CURSOR_API_KEY"
: "No API key configured",
);
}
const model = feature === "evidence_digest" ? cfg.modelDigest : cfg.modelDraft;
if (cfg.provider === "openai") {
const text = await callOpenAI(cfg.apiKey, model || "gpt-4o-mini", messages);
return { text, model: `openai:${model}`, provider: "openai" as const };
}
if (cfg.provider === "cursor") {
if (!cfg.cursorBaseUrl) {
if (cfg.provider === "openai_compatible") {
if (!cfg.openaiCompatibleBaseUrl) {
throw new Error(
"OpenAI-compatible base URL required. Cursor dashboard API keys (crsr_…) talk to Cloud Agents, not /v1/chat/completions. " +
"Prefer Anthropic, OpenAI, or Ollama — or set a gateway URL that implements OpenAI Chat Completions.",
"OpenAI-compatible base URL required. Set a gateway that implements /v1/chat/completions, " +
"or switch provider to Cursor Cloud Agents to use a crsr_… dashboard key.",
);
}
const text = await callOpenAI(
cfg.apiKey,
model || "gpt-4o-mini",
messages,
cfg.cursorBaseUrl,
cfg.openaiCompatibleBaseUrl,
"OpenAI-compatible",
);
return { text, model: `cursor:${model || "gpt-4o-mini"}`, provider: "cursor" as const };
return {
text,
model: `openai_compatible:${model || "gpt-4o-mini"}`,
provider: "openai_compatible" as const,
};
}
if (cfg.provider === "cursor") {
const result = await runCursorCloudAgent({
apiKey: cfg.apiKey,
messages,
feature,
model: model || null,
});
return { text: result.text, model: result.model, provider: "cursor" as const };
}
const text = await callAnthropic(cfg.apiKey, model || "claude-sonnet-4-5", messages, maxTokens);
return { text, model: `anthropic:${model}`, provider: "anthropic" as const };
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/aiEstimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const RATES: Record<string, { input: number; output: number } | null> = {
anthropic: { input: 3, output: 15 },
openai: { input: 0.15, output: 0.6 },
cursor: null,
openai_compatible: null,
ollama: null,
};

Expand Down Expand Up @@ -47,7 +48,7 @@ export function buildAiEstimate(opts: {
const model =
opts.feature === "evidence_digest"
? cfg.modelDigest
: cfg.modelDraft || (provider === "cursor" ? "composer-2.5" : "claude-sonnet-4-5");
: cfg.modelDraft || (provider === "cursor" ? "default" : "claude-sonnet-4-5");
const inputTokensApprox = estimateTokensFromMessages(opts.messages);
const outputTokensMax = maxOutputTokensForFeature(opts.feature);
const costUsdApprox = estimateCostUsd({
Expand All @@ -66,7 +67,7 @@ export function buildAiEstimate(opts: {
dataClassesSent: opts.dataClassesSent ?? [],
note:
costUsdApprox == null
? "Token estimate only — Cursor/Ollama pricing is not modeled."
? "Token estimate only — Cursor Cloud Agents / OpenAI-compatible / Ollama pricing is not modeled."
: "Approximate list-price estimate; actual bill depends on provider discounts and true tokenizer.",
};
}
Expand Down
Loading