diff --git a/changelogs/v2.6.12.md b/changelogs/v2.6.12.md new file mode 100644 index 000000000..483ae1d3c --- /dev/null +++ b/changelogs/v2.6.12.md @@ -0,0 +1,18 @@ +## What's new + +### Added + +- **Chat personalities — shape how the AI talks to you.** Pick a personality next to the model selector in the chat input to layer behavioral rules onto the system prompt: *Caveman* (short, plain words), *Grill Me* (stress-tests your plan with calibrated questions), *Junior to Senior* (adversarial senior review), *Last 20%* (finishes the experiential layer), *Ponytail* (the lazy senior dev — write only what's needed), and more. The full prompt is shown before you install, so you always know exactly what's being added. + - **Default is unchanged** — with no personality selected the assistant behaves exactly as before; personalities are an additive style layer, never a replacement identity. + - **Browse + install** from the community catalog in Settings → AI & Chat → Chat personality, or create your own behavioral rules in a couple of clicks. + +- **Writing Style — your voice, generated.** Set up in Settings → Writing Style: a short guided session asks who you are, lets you paste a few real messages (or analyse your recent notes & tasks), and builds a **full style guide** plus a condensed **cheat sheet**. Both are editable before saving. + - **Draft in your voice everywhere.** Chat and the coding agent can call `get_user_writing_style` when asked to write as you — emails, replies, notes, PRDs. The tool returns the cheat sheet by default and the full guide on request, and is only fetched when actually drafting (never injected into every prompt). + +### Changed + +- **The coding agent's system prompt is leaner.** Removed the redundant tool-listing sections (every tool is already defined in the tool array the model receives) — about 320 tokens saved per turn on the coding agent, with tool selection verified as at least as accurate in a live A/B against the same model. + +### Fixed + +- **Mermaid subgraph labels are readable again in dark mode.** Subgraph (cluster) titles now get their colour computed from the actual fill the diagram uses — so when an LLM-generated chart paints a light pastel background, the label flips to dark text instead of inheriting the theme's light text and becoming invisible. diff --git a/electron/db/queries.ts b/electron/db/queries.ts index e957326ec..7c662e676 100644 --- a/electron/db/queries.ts +++ b/electron/db/queries.ts @@ -1823,3 +1823,8 @@ export * from "./codebase-queries"; // Note + task embedding queries live in embeddings-queries.ts; re-exported // here so existing `./queries` / `../db/queries` imports are unchanged. export * from "./embeddings-queries"; + +// ── User writing style ──────────────────────── +// Persona + style guide queries live in user-style-queries.ts; re-exported +// here so tools/ipc can import from the single `./db/queries` surface. +export * from "./user-style-queries"; diff --git a/electron/db/schema.ts b/electron/db/schema.ts index 38b9e3256..db404a477 100644 --- a/electron/db/schema.ts +++ b/electron/db/schema.ts @@ -1007,6 +1007,24 @@ const MIGRATIONS: Migration[] = [ CREATE INDEX IF NOT EXISTS idx_pi_todos_session ON pi_session_todos(session_id); `); }, + + // v41: Single-row user writing-style table — the user's persona, full style + // guide, and condensed cheat sheet. App-global (not project/workspace-scoped): + // read by the get_user_writing_style tool (chat + agent) and editable via + // Settings → Writing Style. `id` is a fixed constant ("global"); the row is + // upserted, never inserted twice. + (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS user_style ( + id TEXT PRIMARY KEY, + persona_json TEXT, + full_guide TEXT, + cheatsheet TEXT, + source TEXT NOT NULL DEFAULT 'none', + updated_at TEXT NOT NULL + ); + `); + }, ]; export function applySchema(db: Database.Database): void { diff --git a/electron/db/usage-queries.ts b/electron/db/usage-queries.ts index 67480ea3d..561022930 100644 --- a/electron/db/usage-queries.ts +++ b/electron/db/usage-queries.ts @@ -23,7 +23,8 @@ export type UsageSource = | "explain" | "flow-ai-summary" | "summary" - | "tool-builder"; + | "tool-builder" + | "writing-style"; /** Human label for a source, used by the renderer (kept here so it never drifts). */ export const USAGE_SOURCE_LABELS: Record = { @@ -39,6 +40,7 @@ export const USAGE_SOURCE_LABELS: Record = { "flow-ai-summary": "Idea Flow summary", summary: "Compaction", "tool-builder": "Tool builder", + "writing-style": "Writing style", }; export interface LlmUsageRecord { diff --git a/electron/db/user-style-queries.test.ts b/electron/db/user-style-queries.test.ts new file mode 100644 index 000000000..8d1d24989 --- /dev/null +++ b/electron/db/user-style-queries.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import Database from "better-sqlite3"; +import { applySchema } from "./schema"; +import { getUserStyle, saveUserStyle, clearUserStyle, USER_STYLE_ID } from "./user-style-queries"; + +function makeDb() { + const db = new Database(":memory:"); + applySchema(db); + return db; +} + +describe("user-style queries", () => { + it("returns null before anything is saved", () => { + const db = makeDb(); + expect(getUserStyle(db)).toBeNull(); + db.close(); + }); + + it("upserts a single global row with parsed persona", () => { + const db = makeDb(); + const saved = saveUserStyle(db, { + persona: { name: "Gerard", role: "Engineering lead" }, + fullGuide: "## 1. Voice in one line\nWarm and precise.", + cheatsheet: "Warm and precise.", + source: "guided", + }); + expect(saved.id).toBe(USER_STYLE_ID); + expect(saved.persona?.name).toBe("Gerard"); + expect(saved.fullGuide).toContain("Voice in one line"); + expect(saved.cheatsheet).toContain("Warm and precise."); + + // Re-saving preserves fields not supplied. + const updated = saveUserStyle(db, { cheatsheet: "Updated.", source: "guided" }); + expect(updated.fullGuide).toContain("Voice in one line"); + expect(updated.cheatsheet).toBe("Updated."); + db.close(); + }); + + it("parses persona defensively (bad JSON → null)", () => { + const db = makeDb(); + saveUserStyle(db, { persona: { name: "A" }, fullGuide: "", cheatsheet: "", source: "guided" }); + db.prepare("UPDATE user_style SET persona_json = ?").run("not json{"); + expect(getUserStyle(db)?.persona).toBeNull(); + db.close(); + }); + + it("clearUserStyle removes the row", () => { + const db = makeDb(); + saveUserStyle(db, { persona: { name: "A" }, fullGuide: "g", cheatsheet: "c", source: "guided" }); + clearUserStyle(db); + expect(getUserStyle(db)).toBeNull(); + db.close(); + }); +}); diff --git a/electron/db/user-style-queries.ts b/electron/db/user-style-queries.ts new file mode 100644 index 000000000..d5f70048d --- /dev/null +++ b/electron/db/user-style-queries.ts @@ -0,0 +1,97 @@ +/** + * User writing-style queries — the single-row `user_style` table (persona, + * full style guide, condensed cheat sheet). App-global: read by the + * get_user_writing_style tool (chat + agent) and edited via Settings → + * Writing Style. The row is keyed by a fixed constant and upserted. + */ + +import type Database from "better-sqlite3"; +import { ts } from "./utils"; + +export const USER_STYLE_ID = "global"; + +export type UserStyleSource = "none" | "guided" | "manual" | "analyzed"; + +export interface UserStylePersona { + name?: string; + role?: string; + context?: string; + audiences?: string; +} + +export interface UserStyleRow { + id: string; + /** Serialized UserStylePersona JSON (parse defensively; absent = null). */ + persona: UserStylePersona | null; + /** The long, section-structured writing style guide (markdown). */ + fullGuide: string; + /** The condensed one-page cheat sheet (markdown). */ + cheatsheet: string; + /** How the guide was produced: guided wizard / manual / analyzed / none. */ + source: UserStyleSource; + updatedAt: string; +} + +interface UserStyleRowRaw { + id: string; + persona_json: string | null; + full_guide: string | null; + cheatsheet: string | null; + source: string; + updated_at: string; +} + +function toUserStyle(row: UserStyleRowRaw): UserStyleRow { + let persona: UserStylePersona | null = null; + if (row.persona_json) { + try { + persona = JSON.parse(row.persona_json) as UserStylePersona; + } catch { + persona = null; + } + } + return { + id: row.id, + persona, + fullGuide: row.full_guide ?? "", + cheatsheet: row.cheatsheet ?? "", + source: (row.source ?? "none") as UserStyleSource, + updatedAt: row.updated_at, + }; +} + +export function getUserStyle(db: Database.Database): UserStyleRow | null { + const row = db.prepare("SELECT * FROM user_style WHERE id = ?").get(USER_STYLE_ID) as UserStyleRowRaw | undefined; + return row ? toUserStyle(row) : null; +} + +export interface UserStyleSaveInput { + persona?: UserStylePersona; + fullGuide?: string; + cheatsheet?: string; + source: UserStyleSource; +} + +/** Upsert the single global row. Absent fields preserve the existing value. */ +export function saveUserStyle(db: Database.Database, input: UserStyleSaveInput): UserStyleRow { + const existing = getUserStyle(db); + const personaJson = input.persona !== undefined ? JSON.stringify(input.persona) : (existing?.persona ? JSON.stringify(existing.persona) : null); + const fullGuide = input.fullGuide !== undefined ? input.fullGuide : (existing?.fullGuide ?? ""); + const cheatsheet = input.cheatsheet !== undefined ? input.cheatsheet : (existing?.cheatsheet ?? ""); + const now = ts(); + db.prepare(` + INSERT INTO user_style (id, persona_json, full_guide, cheatsheet, source, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + persona_json = excluded.persona_json, + full_guide = excluded.full_guide, + cheatsheet = excluded.cheatsheet, + source = excluded.source, + updated_at = excluded.updated_at + `).run(USER_STYLE_ID, personaJson, fullGuide, cheatsheet, input.source, now); + return getUserStyle(db)!; +} + +export function clearUserStyle(db: Database.Database): void { + db.prepare("DELETE FROM user_style WHERE id = ?").run(USER_STYLE_ID); +} diff --git a/electron/ipc/chat-executor.test.ts b/electron/ipc/chat-executor.test.ts index 4793166f1..1111cf85a 100644 --- a/electron/ipc/chat-executor.test.ts +++ b/electron/ipc/chat-executor.test.ts @@ -20,6 +20,7 @@ import { describe, it, expect } from "vitest"; import BetterSqlite3 from "better-sqlite3"; import type Database from "better-sqlite3"; import { applySchema } from "../db/schema"; +import * as q from "../db/queries"; import { createWorkspace, createProject, createNote, updateNote, createColumn, createCard, getCardById, getNoteById, findLiveNoteByTitle, @@ -1355,3 +1356,64 @@ describe("get_project_context_pack", () => { expect(result).toHaveProperty("error"); }); }); + +// ── get_user_writing_style ──────────────────────────────────────────────────── + +describe("get_user_writing_style", () => { + it("reports configured:false when no style is set up", async () => { + const db = makeDb(); + seed(db); + const result = await exec(db, "get_user_writing_style", {}) as Record; + expect(result.configured).toBe(false); + expect(result.markdown).toBeNull(); + }); + + it("returns the cheat sheet by default and the full guide on request", async () => { + const db = makeDb(); + seed(db); + q.saveUserStyle(db, { + persona: { name: "Gerard" }, + fullGuide: "## 1. Voice in one line\nWarm and precise.", + cheatsheet: "Warm and precise.", + source: "guided", + }); + + const def = await exec(db, "get_user_writing_style", {}) as Record; + expect(def.configured).toBe(true); + expect(def.markdown).toBe("Warm and precise."); + expect(def.persona).toEqual({ name: "Gerard" }); + + const full = await exec(db, "get_user_writing_style", { mode: "full" }) as Record; + expect(full.markdown).toContain("Voice in one line"); + }); + + it("falls back to the full guide when only it exists (cheatsheet requested)", async () => { + const db = makeDb(); + seed(db); + q.saveUserStyle(db, { + persona: { name: "Gerard" }, + fullGuide: "## 1. Voice in one line\nWarm and precise.", + cheatsheet: "", + source: "guided", + }); + const def = await exec(db, "get_user_writing_style", {}) as Record; + expect(def.configured).toBe(true); + expect(def.mode).toBe("cheatsheet"); + expect(def.markdown).toContain("Voice in one line"); + }); + + it("falls back to the cheat sheet when only it exists (full requested)", async () => { + const db = makeDb(); + seed(db); + q.saveUserStyle(db, { + persona: { name: "Gerard" }, + fullGuide: "", + cheatsheet: "Warm and precise.", + source: "guided", + }); + const full = await exec(db, "get_user_writing_style", { mode: "full" }) as Record; + expect(full.configured).toBe(true); + expect(full.mode).toBe("full"); + expect(full.markdown).toBe("Warm and precise."); + }); +}); diff --git a/electron/ipc/chat-executor.ts b/electron/ipc/chat-executor.ts index 5e79c71cc..745389ce5 100644 --- a/electron/ipc/chat-executor.ts +++ b/electron/ipc/chat-executor.ts @@ -286,6 +286,32 @@ export async function executeTool( return { tasksCreated: createdCards.length, tasks: createdCards, noteId: args.noteId }; } + + case "get_user_writing_style": { + const mode = (args.mode as "cheatsheet" | "full" | undefined) ?? "cheatsheet"; + const style = q.getUserStyle(db); + const configured = !!style && style.source !== "none" && !!(style.fullGuide || style.cheatsheet); + if (!configured) { + return { + configured: false, + message: "No writing style set up yet — the user hasn't configured one in Settings → Writing Style. Draft in the user's natural, clear voice; do not invent a style guide.", + mode, + markdown: null, + persona: null, + updatedAt: null, + }; + } + return { + configured: true, + mode, + // Fall back to the alternate guide when the requested one is empty + // (the wizard permits saving either guide alone). + markdown: mode === "full" ? style.fullGuide || style.cheatsheet : style.cheatsheet || style.fullGuide, + persona: style.persona, + updatedAt: style.updatedAt, + }; + } + default: return { error: `Unknown tool: ${name}` }; } diff --git a/electron/ipc/chat.ts b/electron/ipc/chat.ts index 633d5d02c..e76437f7c 100644 --- a/electron/ipc/chat.ts +++ b/electron/ipc/chat.ts @@ -12,7 +12,7 @@ import { registerIpcHandle, registerIpcOn, broadcastEvent } from "./registry"; import { broadcastToChat } from "../chat-popout"; import type Database from "better-sqlite3"; import { isLocalEndpoint, normaliseBaseUrl, type OpenAIMessage, calculatePromptBreakdown, scaleBreakdown, type TokenBreakdown, isSendableMessage } from "../lib/llm"; -import { TOOLS, buildSystemPrompt, type ChatRequest } from "../lib/tools"; +import { TOOLS, buildSystemPrompt, withPersonality, type ChatRequest } from "../lib/tools"; import { getExternalToolDefs } from "../lib/external-tools"; import { getCachedConfig, cacheLlmConnection } from "../lib/config-cache"; import { runToolLoop } from "../lib/chat-loop"; @@ -157,10 +157,15 @@ export function registerChatHandler(db: Database.Database, workspacePath: string } as unknown as OpenAIMessage) : { role: "user", content: req.message }; + // Compose the system prompt ONCE — buildSystemPrompt embeds the current + // date, so recomputing it later (the token-usage breakdown) could measure a + // different string if a turn crosses midnight. + const systemContent = withPersonality(buildSystemPrompt(req), req.personality); + const messages: OpenAIMessage[] = [ { role: resolveSystemRole({ isReasoningModel: req.config?.isReasoningModel, baseUrl, provider, modelId: model }), - content: buildSystemPrompt(req), + content: systemContent, }, ...(req.history ?? []) // Drop assistant turns that carry neither content nor tool_calls — a @@ -236,7 +241,7 @@ export function registerChatHandler(db: Database.Database, workspacePath: string costUsd = (costUsd ?? 0) + cost; } try { - const rawBreakdown = calculatePromptBreakdown(buildSystemPrompt(req), messages, allTools); + const rawBreakdown = calculatePromptBreakdown(systemContent, messages, allTools); lastBreakdown = scaleBreakdown(rawBreakdown, promptTokens); } catch (err) { console.error("[chat] failed to calculate breakdown:", err); diff --git a/electron/ipc/community-registry-handlers.ts b/electron/ipc/community-registry-handlers.ts index 7d478e682..5fad45432 100644 --- a/electron/ipc/community-registry-handlers.ts +++ b/electron/ipc/community-registry-handlers.ts @@ -17,6 +17,8 @@ import { refreshProvidersManifest, fetchAutomationsManifest, refreshAutomationsManifest, + fetchPersonalitiesManifest, + refreshPersonalitiesManifest, } from "../lib/community-registry"; export function registerCommunityRegistryHandlers(): void { @@ -29,4 +31,7 @@ export function registerCommunityRegistryHandlers(): void { // Automations live in a SEPARATE manifest (automations.json), same rationale. registerIpcHandle("registry:fetchAutomations", () => handle(() => fetchAutomationsManifest())); registerIpcHandle("registry:refreshAutomations", () => handle(() => refreshAutomationsManifest())); + // Personalities live in a SEPARATE manifest (personalities.json), same rationale. + registerIpcHandle("registry:fetchPersonalities", () => handle(() => fetchPersonalitiesManifest())); + registerIpcHandle("registry:refreshPersonalities", () => handle(() => refreshPersonalitiesManifest())); } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 93e8c8347..2996f11fa 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -44,6 +44,7 @@ import { registerUrlMetadataHandler } from "./url-metadata"; import { registerMobileHandlers } from "./mobile-handlers"; import { registerMigrationHandlers } from "./migration-handlers"; import { registerSettingsHandlers } from "./settings-handlers"; +import { registerUserStyleHandlers } from "./user-style-handlers"; import { registerUsageHandlers } from "./usage-handlers"; import { initUsageRecorder } from "../lib/usage-recorder"; import { runStartupHygiene } from "../lib/db-hygiene"; @@ -66,6 +67,7 @@ export function registerIpcHandlers(ctx: DbContext): void { registerDbHandlers(ctx); registerChatHandler(ctx.db, ctx.workspacePath, ctx.getWin); registerChatDbHandlers(ctx); + registerUserStyleHandlers(ctx); registerPiSessionHandlers(ctx); registerFlowHandlers(ctx); registerAiHandlers(ctx); diff --git a/electron/ipc/user-style-handlers.test.ts b/electron/ipc/user-style-handlers.test.ts new file mode 100644 index 000000000..de05bb986 --- /dev/null +++ b/electron/ipc/user-style-handlers.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { isUsableGuide, countHeadings, buildUserStylePromptPair } from "./user-style-handlers"; +import { buildUserStyleFullGuidePrompt, buildUserStyleOptimizePrompt } from "../lib/user-style-prompt"; + +describe("writing-style generation guards", () => { + it("accepts a well-structured full guide (12 sections)", () => { + const guide = Array.from({ length: 12 }, (_, i) => `## ${i + 1}. Section ${i}`).join("\n\n"); + expect(isUsableGuide(guide, "full")).toBe(true); + }); + + it("rejects token-soup output with almost no headings", () => { + const soup = "Voice, direct name specifically and clear genuine, the * Will.\"* \" but is switching after Its the so in Frag fine long and outreach sentences channel org\u2013 get one block, multi Tone ****"; + expect(countHeadings(soup)).toBe(0); + expect(isUsableGuide(soup, "full")).toBe(false); + expect(isUsableGuide(soup, "cheatsheet")).toBe(false); + }); + + it("rejects a full guide with too few headings, accepts a small cheat sheet", () => { + expect(isUsableGuide("## 1. Voice\n## 2. Tone", "full")).toBe(false); + expect(isUsableGuide("## 1. Voice\n## 2. Tone\n## 3. Rhythm\n## 4. Format", "cheatsheet")).toBe(true); + }); + + it("treats the optimize step with the full-guide threshold", () => { + const optimized = Array.from({ length: 8 }, (_, i) => `## ${i + 1}. Section ${i}`).join("\n\n"); + expect(isUsableGuide(optimized, "optimize")).toBe(true); + expect(isUsableGuide("## 1. Voice\n## 2. Tone", "optimize")).toBe(false); + }); + + it("builds an optimize prompt from an existing full guide", () => { + const { userPrompt } = buildUserStylePromptPair("optimize", { + persona: { name: "G" }, + samples: [], + answers: [], + fullGuide: "## 1. Voice in one line\nWarm.", + }); + expect(userPrompt).toContain("## Canonical structure"); + expect(userPrompt).toContain("## 1. Voice in one line"); + expect(userPrompt).toContain("Warm."); + }); + + it("caps oversized pasted samples so the prompt stays digestible", () => { + const prompt = buildUserStyleFullGuidePrompt({ + persona: { name: "G" }, + samples: [{ context: "Pasted doc", text: "x".repeat(5000) }], + answers: [], + }); + expect(prompt.length).toBeLessThan(4000); + expect(prompt).toContain("### Pasted doc"); + }); + + it("optimize prompt builder includes the canonical headings", () => { + const p = buildUserStyleOptimizePrompt("source text"); + expect(p).toContain("## 12. Preserve These Voice Tells"); + expect(p).toContain("source text"); + }); +}); diff --git a/electron/ipc/user-style-handlers.ts b/electron/ipc/user-style-handlers.ts new file mode 100644 index 000000000..e86b03bc8 --- /dev/null +++ b/electron/ipc/user-style-handlers.ts @@ -0,0 +1,278 @@ +/** + * Cairn — IPC handlers for the user writing style (persona + full guide + + * condensed cheat sheet). Backs Settings → Writing Style: + * - user-style:get / save / clear — the single-row table + * - user-style:generate — one-shot LLM generation for the guided wizard + * (full guide from persona+samples+answers, or cheat sheet from the guide) + * The tool (get_user_writing_style) reads the same table in the main process. + */ + +import { registerIpcHandle, registerIpcOn } from "./registry"; +import { handle, type DbContext } from "./result-helpers"; +import { getCachedConfig } from "../lib/config-cache"; +import { isLocalEndpoint, normaliseBaseUrl, callLLM, type LLMConfig } from "../lib/llm"; +import { resolveLlmApiKey } from "../lib/secure-store"; +import { + buildUserStyleFullGuidePrompt, + buildUserStyleCheatsheetPrompt, + buildUserStyleOptimizePrompt, + type UserStyleGenerationInput, +} from "../lib/user-style-prompt"; +import { runToolLoop } from "../lib/chat-loop"; +import { TOOLS } from "../lib/tools"; +import * as q from "../db/queries"; +import type { UserStyleSaveInput } from "../db/user-style-queries"; + +/** + * Read-only tool set for the "analyse my notes & tasks" generation path. The + * model may search/read the user's actual notes and tasks to ground the guide — + * nothing else. No get_active_context (IDs are passed in the prompt), no write + * tools, so generation can never mutate anything. + */ +export const WRITING_STYLE_TOOLS = new Set([ + "get_project_context_pack", + "search_notes", + "get_note", + "search_tasks", + "get_task", +]); + +/** Read-only TOOLS subset for the analyse path (exported for the live test). */ +export function writingStyleToolsOverride(analyseNotes: boolean) { + if (!analyseNotes) return [] as typeof TOOLS; + return TOOLS.filter((t) => WRITING_STYLE_TOOLS.has(t.function.name)); +} + +const abortControllers = new Map(); + +/** Resolve the AI Chat connection (same semantics as ai-handlers.resolveConfig). */ +function resolveChatConfig(): { error: string } | LLMConfig { + const cached = getCachedConfig().aiConfig; + const baseUrl = normaliseBaseUrl(cached?.baseUrl || "https://api.openai.com"); + const model = cached?.model || "gpt-5.6-luna"; + const keyRef = cached?.apiKey || ""; + const isLocal = isLocalEndpoint(baseUrl); + if (!keyRef && !isLocal) { + return { error: "AI is not configured. Add an API key in Settings → AI & Chat, or use a local endpoint." }; + } + return { baseUrl, model, apiKey: resolveLlmApiKey(keyRef) }; +} + +/** + * Cheap coherence check: a generated style guide must contain a sensible number + * of markdown headings. A failed generation degrades into "token soup" — long + * scrambled fragments with almost no structure — and this catches that before + * it reaches the preview. Thresholds are deliberately low (the full guide asks + * for 12 sections, the cheat sheet for ~8). + */ +/** Exported for tests. */ +export function countHeadings(markdown: string): number { + const m = markdown.match(/^\s*#{1,2}\s+/gm); + return m ? m.length : 0; +} + +/** Exported for tests. */ +export type UserStyleStep = "full" | "cheatsheet" | "optimize"; + +export function isUsableGuide(markdown: string, step: UserStyleStep): boolean { + const headings = countHeadings(markdown); + if (step === "cheatsheet") return headings >= 3; + return headings >= 6; +} + +const PROMPT_SYSTEM: Record<"full" | "cheatsheet" | "optimize", string> = { + full: "You are a writing-style analyst and editor. Produce a precise, evidence-based writing style guide from the user's real writing. Follow the structure in the user prompt exactly. Write in clean, well-formed Markdown with ## headings — never splice or garble the user's words.", + cheatsheet: "You are a copy editor who condenses style guides into tight cheat sheets. Write in clean, well-formed Markdown with headings — never splice or garble the source text.", + optimize: "You are an editor who restructures existing writing style guides into a canonical, optimized format. Write in clean, well-formed Markdown with ## headings — never splice or garble the source text.", +}; + +/** Build the { system, user } prompt pair for a generation step (shared by the + * one-shot and streaming paths so they can never drift). */ +export function buildUserStylePromptPair( + step: UserStyleStep, + input: UserStyleGenerationInput, +): { systemPrompt: string; userPrompt: string } { + const userPrompt = + step === "full" + ? buildUserStyleFullGuidePrompt(input) + : step === "cheatsheet" + ? buildUserStyleCheatsheetPrompt(input.fullGuide ?? "") + : buildUserStyleOptimizePrompt(input.fullGuide ?? ""); + return { systemPrompt: PROMPT_SYSTEM[step], userPrompt }; +} + +/** + * Run a style-guide generation with the handler's exact resilience: build the + * prompt, call the LLM (bounded output, lower temperature), and if the result + * fails the coherence gate retry once at temp 0.1. Throws a clear error if both + * attempts are unusable. Exported so the live test drives the same code path + * the wizard uses. + */ +export async function generateUserStyleMarkdown( + cfg: LLMConfig, + step: UserStyleStep, + input: UserStyleGenerationInput, +): Promise { + // Validate the precondition BEFORE building a prompt from a possibly-empty + // source guide. + if ((step === "cheatsheet" || step === "optimize") && !input.fullGuide) { + throw new Error(step === "cheatsheet" ? "No full guide to condense." : "No full guide to optimize."); + } + const { systemPrompt, userPrompt } = buildUserStylePromptPair(step, input); + + let markdown = await callLLM(cfg, systemPrompt, userPrompt, { + source: "writing-style", + temperature: 0.3, + maxTokens: 8192, + // Non-streaming: some gateways garble SSE output when a reasoning model + // interleaves long reasoning_content deltas with content deltas (verified + // against zen/go — same request is clean non-streamed, soup streamed). + stream: false, + // reasoning_effort none: measured ~2x faster with 0 reasoning tokens while + // still producing a usable guide for this direct transform. + reasoningEffort: "none", + }); + if (!isUsableGuide(markdown, step)) { + markdown = await callLLM(cfg, systemPrompt, userPrompt, { + source: "writing-style", + temperature: 0.1, + maxTokens: 8192, + stream: false, + reasoningEffort: "none", + }); + } + if (!isUsableGuide(markdown, step)) { + throw new Error( + `The model (${cfg.model}) returned unusable output for the style guide. Try again, or switch to a more capable model in Settings → AI & Chat.`, + ); + } + return markdown; +} + +export function registerUserStyleHandlers(ctx: DbContext): void { + registerIpcHandle("user-style:get", () => handle(() => q.getUserStyle(ctx.db))); + registerIpcHandle("user-style:save", (_e, { input }: { input: UserStyleSaveInput }) => handle(() => q.saveUserStyle(ctx.db, input))); + registerIpcHandle("user-style:clear", () => handle(() => { + q.clearUserStyle(ctx.db); + return { ok: true }; + })); + + registerIpcHandle("user-style:generate", (_e, { step, input }: { step: UserStyleStep; input: UserStyleGenerationInput }) => + handle(async () => { + const cfg = resolveChatConfig(); + if ("error" in cfg) throw new Error(cfg.error); + const markdown = await generateUserStyleMarkdown(cfg, step, input); + return { markdown }; + }), + ); + + // user-style:generateStream — fire-and-forget streaming generation for the + // wizard so the guide (and any note-reading tool calls) appear live. + // Emits: + // user-style:token { delta } — one content chunk + // user-style:tool-call { tool, label, args } — a note/task read (analyse path) + // user-style:done { content, usable, error? } + registerIpcOn("user-style:generateStream", (event, req: { + workspaceId?: string; + projectId?: string; + projectName?: string; + step: UserStyleStep; + analyseNotes: boolean; + input: UserStyleGenerationInput; + }) => { + abortControllers.get(event.sender.id)?.abort(); + const abortCtrl = new AbortController(); + abortControllers.set(event.sender.id, abortCtrl); + + const send = (ch: string, payload: unknown) => { + if (!event.sender.isDestroyed()) event.sender.send(ch, payload); + }; + + void (async () => { + try { + const cfg = resolveChatConfig(); + if ("error" in cfg) { + send("user-style:done", { content: "", usable: false, error: cfg.error }); + return; + } + + // Validate the precondition BEFORE building a prompt from a possibly + // empty source guide. + if ((req.step === "cheatsheet" || req.step === "optimize") && !req.input.fullGuide) { + send("user-style:done", { content: "", usable: false, error: req.step === "cheatsheet" ? "No full guide to condense." : "No full guide to optimize." }); + return; + } + + // Build the generation prompt pair (shared with the one-shot path). + const { systemPrompt, userPrompt: baseUserPrompt } = buildUserStylePromptPair(req.step, req.input); + const scopeHint = req.projectName + ? `\n\n## Active context\nProject: ${req.projectName}\nWorkspace ID: ${req.workspaceId ?? ""}\nProject ID: ${req.projectId ?? ""}\nIf you need the user's own content, search/read their notes and tasks with the tools (scope searches to the project ID). Do NOT call get_active_context — the IDs are above. Never call write tools.` + : ""; + const userPrompt = baseUserPrompt + scopeHint; + + const chatReq = { + message: userPrompt, + threadId: "user-style", + workspaceId: req.workspaceId, + projectId: req.projectId, + config: { maxSteps: 6, temperature: 0.3, reasoningEffort: "none" as const }, + }; + const messages = [ + { role: "system" as const, content: systemPrompt }, + { role: "user" as const, content: userPrompt }, + ]; + const toolsOverride = writingStyleToolsOverride(req.analyseNotes); + + const run = async () => { + const result = await runToolLoop( + ctx.db, chatReq as never, ctx.workspacePath, + cfg.baseUrl, cfg.model, cfg.apiKey, + messages, + (e) => send("user-style:tool-call", e), + abortCtrl.signal, undefined, "openai", + undefined, + (e) => send("user-style:tool-call-done", e), + (delta) => send("user-style:token", { delta }), + undefined, + [], + toolsOverride, + ); + return result.content; + }; + + let content = await run(); + if (!isUsableGuide(content, req.step)) { + // Retry once — non-deterministic models occasionally degrade. + content = await run(); + } + send("user-style:done", { + content, + usable: isUsableGuide(content, req.step), + error: isUsableGuide(content, req.step) + ? undefined + : `The model (${cfg.model}) returned unusable output for the style guide. Try again, or switch to a more capable model in Settings → AI & Chat.`, + }); + } catch (err) { + if (abortCtrl.signal.aborted) return; // user cancelled — no event + send("user-style:done", { + content: "", + usable: false, + error: err instanceof Error ? err.message : String(err), + }); + } finally { + // Only delete the map entry if it still references THIS run's + // controller — a newer generation started for the same sender (which + // aborted this one and replaced the entry) must not be removed here. + if (abortControllers.get(event.sender.id) === abortCtrl) { + abortControllers.delete(event.sender.id); + } + } + })(); + }); + + registerIpcOn("user-style:abort", (event) => { + abortControllers.get(event.sender.id)?.abort(); + // The aborted run's finally clears the entry only when it still owns it, + // so a freshly-started generation's controller survives this abort. + }); +} diff --git a/electron/lib/chat-loop.ts b/electron/lib/chat-loop.ts index f71763590..5497efbd5 100644 --- a/electron/lib/chat-loop.ts +++ b/electron/lib/chat-loop.ts @@ -230,21 +230,32 @@ export async function runToolLoop( } else { streamingTurn = true; let response: Response; - try { - const headers: Record = { "Content-Type": "application/json" }; - if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - response = await fetch(buildApiUrl(baseUrl, "chat/completions"), { + const headers: Record = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const buildBody = (reasoningEffort?: "none" | "low" | "high" | "max") => + buildChatCompletionsBody({ + model, + messages: prepareForSend(), + tools: combinedTools, + maxTokens, + temperature, + reasoningEffort, + }); + const doFetch = (body: Record) => + fetch(buildApiUrl(baseUrl, "chat/completions"), { method: "POST", headers, signal, - body: JSON.stringify(buildChatCompletionsBody({ - model, - messages: prepareForSend(), - tools: combinedTools, - maxTokens, - temperature, - })), + body: JSON.stringify(body), }); + try { + response = await doFetch(buildBody(req.config?.reasoningEffort)); + // reasoning_effort is ignored by non-reasoning models but a strict + // endpoint may reject it (400/422) — retry once without it so the + // loop never breaks for models that don't support the field. + if (req.config?.reasoningEffort && (response.status === 400 || response.status === 422)) { + response = await doFetch(buildBody(undefined)); + } } catch (_err) { if (signal?.aborted) return { exhausted: true, content: "", reasoning: accumulatedReasoning }; return { exhausted: true, content: `Could not reach the AI endpoint at \`${baseUrl}\`. Check your endpoint URL and make sure the server is running.`, reasoning: "" }; diff --git a/electron/lib/community-registry.test.ts b/electron/lib/community-registry.test.ts index 7a32a99a3..e2c8d3312 100644 --- a/electron/lib/community-registry.test.ts +++ b/electron/lib/community-registry.test.ts @@ -18,7 +18,7 @@ vi.mock("../runtime/port-discovery", () => ({ })); // Import AFTER the mock is registered. -import { parseManifest, fetchManifest, refreshManifest, __test } from "./community-registry"; +import { parseManifest, parsePersonalitiesManifest, fetchManifest, fetchPersonalitiesManifest, refreshManifest, __test } from "./community-registry"; const VALID = { version: 1, @@ -162,6 +162,98 @@ describe("parseManifest", () => { }); }); +describe("parsePersonalitiesManifest", () => { + const VALID_PERSONALITIES = { + version: 1, + updatedAt: "2026-08-11T00:00:00Z", + personalities: [ + { + id: "caveman", + author: "cairn", + version: "1.0.0", + category: "Tone & Style", + tags: ["tone"], + blurb: "Short, plain words.", + brandColor: "#9ca3af", + homepage: "https://github.com/JuliusBrussee/caveman", + definition: { + name: "Caveman", + description: "Blunt, minimal answers.", + prompt: "Speak in short, simple sentences. Use plain words. Give the answer or result first.", + }, + }, + ], + }; + + it("accepts a valid personalities manifest", () => { + const m = parsePersonalitiesManifest(VALID_PERSONALITIES); + expect(m.personalities).toHaveLength(1); + expect(m.personalities[0].definition.name).toBe("Caveman"); + }); + + it("drops a personality whose prompt opens with a 'You are …' identity claim", () => { + const bad = structuredClone(VALID_PERSONALITIES) as { personalities: Array<{ definition: { prompt: string } }> }; + bad.personalities[0].definition.prompt = "You are Grug Assistant.\nSpeak short."; + const m = parsePersonalitiesManifest(bad); + expect(m.personalities).toHaveLength(0); + }); + + it("drops a personality whose prompt is a thin one-liner (<20 chars)", () => { + const bad = structuredClone(VALID_PERSONALITIES) as { personalities: Array<{ definition: { prompt: string } }> }; + bad.personalities[0].definition.prompt = "be concise."; + const m = parsePersonalitiesManifest(bad); + expect(m.personalities).toHaveLength(0); + }); + + it("drops a malformed entry instead of rejecting the whole manifest", () => { + const mixed = structuredClone(VALID_PERSONALITIES) as Record; + const list = mixed.personalities as Array>; + list.push({ id: "bad", author: "x", version: "1.0.0", tags: [], blurb: "bad", definition: {} }); + const m = parsePersonalitiesManifest(mixed); + expect(m.personalities).toHaveLength(1); + }); +}); + +describe("fetchPersonalitiesManifest", () => { + const VALID = { + version: 1, + updatedAt: "2026-08-11T00:00:00Z", + personalities: [ + { + id: "grill-me", + author: "cairn", + version: "1.0.0", + category: "Critique & Review", + tags: ["stress-test"], + blurb: "Stress-tests your plan.", + definition: { + name: "Grill Me", + prompt: "Pressure-test plans with calibrated questions. Ask one question at a time. Give a recommended answer.", + }, + }, + ], + }; + + it("fetches from network, validates, and writes its own cache file", async () => { + vi.stubGlobal("fetch", vi.fn(async () => fetchResponse({ status: 200, json: VALID }))); + const res = await fetchPersonalitiesManifest({ force: true }); + expect(res.fromCache).toBe(false); + expect(res.manifest.personalities[0].definition.name).toBe("Grill Me"); + expect(__test.readPersonalitiesCache()?.manifest.personalities).toHaveLength(1); + }); + + it("fails soft to the cache when the network fails", async () => { + vi.stubGlobal("fetch", vi.fn(async () => fetchResponse({ status: 200, json: VALID }))); + await fetchPersonalitiesManifest({ force: true }); + + vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("offline"); })); + const res = await fetchPersonalitiesManifest({ force: true }); + expect(res.fromCache).toBe(true); + expect(res.error).toBe("offline"); + expect(res.manifest.personalities).toHaveLength(1); + }); +}); + describe("fetchManifest", () => { it("fetches from network, validates, and writes the cache (200)", async () => { vi.stubGlobal( diff --git a/electron/lib/community-registry.ts b/electron/lib/community-registry.ts index 602f83d1d..6f4747520 100644 --- a/electron/lib/community-registry.ts +++ b/electron/lib/community-registry.ts @@ -22,9 +22,11 @@ import { parseManifest, parseProvidersManifest, parseAutomationsManifest, + parsePersonalitiesManifest, type CommunityManifest, type ProvidersManifest, type AutomationsManifest, + type PersonalitiesManifest, } from "../../shared/chat/registry-schema"; // Manifest TYPES + Zod validation now live in shared/chat/registry-schema.ts so @@ -34,13 +36,15 @@ export type { CommunityManifest, ProvidersManifest, AutomationsManifest, + PersonalitiesManifest, RegistryMcpEntry, RegistryServiceEntry, RegistryProviderEntry, RegistryAutomationEntry, + RegistryPersonalityEntry, RegistryEntryMeta, } from "../../shared/chat/registry-schema"; -export { parseManifest, parseProvidersManifest, parseAutomationsManifest } from "../../shared/chat/registry-schema"; +export { parseManifest, parseProvidersManifest, parseAutomationsManifest, parsePersonalitiesManifest } from "../../shared/chat/registry-schema"; export interface RegistryFetchResult { manifest: CommunityManifest; @@ -63,15 +67,25 @@ export interface AutomationsFetchResult { error?: string; } +export interface PersonalitiesFetchResult { + manifest: PersonalitiesManifest; + fromCache: boolean; + cachedAt?: string; + error?: string; +} + const MANIFEST_URL = "https://raw.githubusercontent.com/ddutchie/cairn-community/main/manifest.json"; const PROVIDERS_URL = "https://raw.githubusercontent.com/ddutchie/cairn-community/main/providers.json"; const AUTOMATIONS_URL = "https://raw.githubusercontent.com/ddutchie/cairn-community/main/automations.json"; +const PERSONALITIES_URL = + "https://raw.githubusercontent.com/ddutchie/cairn-community/main/personalities.json"; const CACHE_FILE = "community-registry.json"; const PROVIDERS_CACHE_FILE = "community-providers.json"; const AUTOMATIONS_CACHE_FILE = "community-automations.json"; +const PERSONALITIES_CACHE_FILE = "community-personalities.json"; const FETCH_TIMEOUT_MS = 10_000; // ── generic cache + fetch core (shared by both manifests) ─────────────────── @@ -280,14 +294,35 @@ export function refreshAutomationsManifest(): Promise { return fetchAutomationsManifest({ force: true }); } +// ── personalities manifest ─────────────────────────────────────────────────── + +const PERSONALITIES_SPEC: FetchSpec = { + url: PERSONALITIES_URL, + file: PERSONALITIES_CACHE_FILE, + parse: parsePersonalitiesManifest, + empty: { version: 1, updatedAt: "", personalities: [] }, +}; + +/** Fetch the community PERSONALITIES manifest (cache-first; see fetchManifest). */ +export function fetchPersonalitiesManifest(opts?: { force?: boolean }): Promise { + return fetchGeneric(PERSONALITIES_SPEC, opts); +} + +/** Force a network refresh of the personalities manifest. */ +export function refreshPersonalitiesManifest(): Promise { + return fetchPersonalitiesManifest({ force: true }); +} + /** Exposed for tests. */ export const __test = { MANIFEST_URL, PROVIDERS_URL, AUTOMATIONS_URL, + PERSONALITIES_URL, CACHE_FILE, PROVIDERS_CACHE_FILE, AUTOMATIONS_CACHE_FILE, + PERSONALITIES_CACHE_FILE, cachePath: () => cacheFilePath(CACHE_FILE), readCache: () => readCacheFile(CACHE_FILE, parseManifest), writeCache: (env: CacheEnvelope) => writeCacheFile(CACHE_FILE, env), @@ -299,4 +334,8 @@ export const __test = { readAutomationsCache: () => readCacheFile(AUTOMATIONS_CACHE_FILE, parseAutomationsManifest), writeAutomationsCache: (env: CacheEnvelope) => writeCacheFile(AUTOMATIONS_CACHE_FILE, env), + personalitiesCachePath: () => cacheFilePath(PERSONALITIES_CACHE_FILE), + readPersonalitiesCache: () => readCacheFile(PERSONALITIES_CACHE_FILE, parsePersonalitiesManifest), + writePersonalitiesCache: (env: CacheEnvelope) => + writeCacheFile(PERSONALITIES_CACHE_FILE, env), }; diff --git a/electron/lib/compaction.ts b/electron/lib/compaction.ts index f4b913f24..b5174ec2b 100644 --- a/electron/lib/compaction.ts +++ b/electron/lib/compaction.ts @@ -19,7 +19,7 @@ import type { AgentLLMConfig, AgentMessage, AgentToolResultMsg, PiAgentSession } from "./pi-agent-loop"; import { buildApiUrl, postChatCompletions } from "./llm"; -import { recordLlmUsage, extractCost, extractCacheTokens } from "./usage-recorder"; +import { recordLlmUsage, extractCost } from "./usage-recorder"; // ── Constants ───────────────────────────────────────────────────────────────── @@ -128,27 +128,38 @@ export async function generateSummary( const headers: Record = { "Content-Type": "application/json" }; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - const response = await postChatCompletions( + const body = { + model, + // System prompt as a `role: "system"` message (not a top-level `system:` + // field) so strict OpenAI-compatible providers don't reject the request + // with "unknown parameter system". + messages: [ + { role: "system", content: SUMMARIZATION_SYSTEM_PROMPT }, + { role: "user", content: SUMMARIZATION_USER_PROMPT(conversationText) }, + ], + max_tokens: SUMMARY_MAX_TOKENS, + temperature: 0.1, // deterministic summary + // Must stream to prevent proxy connection drop timeouts (e.g. gateway/reverse proxy limits on blocking sync calls returning 504 Gateway Time-out) + stream: true, + // Summary is a direct transform — skip reasoning for ~2x speed, like the + // writing-style one-shots. + reasoning_effort: "none", + }; + const post = (b: Record) => postChatCompletions( buildApiUrl(baseUrl, "chat/completions"), headers, - { - model, - // System prompt as a `role: "system"` message (not a top-level `system:` - // field) so strict OpenAI-compatible providers don't reject the request - // with "unknown parameter system". - messages: [ - { role: "system", content: SUMMARIZATION_SYSTEM_PROMPT }, - { role: "user", content: SUMMARIZATION_USER_PROMPT(conversationText) }, - ], - max_tokens: SUMMARY_MAX_TOKENS, - temperature: 0.1, // deterministic summary - // Must stream to prevent proxy connection drop timeouts (e.g. gateway/reverse proxy limits on blocking sync calls returning 504 Gateway Time-out) - stream: true, - }, + b, true, signal, ); + let response = await post(body); + // A strict endpoint may reject reasoning_effort (400/422) — retry without it + // so compaction never breaks for models that don't support the field. + if (response.status === 400 || response.status === 422) { + response = await post({ ...body, reasoning_effort: undefined }); + } + if (!response.ok) { throw new Error(`Compaction LLM call failed: ${response.status} ${response.statusText}`); } @@ -156,36 +167,26 @@ export async function generateSummary( const reader = response.body?.getReader(); if (!reader) throw new Error("No response stream for compaction"); - const decoder = new TextDecoder(); - let content = ""; let usage: { pt?: number; ct?: number; rt?: number; cacheRead?: number; cacheCreate?: number; chunkCost?: unknown; raw?: unknown } | undefined; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - for (const line of decoder.decode(value, { stream: true }).split("\n")) { - const trimmed = line.trim(); - if (!trimmed.startsWith("data:")) continue; - const jsonStr = trimmed.slice(5).trim(); - if (jsonStr === "[DONE]") break; - try { - const obj = JSON.parse(jsonStr); - if (obj.usage) { - const cache = extractCacheTokens(obj.usage); - usage = { - pt: obj.usage.prompt_tokens ?? 0, - ct: obj.usage.completion_tokens ?? 0, - rt: obj.usage.completion_tokens_details?.reasoning_tokens ?? 0, - cacheRead: cache.cacheReadTokens, - cacheCreate: cache.cacheCreationTokens, - chunkCost: obj.cost, - raw: obj.usage, - }; - } - const delta = obj.choices?.[0]?.delta?.content ?? ""; - if (delta) content += delta; - } catch { /* skip malformed lines */ } - } - } + // Consume via the shared buffered SSE parser (same as chat/agent/callLLM) — + // the old inline split("\n")-per-chunk loop mangled records that straddled + // TCP reads and ignored reasoning fields. + const { consumeAssistantStream } = await import("./llm-stream"); + const turn = await consumeAssistantStream(reader, { + signal, + onUsage: (u) => { + usage = { + pt: u.promptTokens ?? 0, + ct: u.completionTokens ?? 0, + rt: u.reasoningTokens ?? 0, + cacheRead: u.cacheReadTokens, + cacheCreate: u.cacheCreationTokens, + chunkCost: u.chunkCost, + raw: u.raw, + }; + }, + }); + const content = turn.content; if (usage) { recordLlmUsage({ diff --git a/electron/lib/config-cache.test.ts b/electron/lib/config-cache.test.ts index cbb78a7ad..f910d81c0 100644 --- a/electron/lib/config-cache.test.ts +++ b/electron/lib/config-cache.test.ts @@ -92,6 +92,48 @@ describe("config-cache AI settings round-trip", () => { expect(ai?.maxOutputAuto).toBe(true); expect(ai?.maxOutputTokens).toBe(8192); }); + + it("persists installedPersonalities + the active personalityId", async () => { + const { saveCachedConfig, getCachedConfig } = await import("./config-cache"); + const installedPersonalities = [ + { + id: "p1", + name: "Grill Me", + description: "Calibrated grilling.", + prompt: "Pressure-test plans with calibrated questions.", + source: "community" as const, + communityId: "grill-me", + version: "1.0.0", + author: "cairn", + brandColor: "#f43f5e", + homepage: "https://github.com/JuliusBrussee/skills", + }, + { id: "p2", name: "Mine", prompt: "Be brief.", source: "custom" as const }, + ]; + saveCachedConfig("ai", { installedPersonalities, personalityId: "p1" }); + const ai = getCachedConfig().aiConfig; + expect(ai?.installedPersonalities).toHaveLength(2); + expect(ai?.installedPersonalities?.[0].communityId).toBe("grill-me"); + expect(ai?.personalityId).toBe("p1"); + // A later connection-only save must not clobber the personality fields. + saveCachedConfig("ai", { model: "gpt-4o" }); + expect(getCachedConfig().aiConfig?.personalityId).toBe("p1"); + expect(getCachedConfig().aiConfig?.installedPersonalities).toHaveLength(2); + }); + + it("an explicit null personalityId CLEARS the cached value (the renderer's 'None' choice)", async () => { + const { saveCachedConfig, getCachedConfig } = await import("./config-cache"); + saveCachedConfig("ai", { personalityId: "p1" }); + expect(getCachedConfig().aiConfig?.personalityId).toBe("p1"); + // The renderer sets personalityId to null to mean "None" — the cache must + // drop the old selection, not keep it (which would resurrect the previous + // personality on the next hydrate). + saveCachedConfig("ai", { personalityId: null }); + expect(getCachedConfig().aiConfig?.personalityId).toBeUndefined(); + // And a later connection-only save must not resurrect it. + saveCachedConfig("ai", { model: "gpt-4o" }); + expect(getCachedConfig().aiConfig?.personalityId).toBeUndefined(); + }); }); describe("config-cache without Electron", () => { diff --git a/electron/lib/config-cache.ts b/electron/lib/config-cache.ts index 0fd9d47e3..ad699c078 100644 --- a/electron/lib/config-cache.ts +++ b/electron/lib/config-cache.ts @@ -35,6 +35,23 @@ export interface CachedConfig { // id of the active one. Persisted so the switcher survives restarts. savedProviders?: Array<{ id: string; name: string; baseUrl: string; apiKey: string; model: string }>; activeProviderId?: string; + // Installed chat personalities (community + custom) and the active one. + // Persisted so the picker + selection survive restarts; the personality + // text is plain (no secrets), so it is cached verbatim. + installedPersonalities?: Array<{ + id: string; + name: string; + description?: string; + prompt: string; + source: "community" | "custom"; + communityId?: string; + version?: string; + author?: string; + brandColor?: string; + homepage?: string; + }>; + /** Active personality id. `null` (explicit "None") is stored as absent. */ + personalityId?: string | null; }; agentConfig?: { baseUrl?: string; @@ -153,6 +170,20 @@ export function saveCachedConfig(type: "ai" | "agent" | "embeddings" | "theme" | ) : current.aiConfig?.savedProviders, activeProviderId: typeof configRecord.activeProviderId === "string" ? configRecord.activeProviderId : current.aiConfig?.activeProviderId, + // Installed personalities + active selection. The prompt text is plain + // (never a secret), so it is stored verbatim like savedProviders. + installedPersonalities: Array.isArray((config as { installedPersonalities?: unknown }).installedPersonalities) + ? (config as { installedPersonalities: NonNullable["installedPersonalities"] }).installedPersonalities + : current.aiConfig?.installedPersonalities, + // personalityId: a string is stored; an explicit null (the renderer's + // "None" choice) CLEARS the cached value instead of keeping the old one + // (which would resurrect the previous personality on the next hydrate). + // Absent (a connection-only save) preserves the current value. + personalityId: configRecord.personalityId === null + ? undefined + : typeof configRecord.personalityId === "string" + ? configRecord.personalityId + : current.aiConfig?.personalityId, }; } else if (type === "agent" && configRecord) { current.agentConfig = { diff --git a/electron/lib/llm-stream.test.ts b/electron/lib/llm-stream.test.ts index 2d51195a6..8a6cfcf5b 100644 --- a/electron/lib/llm-stream.test.ts +++ b/electron/lib/llm-stream.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from "vitest"; -import { consumeAssistantStream, failToolCallsFromTruncatedMessage, prepareContextMessages, resolveSystemRole, supportsDeveloperRole } from "./llm-stream"; +import { consumeAssistantStream, failToolCallsFromTruncatedMessage, prepareContextMessages, resolveSystemRole, supportsDeveloperRole, buildChatCompletionsBody } from "./llm-stream"; function sseReader(...chunks: string[]): ReadableStreamDefaultReader { const encoder = new TextEncoder(); @@ -315,3 +315,16 @@ describe("failToolCallsFromTruncatedMessage", () => { expect(results.every((r) => r.content.includes("not executed"))).toBe(true); }); }); + +describe("buildChatCompletionsBody reasoning_effort", () => { + it("includes reasoning_effort when provided", () => { + const body = buildChatCompletionsBody({ model: "m", messages: [], tools: [], maxTokens: 1000, temperature: 0.3, reasoningEffort: "none" }); + expect(body.reasoning_effort).toBe("none"); + }); + it("omits reasoning_effort when undefined (the non-reasoning fallback)", () => { + const body = buildChatCompletionsBody({ model: "m", messages: [], tools: [], maxTokens: 1000, temperature: 0.3 }); + expect(body.reasoning_effort).toBeUndefined(); + const retry = buildChatCompletionsBody({ model: "m", messages: [], tools: [], maxTokens: 1000, temperature: 0.3, reasoningEffort: undefined }); + expect(retry.reasoning_effort).toBeUndefined(); + }); +}); diff --git a/electron/lib/llm-stream.ts b/electron/lib/llm-stream.ts index 32af00a3f..d0c8772a6 100644 --- a/electron/lib/llm-stream.ts +++ b/electron/lib/llm-stream.ts @@ -219,6 +219,7 @@ export function buildChatCompletionsBody(opts: { tools: unknown[]; maxTokens?: number; temperature?: number; + reasoningEffort?: "none" | "low" | "high" | "max"; }): Record { return { model: opts.model, @@ -232,6 +233,9 @@ export function buildChatCompletionsBody(opts: { temperature: opts.temperature, stream: true, stream_options: { include_usage: true }, + // One-shot generation can drop reasoning entirely (faster, cheaper, and the + // guide is a direct transform) — measured ~2x faster with 0 reasoning tokens. + ...(opts.reasoningEffort ? { reasoning_effort: opts.reasoningEffort } : {}), }; } diff --git a/electron/lib/llm.ts b/electron/lib/llm.ts index 291c33c39..62325e6b5 100644 --- a/electron/lib/llm.ts +++ b/electron/lib/llm.ts @@ -124,6 +124,24 @@ export interface LlmCallOpts { sessionId?: string; projectId?: string; workspaceId?: string; + /** Sampling temperature override (default 0.4). */ + temperature?: number; + /** Max output/completion token override (default 4096). */ + maxTokens?: number; + /** + * Stream the response (default true). Some gateways corrupt SSE output when a + * reasoning model emits long `reasoning_content` interleaved with `content` + * deltas (the accumulated text comes back garbled). One-shot callers that + * don't need incremental tokens (e.g. writing-style generation) should pass + * stream:false and read the final message instead. + */ + stream?: boolean; + /** + * reasoning_effort for one-shots (none | low | high | max). Default = omit + * (endpoint default). Measured on writing-style generation: "none" is ~2x + * faster with 0 reasoning tokens and still produces a usable guide. + */ + reasoningEffort?: "none" | "low" | "high" | "max"; } /** @@ -216,57 +234,90 @@ export async function callLLM( const headers: Record = { "Content-Type": "application/json" }; if (config.apiKey) headers["Authorization"] = `Bearer ${config.apiKey}`; - const response = await postChatCompletions( - buildApiUrl(config.baseUrl, "chat/completions"), - headers, - { - model: config.model, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - max_tokens: 4096, - temperature: 0.4, - // Must stream to prevent proxy connection drop timeouts (e.g. gateway/reverse proxy limits on blocking sync calls returning 504 Gateway Time-out) - stream: true, - }, - true, - ); + const stream = opts.stream ?? true; + const body = { + model: config.model, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + max_tokens: opts.maxTokens ?? 4096, + temperature: opts.temperature ?? 0.4, + // Stream by default to prevent proxy connection drop timeouts (e.g. + // gateway/reverse proxy limits on blocking sync calls returning 504 + // Gateway Time-out). Callers that need a clean final message (one-shots) + // can pass stream:false — some gateways garble SSE when a reasoning model + // interleaves huge reasoning_content deltas with content deltas. + stream, + ...(opts.reasoningEffort ? { reasoning_effort: opts.reasoningEffort } : {}), + }; + const post = (b: typeof body) => + postChatCompletions(buildApiUrl(config.baseUrl, "chat/completions"), headers, b, stream); + + let response = await post(body); + // reasoning_effort is OpenAI's param for reasoning models; non-reasoning + // models/endpoints IGNORE it, but a strict OpenAI-compatible server may + // reject it with 400/422. Fall back to a retry without it so generation + // never breaks for models that don't support the field. + if (opts.reasoningEffort && (response.status === 400 || response.status === 422)) { + response = await post({ ...body, reasoning_effort: undefined }); + } if (!response.ok) throw new Error(`LLM error ${response.status}: ${await response.text().catch(() => response.statusText)}`); + // Non-streaming: the response is a single JSON body — read message.content and + // usage directly. This is the reliable path for one-shot generation when the + // gateway's SSE is corrupted by long reasoning streams. + if (!stream) { + const body = (await response.json()) as { + choices?: Array<{ message?: { content?: string | null }; finish_reason?: string }>; + usage?: { prompt_tokens?: number; completion_tokens?: number; completion_tokens_details?: { reasoning_tokens?: number }; cost?: unknown }; + }; + const content = body.choices?.[0]?.message?.content ?? ""; + const usage = body.usage; + if (usage) { + const cache = extractCacheTokens(usage); + record( + usage.prompt_tokens ?? 0, + usage.completion_tokens ?? 0, + usage.completion_tokens_details?.reasoning_tokens ?? 0, + extractCost(usage.cost, usage), + undefined, + cache.cacheReadTokens, + cache.cacheCreationTokens, + ); + } else { + record(tok(systemPrompt) + tok(userPrompt), tok(content), 0); + } + return content; + } + + // Streaming: consume via the SAME buffered SSE parser the chat and agent + // loops use (consumeAssistantStream). callLLM's historical inline + // `split("\n")`-per-chunk loop mangled SSE events that split across TCP + // reads and ignored reasoning fields — which silently broke every one-shot + // tool (commit messages, PRD, explain, summaries, writing style) on + // reasoning models/gateways. Dynamic import avoids the llm ↔ llm-stream + // module cycle. const reader = response.body?.getReader(); if (!reader) throw new Error("No readable stream"); - const decoder = new TextDecoder(); - let content = ""; let usage: { pt?: number; ct?: number; rt?: number; cacheRead?: number; cacheCreate?: number; chunkCost?: unknown; raw?: unknown } | undefined; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - for (const line of decoder.decode(value, { stream: true }).split("\n")) { - const trimmed = line.trim(); - if (!trimmed.startsWith("data:")) continue; - const jsonStr = trimmed.slice(5).trim(); - if (jsonStr === "[DONE]") break; - try { - const obj = JSON.parse(jsonStr); - if (obj.usage) { - const cache = extractCacheTokens(obj.usage); - usage = { - pt: obj.usage.prompt_tokens ?? 0, - ct: obj.usage.completion_tokens ?? 0, - rt: obj.usage.completion_tokens_details?.reasoning_tokens ?? 0, - cacheRead: cache.cacheReadTokens, - cacheCreate: cache.cacheCreationTokens, - chunkCost: obj.cost, - raw: obj.usage, - }; - } - const delta = obj.choices?.[0]?.delta?.content ?? ""; - if (delta) content += delta; - } catch { /* skip malformed lines */ } - } - } + const { consumeAssistantStream } = await import("./llm-stream"); + const turn = await consumeAssistantStream(reader, { + signal: undefined, + onUsage: (u) => { + usage = { + pt: u.promptTokens ?? 0, + ct: u.completionTokens ?? 0, + rt: u.reasoningTokens ?? 0, + cacheRead: u.cacheReadTokens, + cacheCreate: u.cacheCreationTokens, + chunkCost: u.chunkCost, + raw: u.raw, + }; + }, + }); + const content = turn.content; if (usage) { record(usage.pt ?? 0, usage.ct ?? 0, usage.rt ?? 0, extractCost(usage.chunkCost, usage.raw), undefined, usage.cacheRead, usage.cacheCreate); diff --git a/electron/lib/pi-agent-loop.ts b/electron/lib/pi-agent-loop.ts index e254fb81d..3b3777da6 100644 --- a/electron/lib/pi-agent-loop.ts +++ b/electron/lib/pi-agent-loop.ts @@ -296,6 +296,7 @@ const CAIRN_TOOL_NAMES = new Set([ // ── Context / read ────────────────────────────────────────────────────────── "get_active_context", "get_project_context_pack", + "get_user_writing_style", "get_neighbors", // ── Notes ─────────────────────────────────────────────────────────────────── "get_note", @@ -344,6 +345,7 @@ const PLAN_MODE_ALLOWED = new Set([ "read", "grep", "find", "ls", // Cairn read "get_active_context", "get_project_context_pack", + "get_user_writing_style", "get_note", "search_notes", "get_task", "search_tasks", "list_ready_tasks", "list_overdue_tasks", "list_tasks_due", diff --git a/electron/lib/pi-agent-prompt-trim.test.ts b/electron/lib/pi-agent-prompt-trim.test.ts new file mode 100644 index 000000000..a1dd1d26b --- /dev/null +++ b/electron/lib/pi-agent-prompt-trim.test.ts @@ -0,0 +1,259 @@ +/** + * Cairn — Coding agent system-prompt trim verification + * + * The pi-agent system prompt no longer lists tools ("## Coding tools" / + * "## Cairn tools" / "## Available tools") because every tool is already + * defined in the tools array the model receives. This test: + * + * • OFFLINE (always runs): asserts the trimmed prompt drops the redundant + * sections, keeps the base identity + workflow intact, and measures the + * deterministic token saving vs. the legacy prompt. + * • LIVE (gated on an endpoint, like chat-agent-benchmark): replays a small + * tool-selection task set through runToolLoop with BOTH the legacy and the + * trimmed prompt, and asserts the trimmed prompt selects the same (correct) + * tools while sending fewer prompt tokens. Run with: + * + * CAIRN_LIVE_TESTS=1 TEST_LLM_BASE_URL=... TEST_LLM_MODEL=... \ + * TEST_LLM_API_KEY=... npx vitest run electron/lib/pi-agent-prompt-trim.test.ts + */ + +import { describe, it, expect, beforeAll } from "vitest"; +import type Database from "better-sqlite3"; +import BetterSqlite3 from "better-sqlite3"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { encode } from "gpt-tokenizer"; +import { applySchema } from "../db/schema"; +import { + createWorkspace, createProject, createNote, createColumn, createCard, +} from "../db/queries"; +import { buildPiAgentSystemPrompt, type PiAgentPromptContext } from "./pi-agent-prompt"; +import { type ChatRequest } from "./tools"; +import { BASE_URL, MODEL, API_KEY, endpointUp, LIVE_TESTS_ENABLED } from "./bench-endpoint"; +import { runToolLoop } from "./chat-loop"; + +const tok = (s: string) => encode(s).length; + +// The exact tool-list blocks removed from the execute-mode prompt, kept here so +// the live suite can rebuild the LEGACY prompt and A/B it against the trimmed +// one with the same model + tasks. +const LEGACY_CODING_TOOLS = `## Coding tools +- **read** — read file contents with line ranges +- **write** — write or overwrite a file entirely +- **edit** — make targeted string replacements (always read first to get exact content) +- **bash** — execute shell commands (tests, builds, git, grep, etc.) +- **grep** — search file contents with regex +- **find** — find files by name pattern +- **ls** — list directory contents +- **spawn_subagent** — delegate a contained, deep sub-task to a fresh agent with its own context window; only the final answer is returned to you`; + +const LEGACY_CAIRN_TOOLS = `## Cairn tools +- **get_active_context** — get IDs for the current workspace, project, and board columns +- **get_project_context_pack** — get full project state: tasks, notes, recent activity +- **ensure_note** — create-or-update a note by title (idempotent — use this for all note writes) +- **patch_note** / **append_to_note** — targeted edit or append to an existing note +- **search_notes** / **get_note** — find and read project notes +- **search_notes_semantic** — natural-language search via local embeddings (better than search_notes when concepts are described in different words; requires embeddings enabled) +- **create_task** / **update_task** — create tasks; update_task with \`columnId\` moves to a column +- **search_tasks** / **list_ready_tasks** — find tasks; list_ready_tasks returns only unblocked work`; + +function executePrompt(ctx: Partial): string { + return buildPiAgentSystemPrompt({ + projectName: ctx.projectName ?? "My Project", + cwd: ctx.cwd ?? "/project", + taskTitle: ctx.taskTitle, + mode: "execute", + }); +} + +function legacyExecutePrompt(ctx: Partial): string { + const trimmed = executePrompt(ctx); + // Rebuild the pre-trim prompt: the removed blocks sat between the Context + // section and "## Mandatory Cairn workflow". + return trimmed.replace( + "\n\n## Mandatory Cairn workflow", + `\n\n${LEGACY_CODING_TOOLS}\n\n${LEGACY_CAIRN_TOOLS}\n\n## Mandatory Cairn workflow`, + ); +} + +// ── Offline: structural + deterministic token savings ───────────────────────── + +describe("pi-agent system prompt trim (offline)", () => { + const trimmed = executePrompt({ taskTitle: "Fix bug" }); + const legacy = legacyExecutePrompt({ taskTitle: "Fix bug" }); + + it("drops the redundant tool-listing sections from execute mode", () => { + expect(trimmed).not.toContain("## Coding tools"); + expect(trimmed).not.toContain("## Cairn tools"); + expect(trimmed).not.toContain("- **read** — read file contents"); + expect(trimmed).not.toContain("**get_active_context** — get IDs"); + }); + + it("keeps the base identity, context, and mandatory workflow intact", () => { + expect(trimmed).toContain("You are the Cairn coding agent"); + expect(trimmed).toContain("## Context"); + expect(trimmed).toContain("## Mandatory Cairn workflow"); + expect(trimmed).toContain("**1. Orient (first thing, once per session)**"); + expect(trimmed).toContain("## Coding guidelines"); + // Tool guidance that the workflow still depends on survives as prose. + expect(trimmed).toContain("Always use `ensure_note` to write notes"); + expect(trimmed).toContain("get_active_context"); + }); + + it("drops the redundant tool-listing section from plan mode", () => { + const plan = buildPiAgentSystemPrompt({ projectName: "P", cwd: "/p", mode: "plan" }); + expect(plan).not.toContain("## Available tools"); + expect(plan).not.toContain("- **ask_questions** — render an inline question form"); + }); + + it("sends measurably fewer system-prompt tokens than the legacy prompt", () => { + const saved = tok(legacy) - tok(trimmed); + // The removed listings are ~15 tool bullets ≈ several hundred tokens. + expect(saved).toBeGreaterThan(100); + expect(tok(trimmed)).toBeLessThan(tok(legacy)); + console.log(`\nCoding-agent system prompt: ${tok(trimmed)} tok (was ${tok(legacy)}) — ${saved} tok saved per turn (${Math.round((saved / tok(legacy)) * 100)}%).`); + }); +}); + +// ── Live: same-model A/B of tool selection ──────────────────────────────────── + +interface ToolSelTask { + id: string; + prompt: string; + /** Tools that count as a correct first call. */ + expected: string[]; +} + +const TOOL_TASKS: ToolSelTask[] = [ + { id: "ls", prompt: "List the files in this directory.", expected: ["ls"] }, + { id: "find", prompt: "Find the file named package.json in this project.", expected: ["find"] }, + { id: "grep", prompt: "Search the project files for the string OPENAI_API_KEY.", expected: ["grep"] }, + { id: "read", prompt: "Read the contents of src/hello.ts.", expected: ["read"] }, + { id: "note", prompt: "Create a note titled 'Hello World' in this project.", expected: ["ensure_note"] }, + { id: "tasks", prompt: "List the open tasks in this project.", expected: ["search_tasks", "list_ready_tasks"] }, + { id: "notes", prompt: "Search notes for the word 'benchmark'.", expected: ["search_notes"] }, +]; + +const WS = "ws-trim"; +const PROJ = "proj-trim"; +const COL_TODO = "col-todo"; +const COL_DONE = "col-done"; + +function seed(db: Database.Database) { + createWorkspace(db, { id: WS, name: "Trim Workspace" }); + createProject(db, { id: PROJ, workspaceId: WS, name: "Cairn", description: "Desktop app", priority: "high", icon: "🪨" }); + createColumn(db, { id: COL_TODO, projectId: PROJ, workspaceId: WS, name: "Todo", type: "todo", order: 0 }); + createColumn(db, { id: COL_DONE, projectId: PROJ, workspaceId: WS, name: "Done", type: "done", order: 1 }); + createNote(db, { id: "n-bench", projectId: PROJ, workspaceId: WS, title: "Benchmark findings", content: "Tool-array context cost benchmark results." }); + createCard(db, { id: "c-1", columnId: COL_TODO, projectId: PROJ, workspaceId: WS, title: "Trim the system prompt", description: "", priority: "medium", tagIds: [] }); +} + +/** A tiny real workspace so ls/grep/find/read have content (no repo writes). */ +function seedWorkspaceDir(): { dir: string; cleanup: () => void } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cairn-trim-")); + fs.mkdirSync(path.join(dir, "src"), { recursive: true }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "trim-test", version: "1.0.0" })); + fs.writeFileSync(path.join(dir, "src", "hello.ts"), "export const greet = () => 'hello';\n// OPENAI_API_KEY is never committed here\n"); + return { dir, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) }; +} + +async function toolCallsFor( + db: Database.Database, + cwd: string, + systemPrompt: string, + task: ToolSelTask, +): Promise<{ called: Set; promptTokens: number; toolErrors: number }> { + const req: ChatRequest = { + message: task.prompt, + threadId: "trim-thread", + workspaceId: WS, + projectId: PROJ, + config: { maxSteps: 6, temperature: 0 }, + }; + const messages = [ + { role: "system" as const, content: systemPrompt }, + { role: "user" as const, content: task.prompt }, + ]; + const called = new Set(); + let promptTokens = 0; + let toolErrors = 0; + await runToolLoop( + db, req, cwd, BASE_URL, MODEL, API_KEY, messages, + (e) => { called.add(e.tool); }, + undefined, undefined, "openai", + (pt) => { promptTokens += pt; }, // accumulate across tool-loop rounds + (e) => { if (e.output) { try { if (JSON.parse(e.output)?.error) toolErrors += 1; } catch { /* ignore */ } } }, + ); + return { called, promptTokens, toolErrors }; +} + +describe.skipIf(!LIVE_TESTS_ENABLED)("pi-agent prompt trim — live A/B tool selection", () => { + let up = false; + beforeAll(async () => { up = await endpointUp(); }); + + it("selects the same correct tools with the trimmed prompt, with fewer prompt tokens", async () => { + if (!up) { + console.log(`[skip] No LLM endpoint reachable at ${BASE_URL}. Set TEST_LLM_BASE_URL/MODEL/API_KEY to run the live A/B.`); + return; + } + + const wd = seedWorkspaceDir(); + try { + const rows: string[] = ["task,legacy_called,trimmed_called,legacy_ptok,trimmed_ptok"]; + let legacyCorrect = 0; + let trimmedCorrect = 0; + let legacyTokens = 0; + let trimmedTokens = 0; + + for (const task of TOOL_TASKS) { + // Fresh DB per prompt so writes from one don't bias the other. + const legacy = await (async () => { + const db = new BetterSqlite3(":memory:"); + applySchema(db); + seed(db); + try { + return await toolCallsFor(db, wd.dir, legacyExecutePrompt({ taskTitle: "Trim prompt" }), task); + } finally { db.close(); } + })(); + const trimmed = await (async () => { + const db = new BetterSqlite3(":memory:"); + applySchema(db); + seed(db); + try { + return await toolCallsFor(db, wd.dir, executePrompt({ taskTitle: "Trim prompt" }), task); + } finally { db.close(); } + })(); + + // The workflow mandates get_active_context first, so check the expected + // tool appears SOMEWHERE in the call set, not as the first call. + const legacyOk = task.expected.some((t) => legacy.called.has(t)); + const trimmedOk = task.expected.some((t) => trimmed.called.has(t)); + if (legacyOk) legacyCorrect += 1; + if (trimmedOk) trimmedCorrect += 1; + legacyTokens += legacy.promptTokens; + trimmedTokens += trimmed.promptTokens; + + rows.push( + `${task.id},${[...legacy.called].join("|") || "—"},${[...trimmed.called].join("|") || "—"},${legacy.promptTokens},${trimmed.promptTokens}`, + ); + console.log( + `${task.id.padEnd(6)} legacy=${legacyOk ? "ok" : "MISS"} trimmed=${trimmedOk ? "ok" : "MISS"} ` + + `ptok ${legacy.promptTokens}→${trimmed.promptTokens} ` + + `calls ${[...legacy.called].join(",") || "—"} → ${[...trimmed.called].join(",") || "—"} err ${legacy.toolErrors}/${trimmed.toolErrors}`, + ); + } + + console.log("\n=== A/B summary ===\n" + rows.join("\n")); + console.log(`\nCorrect tool selection: legacy ${legacyCorrect}/${TOOL_TASKS.length}, trimmed ${trimmedCorrect}/${TOOL_TASKS.length}`); + console.log(`Prompt tokens (sum): legacy ${legacyTokens}, trimmed ${trimmedTokens} (${Math.round((1 - trimmedTokens / legacyTokens) * 100)}% less)`); + + // The trimmed prompt must select the correct tool at least as often as the + // legacy prompt, while sending strictly fewer prompt tokens. + expect(trimmedCorrect).toBeGreaterThanOrEqual(legacyCorrect); + expect(trimmedTokens).toBeLessThan(legacyTokens); + } finally { + wd.cleanup(); + } + }, 600_000); +}); diff --git a/electron/lib/pi-agent-prompt.ts b/electron/lib/pi-agent-prompt.ts index f7aeb4ad9..5cdc0c9f4 100644 --- a/electron/lib/pi-agent-prompt.ts +++ b/electron/lib/pi-agent-prompt.ts @@ -38,7 +38,7 @@ function buildPlanModePrompt(ctx: PiAgentPromptContext): string { : ""; const skillsSection = ctx.skillsXml - ? `\n- **skill** — load the full instructions for a skill listed below\n\n${ctx.skillsXml}` + ? `\n\n- **skill** — load the full instructions for a skill listed below\n\n${ctx.skillsXml}` : ""; return `You are the Cairn planning agent — an expert software engineer helping the user think through an implementation plan before any code is written. @@ -97,14 +97,7 @@ Unresolved items that need input before or during execution. Use \`ensure_note\` with the title **"Plan: "** — derive the feature name from what the user wants to build (e.g. "Plan: Dark mode toggle", "Plan: Export to CSV"). ${ctx.taskTitle ? `For this session use **"Plan: ${ctx.taskTitle}"**.` : "Pick a title that describes the specific feature, not just the project name."} Keep the same title on every turn so \`ensure_note\` updates the same note rather than creating duplicates. -## Available tools -- **ask_questions** — render an inline question form in the UI; use this to gather structured input from the user instead of asking in prose -- **read**, **grep**, **find**, **ls** — explore the codebase (read-only) -- **ensure_note** — write and update the PRD note -- **get_active_context**, **get_project_context_pack** — understand the project state -- **search_notes** / **get_note** — find and read existing notes (search_notes with empty query lists all) -- **search_notes_semantic** — natural-language note search via local embeddings; better than search_notes when concepts are described in different words (requires embeddings enabled) -- **search_tasks** / **get_task** / **list_ready_tasks** — read the board${skillsSection} +Before drafting or updating the PRD note, call \`get_user_writing_style\` and write it in the user's voice. If it reports configured:false, write clearly and naturally instead.${skillsSection} Tone: collaborative, curious, like a senior engineer helping clarify scope before diving in.`; } @@ -136,27 +129,7 @@ You are not just a code executor. You are an active participant in the project: ## Context **Project:** ${ctx.projectName}${taskLine} **Code directory:** ${ctx.cwd} -**Date:** ${date}${taskSection}${planSection} - -## Coding tools -- **read** — read file contents with line ranges -- **write** — write or overwrite a file entirely -- **edit** — make targeted string replacements (always read first to get exact content) -- **bash** — execute shell commands (tests, builds, git, grep, etc.) -- **grep** — search file contents with regex -- **find** — find files by name pattern -- **ls** — list directory contents -- **spawn_subagent** — delegate a contained, deep sub-task to a fresh agent with its own context window; only the final answer is returned to you - -## Cairn tools -- **get_active_context** — get IDs for the current workspace, project, and board columns -- **get_project_context_pack** — get full project state: tasks, notes, recent activity -- **ensure_note** — create-or-update a note by title (idempotent — use this for all note writes) -- **patch_note** / **append_to_note** — targeted edit or append to an existing note -- **search_notes** / **get_note** — find and read project notes -- **search_notes_semantic** — natural-language search via local embeddings (better than search_notes when concepts are described in different words; requires embeddings enabled) -- **create_task** / **update_task** — create tasks; update_task with \`columnId\` moves to a column -- **search_tasks** / **list_ready_tasks** — find tasks; list_ready_tasks returns only unblocked work${skillsSection} +**Date:** ${date}${taskSection}${planSection}${skillsSection} ## Mandatory Cairn workflow @@ -190,6 +163,7 @@ Before writing your final response to the user: 4. **Be concise in your final reply.** Summarise what you did and why — don't repeat file contents back. 5. **Security.** Never read or write outside the project's code directory. 6. **Use \`spawn_subagent\` for deep sub-tasks** — e.g. "research all usages of X", "refactor this module end-to-end", "investigate and summarise the bug". The subagent has the same tools. Pass it a fully self-contained prompt. +7. **Write in the user's voice.** When drafting prose for the user (release notes, PRDs, session summaries, replies), call \`get_user_writing_style\` first and match it. If it reports configured:false, write clearly and naturally instead. Tone: direct, technical, like a senior engineer pairing with the user.`; } diff --git a/electron/lib/tool-schemas.ts b/electron/lib/tool-schemas.ts index 6ed2fc574..082f2f6b3 100644 --- a/electron/lib/tool-schemas.ts +++ b/electron/lib/tool-schemas.ts @@ -52,6 +52,13 @@ export const TOOL_SCHEMAS = { schema: z.object({}), }, + get_user_writing_style: { + description: "Return the user's personal writing style — their persona plus a style guide to match when drafting or rewriting content IN the user's voice (emails, notes, PRDs, replies). Prefer the condensed 'cheatsheet' for everyday drafting; request 'full' for long-form work or deep analysis. Returns configured:false when the user hasn't set one up — never invent a style.", + schema: z.object({ + mode: z.enum(["cheatsheet", "full"]).optional().describe("'cheatsheet' (default) = condensed one-page reference; 'full' = the complete style guide"), + }), + }, + get_note: { description: "Get the full content of a note by ID.", schema: z.object({ noteId: sId }), diff --git a/electron/lib/tools.ts b/electron/lib/tools.ts index 902a1286b..eeeeb31b9 100644 --- a/electron/lib/tools.ts +++ b/electron/lib/tools.ts @@ -7,6 +7,7 @@ import * as z from "zod"; import { TOOL_SCHEMAS, CHAT_ONLY_TOOLS, AGENT_EXCLUDED_TOOLS } from "./tool-schemas"; +import { MAX_PERSONALITY_PROMPT_CHARS } from "../../shared/chat/registry-schema"; import type { ToolName } from "./tool-schemas"; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -17,6 +18,7 @@ export const TOOL_LABELS: Record string> = { get_cairn_context: () => "Reading workspace context", get_project_context_pack: () => "Reading project context pack", get_active_context: () => "Reading active context", + get_user_writing_style: (a) => (a.mode === "full" ? "Reading your full writing style" : "Reading your writing style cheat sheet"), get_note: () => "Reading note", search_notes: (a) => `Searching notes for "${a.query}"`, search_notes_semantic: (a) => `Semantic search for "${a.query}"`, @@ -142,8 +144,14 @@ export interface ChatRequest { projectId?: string; workspaceId?: string; history?: ChatHistoryEntry[]; - config?: { baseUrl?: string; model?: string; apiKey?: string; maxSteps?: number; temperature?: number; maxTokens?: number; isReasoningModel?: boolean }; + config?: { baseUrl?: string; model?: string; apiKey?: string; maxSteps?: number; temperature?: number; maxTokens?: number; isReasoningModel?: boolean; reasoningEffort?: "none" | "low" | "high" | "max" }; systemPrompt?: string; + /** + * Active chat personality for this turn — a style layer appended verbatim + * after the system prompt. `name` is the display label; `prompt` holds the + * behavioral rules. Absent = no personality (default Cairn behavior). + */ + personality?: { name: string; prompt: string }; /** Attachments on the current user message (base64 data URLs; kind="pdf" * becomes an Anthropic-style `document` part, images become `image_url`). */ images?: Array<{ name: string; dataUrl: string; kind?: "image" | "pdf" }>; @@ -169,10 +177,32 @@ Use the provided tools to read and modify the user's workspace. Choose the tool - **Writes:** Call the tool directly (no confirmation needed), then briefly confirm what you did. - **Suggesting connections:** When proposing new links, wikilinks, note↔card links, or tags, you MUST call \`suggest_connections\` (the UI renders "Apply" buttons) rather than describing them in prose. - **Rendering:** Replies are markdown — use bold, lists, tables, fenced code (with language), and mermaid fenced blocks for diagrams. Keep replies concise and actionable. +- **Writing in the user's voice:** When the user asks you to draft or rewrite content that sounds like them (emails, replies, notes, PRDs), call \`get_user_writing_style\` first and match it. If it reports configured:false, write in a natural, clear voice instead. Tone: calm, focused, like a thoughtful co-worker.`; } +/** + * Append the active chat personality to a built system prompt as a clearly + * delimited style LAYER. A personality is never a replacement identity — the + * base "You are the Cairn AI assistant" prompt stays intact and the behavioral + * rules are added under their own header so the model treats them as session + * style guidance rather than a conflicting persona. + */ +export function withPersonality( + systemPrompt: string, + personality?: { name: string; prompt: string }, +): string { + if (!personality || !personality.name || !personality.prompt) return systemPrompt; + // Guard the appended layer against an oversized prompt (same shared ceiling + // the registry schema + create forms enforce — truncate defensively so an + // over-long persisted prompt can't bloat the system prompt). + const prompt = personality.prompt.length > MAX_PERSONALITY_PROMPT_CHARS + ? personality.prompt.slice(0, MAX_PERSONALITY_PROMPT_CHARS) + : personality.prompt; + return `${systemPrompt}\n\n## Personality: ${personality.name}\n${prompt}`; +} + /** * System prompt for the interactive PRD generation micro-chat. * Scoped to a single project. The agent gathers context, asks focused diff --git a/electron/lib/user-style-generation.live.test.ts b/electron/lib/user-style-generation.live.test.ts new file mode 100644 index 000000000..0e97d559d --- /dev/null +++ b/electron/lib/user-style-generation.live.test.ts @@ -0,0 +1,131 @@ +/** + * Cairn — Writing Style generation live test + * + * Drives the EXACT code path the wizard uses (`generateUserStyleMarkdown` in + * ipc/user-style-handlers.ts → buildUserStyleFullGuidePrompt + callLLM + + * isUsableGuide gate + retry) against a real endpoint, and asserts both the + * FULL guide and the CHEAT SHEET come back as usable structured markdown — + * not "token soup". + * + * Uses the repo's standard live-test convention (bench-endpoint): + * CAIRN_LIVE_TESTS=1 \ + * TEST_LLM_BASE_URL=... TEST_LLM_MODEL=... TEST_LLM_API_KEY=... \ + * npx vitest run electron/lib/user-style-generation.live.test.ts + * + * To reproduce a user's in-app setup (e.g. the ocgo proxy + deepseek-v4-flash), + * point TEST_LLM_* at that endpoint/model/key. The scenario mirrors what a real + * user does: persona + pasted samples (including a long document), no gap + * answers. + */ + +import { describe, it, expect, beforeAll } from "vitest"; +import Database from "better-sqlite3"; +import { applySchema } from "../db/schema"; +import { createWorkspace, createProject, createNote, createColumn } from "../db/queries"; +import { runToolLoop } from "./chat-loop"; +import { generateUserStyleMarkdown, isUsableGuide, writingStyleToolsOverride, buildUserStylePromptPair } from "../ipc/user-style-handlers"; +import type { UserStyleGenerationInput } from "./user-style-prompt"; +import { BASE_URL, MODEL, API_KEY, endpointUp, LIVE_TESTS_ENABLED } from "./bench-endpoint"; +import type { LLMConfig } from "./llm"; + +const config: LLMConfig = { provider: "openai", baseUrl: BASE_URL, model: MODEL, apiKey: API_KEY }; + +/** Realistic sample set — technical, DM, and a long pasted doc (caps exercise). */ +function realisticInput(): UserStyleGenerationInput { + return { + persona: { name: "Alex", role: "Engineering lead", context: "Health-tech startup", audiences: "engineers, execs, customers" }, + samples: [ + { + context: "Technical reply", + text: "not a crash, just a timing assertion. Flaky on slow CI, which is why the prior runs passed. Fix in #3040: preload outside the lock so swaps aren't starved. Its one of the last blockers remaining so it needs to get in to test.", + }, + { + context: "Team message / DM", + text: "On it. Will fix. Also - why are you working at 6am? Keep me posted and happy to answer any questions.", + }, + { + context: "Pasted document (long)", + text: Array.from({ length: 8 }, (_, i) => `### Section ${i + 1}\nThis is a longer block of prose the user pasted in — describing how they write, format, open messages, close them, give feedback, and what to avoid. They use plain hyphens, never em-dashes. Warm and direct, praise then a concrete ask. Sign off with 'Keep me posted'.`).join("\n\n"), + }, + ], + answers: [], + }; +} + +describe.skipIf(!LIVE_TESTS_ENABLED)("Writing Style generation (live)", () => { + let up = false; + beforeAll(async () => { up = await endpointUp(); }); + + it("produces a usable FULL guide from samples (not token soup)", async () => { + if (!up) { + console.log(`[skip] No LLM endpoint reachable at ${BASE_URL}. Set TEST_LLM_BASE_URL/MODEL/API_KEY to run.`); + return; + } + const markdown = await generateUserStyleMarkdown(config, "full", realisticInput()); + console.log(`\n=== FULL guide (${MODEL}) — ${markdown.length} chars, ${(markdown.match(/^\s*#{1,2}\s+/gm) ?? []).length} headings ===\n${markdown.slice(0, 600)}…`); + expect(isUsableGuide(markdown, "full")).toBe(true); + }, 300_000); + + it("produces a usable CHEAT SHEET from the generated full guide", async () => { + if (!up) { + console.log("[skip] endpoint not reachable."); + return; + } + const full = await generateUserStyleMarkdown(config, "full", realisticInput()); + const cheat = await generateUserStyleMarkdown(config, "cheatsheet", { ...realisticInput(), fullGuide: full }); + console.log(`\n=== CHEAT SHEET (${MODEL}) — ${cheat.length} chars, ${(cheat.match(/^\s*#{1,2}\s+/gm) ?? []).length} headings ===\n${cheat.slice(0, 600)}…`); + expect(isUsableGuide(cheat, "cheatsheet")).toBe(true); + expect(cheat.length).toBeLessThan(full.length * 2); // condensed, not a rewrite + }, 300_000); + + it("streams through runToolLoop with the read-only tools and produces a usable guide (analyse path)", async () => { + if (!up) { + console.log("[skip] endpoint not reachable."); + return; + } + + // Seed a tiny workspace so search_notes/get_note have real content. + const db = new Database(":memory:"); + applySchema(db); + createWorkspace(db, { id: "ws", name: "WS" }); + createProject(db, { id: "proj", workspaceId: "ws", name: "Demo", description: "", priority: "medium" }); + createColumn(db, { id: "col", projectId: "proj", workspaceId: "ws", name: "Todo", type: "todo", order: 0 }); + createNote(db, { id: "n1", projectId: "proj", workspaceId: "ws", title: "Alpha", content: "We write terse, warm notes. Plain hyphens, never em-dashes. Praise first, then a concrete ask." }); + createNote(db, { id: "n2", projectId: "proj", workspaceId: "ws", title: "Beta", content: "Squad chats are lowercase and quick. Sign off 'Keep me posted'." }); + + try { + const input = realisticInput(); + // Reuse the handler's prompt pair + read-only tool override so the test + // always reflects the handler's current configuration. + const { systemPrompt, userPrompt: baseUserPrompt } = buildUserStylePromptPair("full", input); + const userPrompt = baseUserPrompt + + "\n\n## Active context\nProject: Demo\nWorkspace ID: ws\nProject ID: proj\nRead the user's notes with search_notes/get_note (scope to project proj) to ground the guide."; + const req = { message: userPrompt, threadId: "us-test", workspaceId: "ws", projectId: "proj", config: { maxSteps: 4, temperature: 0.3 } }; + const messages = [ + { role: "system" as const, content: systemPrompt }, + { role: "user" as const, content: userPrompt }, + ]; + const toolsOverride = writingStyleToolsOverride(true); + + const tokens: string[] = []; + const toolCalls: string[] = []; + const result = await runToolLoop( + db, req as never, "/tmp", BASE_URL, MODEL, API_KEY, messages, + (e) => toolCalls.push(e.tool), + undefined, undefined, "openai", + undefined, undefined, + (d) => tokens.push(d), + undefined, + [], + toolsOverride, + ); + const content = result.content; + console.log(`\n=== STREAMED (${MODEL}) — ${content.length} chars, ${(content.match(/^\s*#{1,2}\s+/gm) ?? []).length} headings, toolCalls=[${toolCalls.join(",") || "none"}] ===\n${content.slice(0, 400)}…`); + expect(tokens.length).toBeGreaterThan(0); // actually streamed + expect(toolCalls).toContain("search_notes"); // used the read-only tools + expect(isUsableGuide(content, "full")).toBe(true); + } finally { + db.close(); + } + }, 300_000); +}); diff --git a/electron/lib/user-style-prompt.ts b/electron/lib/user-style-prompt.ts new file mode 100644 index 000000000..5923857b9 --- /dev/null +++ b/electron/lib/user-style-prompt.ts @@ -0,0 +1,144 @@ +/** + * System prompts for the Writing Style guided generator. + * + * Step 1 ("full") analyzes the user's persona, pasted sample messages, and + * answers to the gap questions, and produces the FULL writing style guide — + * structured into the canonical section taxonomy (rhythm, tone/register, + * openings/closings, punctuation, vocabulary, feedback, emoji, formatting, + * anti-patterns, range). Step 2 ("cheatsheet") condenses the full guide into + * a one-page drafting reference. + * + * Both prompts demand markdown only, no preamble, so the output can be saved + * verbatim. The anti-pattern rule (no em-dashes) is deliberately the strongest + * signal: it is the canonical "AI tell" the whole feature exists to prevent. + */ + +export interface UserStyleGenerationInput { + persona: { + name?: string; + role?: string; + context?: string; + audiences?: string; + }; + /** Sample messages pasted by the user, tagged by context. */ + samples: Array<{ context: string; text: string }>; + /** Answers to the gap questions. */ + answers: Array<{ question: string; answer: string }>; + /** Existing full guide — required for the "cheatsheet" step. */ + fullGuide?: string; +} + +const PERSONA_BLOCK = (p: UserStyleGenerationInput["persona"]) => { + const parts = [ + p.name ? `Name: ${p.name}` : "", + p.role ? `Role: ${p.role}` : "", + p.context ? `Context: ${p.context}` : "", + p.audiences ? `Audiences: ${p.audiences}` : "", + ].filter(Boolean); + return parts.length ? parts.join("\n") : "(none provided)"; +}; + +/** Build the system prompt for generating the FULL writing style guide. */ +export function buildUserStyleFullGuidePrompt(input: UserStyleGenerationInput): string { + // Bound the raw material: a pasted document (e.g. an existing style guide) + // can run tens of thousands of chars and drown a smaller model — truncate + // each sample and cap the combined block so the analysis prompt stays + // digestible. Truncation is lossy but the style *signals* survive. + const MAX_SAMPLE_CHARS = 2000; + const MAX_TOTAL_CHARS = 12_000; + let total = 0; + const bounded: string[] = []; + for (const s of input.samples) { + const text = s.text.slice(0, MAX_SAMPLE_CHARS); + if (total + text.length > MAX_TOTAL_CHARS) { + bounded.push(`### ${s.context}\n${text.slice(0, Math.max(0, MAX_TOTAL_CHARS - total))}`); + break; + } + total += text.length; + bounded.push(`### ${s.context}\n${text}`); + } + const samples = bounded.length + ? bounded.join("\n\n") + : "(no samples provided)"; + const answers = input.answers.length + ? input.answers.map((a) => `- ${a.question}: ${a.answer || "(no answer)"}`).join("\n") + : "(no answers provided)"; + + return `You are a writing-style analyst. Analyse the user's real messages and answers below and produce a complete "Writing Style Guide" — a reference document an AI can use to draft content that sounds like the user. + +Follow this exact structure, with ## headings. Write EVERY section, but keep the whole document focused and evidence-based — quote the user's own words as examples where possible. Do not invent traits that the evidence contradicts; mark uncertain items as such. + +## 1. Voice in one line +## 2. Sentence Length & Rhythm +## 3. Tone & Register +## 4. Openings & Closings +## 5. Punctuation Habits +## 6. Vocabulary & Phrases (signature phrases, then "Avoid") +## 7. How I Give Feedback or Disagree +## 8. Emoji Use +## 9. Formatting +## 10. Other Distinctive Patterns +## 11. Anti-Patterns — Does NOT Sound Like Me +## 12. Preserve These Voice Tells + +Rules: +- Output ONLY the markdown guide. No preamble, no commentary, no code fence. +- The "Anti-Patterns" section must be explicit and specific (e.g. a hard rule like "never use em-dashes (—), use a plain hyphen" if that matches the evidence). +- "Preserve These Voice Tells" lists quirks to KEEP when drafting (dropped apostrophes, dropped question marks, spelling variants) — only if present in the samples. +- Base everything on the samples and answers. If the user pasted technical and casual messages, capture the register range across contexts. + +## Persona +${PERSONA_BLOCK(input.persona)} + +## Sample messages (real writing) +${samples} + +## Answers to style questions +${answers}`; +} + +/** Build the system prompt for condensing the full guide into the cheat sheet. */ +export function buildUserStyleCheatsheetPrompt(fullGuide: string): string { + return `You are an editor. Condense the Writing Style Guide below into a one-page "Cheat Sheet" — a quick drafting reference the user can glance at while an AI writes in their voice. + +Requirements: +- Output ONLY markdown. No preamble, no commentary, no code fence. +- Lead with a "Voice in one line" (a single sentence) and a "Two gears" line if the guide describes context-dependent registers. +- Keep the highest-signal rules under tight subsections: Rhythm, Tone & Register, Openings & Closings, Questions & Requests, Feedback & Mistakes, Signature Phrases, Formatting, Emoji. +- Keep an "⚠️ Anti-Patterns (instant tells — never do)" section VERBATIM in spirit — the no-em-dash rule (or equivalent) must survive intact, worded as a hard rule. +- Keep a "Preserve (don't 'fix')" line for the listed voice tells. +- Aim for roughly a third of the original length. Quotes/examples should be one-liners. + +## Writing Style Guide +${fullGuide}`; +} + +/** Build the system prompt for optimizing/restructuring an EXISTING full guide. */ +export function buildUserStyleOptimizePrompt(fullGuide: string): string { + return `You are an editor optimizing an existing writing style guide. Restructure the guide below into the canonical 12-section format so an AI can use it to draft in the user's voice. Produce ONLY the optimized guide, in clean Markdown with ## headings. + +## Canonical structure (use exactly these headings) +## 1. Voice in one line +## 2. Sentence Length & Rhythm +## 3. Tone & Register +## 4. Openings & Closings +## 5. Punctuation Habits +## 6. Vocabulary & Phrases +## 7. How I Give Feedback or Disagree +## 8. Emoji Use +## 9. Formatting +## 10. Other Distinctive Patterns +## 11. Anti-Patterns — Does NOT Sound Like Me +## 12. Preserve These Voice Tells + +Rules: +- Preserve every concrete, evidence-based detail from the source; move each into its matching section. +- Keep signature phrases, quoted examples, and the emoji lexicon verbatim. +- Keep the Anti-Patterns section explicit and hard (e.g. "never use em-dashes — use a plain hyphen" if that's a rule). +- Fill genuinely missing sections with a short "Uncertain — not covered in the source." line rather than inventing traits. +- Do NOT invent style traits the source doesn't support. +- Output ONLY the optimized guide. No preamble, no commentary, no code fence. + +## Existing guide to optimize +${fullGuide}`; +} diff --git a/electron/lib/with-personality.test.ts b/electron/lib/with-personality.test.ts new file mode 100644 index 000000000..68a5736d4 --- /dev/null +++ b/electron/lib/with-personality.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import { withPersonality } from "./tools"; + +describe("withPersonality", () => { + const base = "You are the Cairn AI assistant — an intelligent helper."; + + it("returns the prompt unchanged when no personality is given", () => { + expect(withPersonality(base, undefined)).toBe(base); + expect(withPersonality(base, { name: "", prompt: "" })).toBe(base); + }); + + it("appends the personality as a delimited style layer", () => { + const out = withPersonality(base, { name: "Caveman", prompt: "Speak short." }); + expect(out).toContain("## Personality: Caveman"); + expect(out).toContain("Speak short."); + expect(out.indexOf(base)).toBe(0); // the base Cairn identity stays first + }); +}); diff --git a/electron/mcp/tools/index.ts b/electron/mcp/tools/index.ts index 80eb5213f..3ddd4d3bc 100644 --- a/electron/mcp/tools/index.ts +++ b/electron/mcp/tools/index.ts @@ -23,6 +23,7 @@ import { codebase_get_references, codebase_get_file_symbols } from "./codebase"; +import { getUserStyle } from "../../db/queries"; // eslint-disable-next-line @typescript-eslint/no-explicit-any export function executeTool(db: Database.Database, workspacePath: string, toolName: string, args: Record): unknown { @@ -178,6 +179,29 @@ export function executeTool(db: Database.Database, workspacePath: string, toolNa case "codebase_get_file_symbols": return codebase_get_file_symbols(db, args as Parameters[1]); + case "get_user_writing_style": { + const mode = (args.mode as "cheatsheet" | "full" | undefined) ?? "cheatsheet"; + const style = getUserStyle(db); + const configured = !!style && style.source !== "none" && !!(style.fullGuide || style.cheatsheet); + if (!configured) { + return { + configured: false, + message: "No writing style set up yet — the user hasn't configured one in Settings → Writing Style. Draft in the user's natural, clear voice; do not invent a style guide.", + mode, + markdown: null, + persona: null, + updatedAt: null, + }; + } + return { + configured: true, + mode, + markdown: mode === "full" ? style.fullGuide || style.cheatsheet : style.cheatsheet || style.fullGuide, + persona: style.persona, + updatedAt: style.updatedAt, + }; + } + default: return { error: `Unknown tool: ${toolName}` }; } diff --git a/electron/preload.ts b/electron/preload.ts index b8cb7fccc..7ace6723a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -31,6 +31,25 @@ interface CustomServiceConfig { interface ToolAttachment { projectId: string; toolType: "mcp" | "service"; toolId: string; enabled: boolean; } +// ── User writing style (persona + full guide + cheat sheet) ────────────────── +interface UserStylePersona { + name?: string; role?: string; context?: string; audiences?: string; +} +interface UserStyleRow { + id: string; persona: UserStylePersona | null; + fullGuide: string; cheatsheet: string; + source: "none" | "guided" | "manual" | "analyzed"; updatedAt: string; +} +interface UserStyleSaveInput { + persona?: UserStylePersona; fullGuide?: string; cheatsheet?: string; + source: "none" | "guided" | "manual" | "analyzed"; +} +interface UserStyleGenerationInput { + persona: UserStylePersona; + samples: Array<{ context: string; text: string }>; + answers: Array<{ question: string; answer: string }>; + fullGuide?: string; +} // ── Community registry (cairn-community manifest) ─────────────────────────── interface RegistryEntryMeta { id: string; author: string; version: string; category?: string; tags: string[]; blurb: string; @@ -94,6 +113,15 @@ interface AutomationsManifest { interface AutomationsFetchResult { manifest: AutomationsManifest; fromCache: boolean; cachedAt?: string; error?: string; } +interface RegistryPersonalityEntry extends RegistryEntryMeta { + definition: { name: string; description?: string; prompt: string }; +} +interface PersonalitiesManifest { + version: number; updatedAt: string; personalities: RegistryPersonalityEntry[]; +} +interface PersonalitiesFetchResult { + manifest: PersonalitiesManifest; fromCache: boolean; cachedAt?: string; error?: string; +} // ── Inline types for the codebase index / Architecture tab ────────────────── interface CodebaseSymbol { id: string; file_id: string; name: string; kind: string; line: number; @@ -521,6 +549,43 @@ const api = { resetAllData: () => invoke("app:reset"), getAiSettings: () => invoke | null>("app:getAiSettings"), saveAiSettings: (config: Record) => invoke<{ ok: true }>("app:saveAiSettings", { config }), + // User writing style (persona + full guide + cheat sheet) — Settings → Writing Style. + getUserStyle: () => invoke("user-style:get"), + saveUserStyle: (input: UserStyleSaveInput) => invoke("user-style:save", { input }), + clearUserStyle: () => invoke<{ ok: true }>("user-style:clear"), + generateUserStyle: (step: "full" | "cheatsheet" | "optimize", input: UserStyleGenerationInput) => + invoke<{ markdown: string }>("user-style:generate", { step, input }), + // Streaming generation (wizard) — fire-and-forget; listen via onUserStyle*. + // Credentials are resolved main-side (resolveChatConfig), never sent here. + generateUserStyleStream: (req: { + workspaceId?: string; + projectId?: string; + projectName?: string; + step: "full" | "cheatsheet" | "optimize"; + analyseNotes: boolean; + input: UserStyleGenerationInput; + }) => ipcRenderer.send("user-style:generateStream", req), + abortUserStyleStream: () => ipcRenderer.send("user-style:abort"), + onUserStyleToken: (cb: (e: { delta: string }) => void) => { + const handler = (_: unknown, e: { delta: string }) => cb(e); + ipcRenderer.on("user-style:token", handler); + return () => ipcRenderer.off("user-style:token", handler); + }, + onUserStyleToolCall: (cb: (e: { tool: string; label: string; args: Record }) => void) => { + const handler = (_: unknown, e: { tool: string; label: string; args: Record }) => cb(e); + ipcRenderer.on("user-style:tool-call", handler); + return () => ipcRenderer.off("user-style:tool-call", handler); + }, + onUserStyleToolCallDone: (cb: (e: { tool: string; ok?: boolean; error?: string }) => void) => { + const handler = (_: unknown, e: { tool: string; ok?: boolean; error?: string }) => cb(e); + ipcRenderer.on("user-style:tool-call-done", handler); + return () => ipcRenderer.off("user-style:tool-call-done", handler); + }, + onUserStyleDone: (cb: (e: { content: string; usable: boolean; error?: string }) => void) => { + const handler = (_: unknown, e: { content: string; usable: boolean; error?: string }) => cb(e); + ipcRenderer.on("user-style:done", handler); + return () => ipcRenderer.off("user-style:done", handler); + }, getAgentSettings: () => invoke | null>("app:getAgentSettings"), saveAgentSettings: (config: Record) => invoke<{ ok: true }>("app:saveAgentSettings", { config }), getTheme: () => invoke("app:getTheme"), @@ -856,6 +921,10 @@ const api = { fetchAutomations: () => invoke("registry:fetchAutomations"), /** Force a network refresh of the automations manifest. */ refreshAutomations: () => invoke("registry:refreshAutomations"), + /** Community personalities (separate personalities.json manifest). Cache-first. */ + fetchPersonalities: () => invoke("registry:fetchPersonalities"), + /** Force a network refresh of the personalities manifest. */ + refreshPersonalities: () => invoke("registry:refreshPersonalities"), }, // ── Git operations (Agent Git tab) ──────────── diff --git a/scripts/features.config.js b/scripts/features.config.js index f7f5faf19..53962ca21 100644 --- a/scripts/features.config.js +++ b/scripts/features.config.js @@ -233,6 +233,32 @@ const FEATURES = [ "Persists Per Session: Todos are stored with the session and restored when you resume it, so long-running work survives restarts.", ], }, + { + id: "v2.6.12-chat-personalities", + version: "v2.6.12", + title: "Chat Personalities", + category: "Chat", + description: + "Pick a personality next to the model selector in the chat input to layer behavioral rules onto the system prompt. The catalog ships with Caveman, Grill Me, Junior to Senior, Last 20%, and Ponytail — all with full prompt transparency — plus a way to browse more from the community or write your own.", + highlights: [ + "Session Style Layer: A personality is appended to the system prompt as behavioral rules — Default keeps the exact current assistant with no layer.", + "Community Catalog: Browse and install ready-made personalities from cairn-community in Settings → AI & Chat → Chat personality; the full prompt is shown before you install.", + "Write Your Own: Create custom rules in the picker or Settings — e.g. 'Always talk in ASD-STE100 Simplified English'.", + ], + }, + { + id: "v2.6.12-writing-style", + version: "v2.6.12", + title: "Writing Style", + category: "Chat", + description: + "Set up your writing voice once in Settings → Writing Style: a short guided session builds a full style guide plus a condensed cheat sheet from who you are, a few pasted messages, and your notes. Then chat and the coding agent can call get_user_writing_style to draft emails, replies, notes, and PRDs in your voice.", + highlights: [ + "Guided Setup: Persona → paste real messages (or analyse your notes & tasks) → a few questions → generated full guide → condensed cheat sheet, both editable.", + "Your Voice Everywhere: get_user_writing_style is exposed to chat and the agent — it returns the cheat sheet by default and is only fetched when actually drafting.", + "Full + Cheat Sheet: The tool never injects your full guide into every prompt — it fetches on demand to keep context lean.", + ], + }, ]; module.exports = { FEATURES }; diff --git a/shared/chat/registry-schema.ts b/shared/chat/registry-schema.ts index aa5fe47d9..52b30dea6 100644 --- a/shared/chat/registry-schema.ts +++ b/shared/chat/registry-schema.ts @@ -17,6 +17,14 @@ import * as z from "zod"; +/** + * Shared ceiling for a personality prompt (behavioural rules appended verbatim + * to the chat system prompt). Enforced by the registry schema (zod .max), the + * custom-personality forms (maxLength) and withPersonality (truncation) so an + * oversized prompt can never bloat the system prompt. + */ +export const MAX_PERSONALITY_PROMPT_CHARS = 4000; + // ── Types (mirror src/types/index.ts; wired to the renderer by IPC strings) ── export interface RegistryEntryMeta { @@ -226,6 +234,35 @@ export interface ProvidersManifest { providers: RegistryProviderEntry[]; } +/** + * A community personality — a set of behavioral rules appended verbatim to + * Cairn's chat system prompt to shape the assistant's tone and style. Installed + * into `aiConfig.installedPersonalities` and picked next to the model selector + * in chat. Kept in a SEPARATE manifest (personalities.json) from the tools + * catalog so the two can evolve independently. + */ +export interface RegistryPersonalityEntry extends RegistryEntryMeta { + definition: { + /** Display name shown in the personality picker. */ + name: string; + /** One line shown in the browse card and picker list. */ + description?: string; + /** + * Behavioral rules appended to the chat system prompt. A style LAYER on the + * existing "You are the Cairn AI assistant" identity — must NOT open with a + * "You are …" identity claim (CI rejects those upstream). + */ + prompt: string; + }; +} + +/** The parsed cairn-community PERSONALITIES manifest (personalities.json). */ +export interface PersonalitiesManifest { + version: number; + updatedAt: string; + personalities: RegistryPersonalityEntry[]; +} + // ── validation (mirrors cairn-community/schema.json) ──────────────────────── const headers = z.record(z.string(), z.string()).optional(); @@ -466,3 +503,45 @@ export function parseProvidersManifest(raw: unknown): ProvidersManifest { }), } as ProvidersManifest; } + +// ── personalities manifest (personalities.json) ────────────────────────────── + +const personalityDefinition = z.object({ + name: z.string().min(1), + description: z.string().optional(), + // A personality is a STYLE LAYER on the existing Cairn assistant identity. An + // opening "You are …" claim would contradict the base system prompt, so it is + // rejected here (mirrors the cairn-community validator). Min 20 chars keeps + // thin one-liners that add nothing to the prompt out of the catalog; the max + // (shared MAX_PERSONALITY_PROMPT_CHARS) keeps the appended layer compact. + prompt: z + .string() + .min(20) + .max(MAX_PERSONALITY_PROMPT_CHARS) + .refine((p) => !/^you\s+are/i.test(p.trim()), { + message: "prompt must not start with a 'You are …' identity claim", + }), +}); + +const personalityEntry = z.object({ ...entryMeta, definition: personalityDefinition }).passthrough(); + +// Envelope-only validation; entries validated individually in +// parsePersonalitiesManifest so one bad entry can't blank the catalog. +const personalitiesManifestSchema = z.object({ + version: z.number(), + updatedAt: z.string(), + personalities: z.array(z.unknown()), +}); + +/** Parse + validate an unknown payload into a PersonalitiesManifest, or throw. */ +export function parsePersonalitiesManifest(raw: unknown): PersonalitiesManifest { + const m = personalitiesManifestSchema.parse(raw); + return { + version: m.version, + updatedAt: m.updatedAt, + personalities: m.personalities.flatMap((e) => { + const r = personalityEntry.safeParse(e); + return r.success ? [r.data] : []; + }), + } as PersonalitiesManifest; +} diff --git a/src/app/globals.css b/src/app/globals.css index c20d9aedc..2175674ff 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -43,6 +43,13 @@ is the fallback foreground when an entry ships no brandColor. */ --connector-chip-bg: #f5f4f2; --connector-chip-fg: #1a1a1a; + /* Mermaid subgraph label contrast candidates. FIXED (theme-independent) + near-black / near-white so a cluster label always contrasts with its + arbitrary fill regardless of the current theme — the lighter candidate is + used on dark fills and the darker on light fills. Not overridden in the + light block on purpose. */ + --mermaid-label-dark: #1a1917; + --mermaid-label-light: #e8e4dc; } /* Light theme — applied via data-theme="light" on */ diff --git a/src/components/chat/BrowsePersonalitiesModal.tsx b/src/components/chat/BrowsePersonalitiesModal.tsx new file mode 100644 index 000000000..9695c6ab7 --- /dev/null +++ b/src/components/chat/BrowsePersonalitiesModal.tsx @@ -0,0 +1,289 @@ +"use client"; + +/** + * Browse Community Personalities — install ready-made chat personalities from + * the cairn-community catalog. Fetches the separate personalities.json manifest + * (cache-first, refreshable), lists each entry with its full prompt text, and + * installs the chosen one into the shared installed list. Installed entries are + * NOT auto-selected — the user picks one in the personality picker afterwards. + * + * The full prompt is shown on every card because it is appended verbatim to the + * chat system prompt — transparency matters for untrusted community text. + */ + +import { useState, useEffect, useMemo, useCallback } from "react"; +import { RefreshCw, Loader2, Check, Download, WifiOff, Search, ExternalLink } from "lucide-react"; +import { ModalShell } from "@/components/ui/modal-shell"; +import { Button } from "@/components/ui/button"; +import { useCairnStore } from "@/store"; +import { useShallow } from "zustand/react/shallow"; +import { cn } from "@/lib/utils"; +import type { PersonalitiesFetchResult, RegistryPersonalityEntry } from "@/types"; + +const inputCls = + "w-full rounded border border-[var(--border)] bg-[var(--surface)] text-[var(--text-primary)] text-sm pl-8 pr-3 py-2 focus:outline-none"; + +const emptyResult: PersonalitiesFetchResult = { + manifest: { version: 1, updatedAt: "", personalities: [] }, + fromCache: false, +}; + +export function BrowsePersonalitiesModal({ onClose }: { onClose: () => void }) { + const { installedPersonalities, installCommunityPersonality } = useCairnStore( + useShallow((s) => ({ + // NOTE: no `?? []` inside the selector — a fresh array reference would + // break useShallow's snapshot caching (React infinite-loop guard). + installedPersonalities: s.aiConfig.installedPersonalities, + installCommunityPersonality: s.installCommunityPersonality, + })), + ); + + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [query, setQuery] = useState(""); + const [activeCategory, setActiveCategory] = useState(null); + const [installing, setInstalling] = useState(null); + const [installError, setInstallError] = useState(null); + + const load = useCallback(async (force: boolean) => { + const reg = window.electron?.registry; + if (!reg?.fetchPersonalities) { + setLoading(false); + return; + } + if (force) setRefreshing(true); + else setLoading(true); + try { + const r = force ? await reg.refreshPersonalities() : await reg.fetchPersonalities(); + setResult(r); + } catch (err) { + setResult({ ...emptyResult, error: err instanceof Error ? err.message : String(err) }); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + void load(false); // eslint-disable-line react-hooks/set-state-in-effect + }, [load]); + + const personalities = useMemo(() => result?.manifest.personalities ?? [], [result]); + + const categories = useMemo(() => { + const set = new Set(); + for (const p of personalities) if (p.category) set.add(p.category); + return [...set].sort(); + }, [personalities]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return personalities.filter((p) => { + if (activeCategory && p.category !== activeCategory) return false; + if (!q) return true; + return ( + p.definition.name.toLowerCase().includes(q) || + p.blurb.toLowerCase().includes(q) || + (p.category ?? "").toLowerCase().includes(q) || + p.tags.some((t) => t.toLowerCase().includes(q)) + ); + }); + }, [personalities, query, activeCategory]); + + // Installed state, keyed by communityId (== the entry id). + const isInstalled = useCallback( + (entry: RegistryPersonalityEntry): boolean => + (installedPersonalities ?? []).some((p) => p.communityId === entry.id), + [installedPersonalities], + ); + + const runInstall = useCallback( + async (entry: RegistryPersonalityEntry) => { + setInstalling(entry.id); + setInstallError(null); + try { + await installCommunityPersonality(entry); + } catch (err) { + setInstallError(err instanceof Error ? err.message : "Couldn't install the personality."); + } finally { + setInstalling(null); + } + }, + [installCommunityPersonality], + ); + + return ( + + Browse Community Personalities + + } + description="Install ready-made tone & style rules for chat. The full prompt is shown — it's appended to the system prompt verbatim." + > + {/* Toolbar: search + refresh */} +
+
+
+ + setQuery(e.target.value)} + /> +
+ +
+ + {categories.length > 0 && ( +
+ setActiveCategory(null)} /> + {categories.map((cat) => ( + setActiveCategory(cat)} /> + ))} +
+ )} +
+ + {/* Provenance / error banner */} + {result?.error && ( +
+ + {result.fromCache + ? "Showing the cached catalog — couldn't reach the registry." + : `Couldn't load the registry: ${result.error}`} +
+ )} + {installError && ( +
+ {installError} +
+ )} + + {/* List */} +
+ {loading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +

+ {personalities.length === 0 ? "No community personalities available." : "No matches."} +

+ ) : ( + filtered.map((entry) => { + const def = entry.definition; + const installed = isInstalled(entry); + const busy = installing === entry.id; + return ( +
+
+
+ +
+ +
+
+ {def.name} + {entry.category && ( + + {entry.category} + + )} + by {entry.author} + {entry.homepage && ( + + source + + )} +
+

{entry.blurb}

+ {def.description && ( +

{def.description}

+ )} + {/* Full prompt — transparency: this text is appended verbatim + to the chat system prompt. */} +
+

+ System prompt layer +

+

+ {def.prompt} +

+
+
+ +
+ {installed ? ( + + Added + + ) : ( + + )} +
+
+
+ ); + }) + )} +
+ +

+ Added personalities join your installed list. Select one in the personality + picker next to the model selector in chat — "None" means no personality. +

+
+ ); +} + +function TagChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) { + return ( + + ); +} diff --git a/src/components/chat/ChatInputArea.tsx b/src/components/chat/ChatInputArea.tsx index e93b72035..7e86304dd 100644 --- a/src/components/chat/ChatInputArea.tsx +++ b/src/components/chat/ChatInputArea.tsx @@ -14,6 +14,7 @@ import React, { useState, useCallback } from "react"; import { ChatInput, type SlashCommand, type SuggestionItem } from "@/components/chat/ChatInput"; import { ProviderModelPicker } from "@/components/ui/provider-model-picker"; +import { PersonalityPicker } from "@/components/ui/personality-picker"; import { readAttachments, type AttachmentItem } from "@/lib/read-attachments"; import { cn } from "@/lib/utils"; @@ -108,6 +109,7 @@ export const ChatInputArea = React.forwardRef
{providerModelTarget && } + {providerModelTarget === "ai" && } {statusText && (

{statusText} diff --git a/src/components/chat/chat-panel/index.tsx b/src/components/chat/chat-panel/index.tsx index f4bd4721e..45bb2c686 100644 --- a/src/components/chat/chat-panel/index.tsx +++ b/src/components/chat/chat-panel/index.tsx @@ -393,6 +393,10 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP systemPrompt = `## Context\n- **Date:** ${date}\n\n${GRAPH_SYSTEM_PROMPT}\n\n--- CURRENT GRAPH SNAPSHOT ---\n${graphContext}`; } + // Active chat personality (Default = none). The main process appends its + // prompt to the system prompt as a delimited style layer. + const activePersonality = aiConfig.installedPersonalities?.find((p) => p.id === aiConfig.personalityId); + const formatChatHistory = (msgs: typeof messages) => { const history: ChatHistoryEntry[] = []; msgs.slice(-40).forEach((m) => { @@ -465,6 +469,9 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP isReasoningModel: getModelInfo(aiConfig.model)?.reasoning === true, }, systemPrompt, + // Active chat personality (Default = none). The main process appends it + // to the system prompt as a delimited style layer. + personality: activePersonality ? { name: activePersonality.name, prompt: activePersonality.prompt } : undefined, images: attachmentsToSend?.map((a) => ({ name: a.name, dataUrl: a.dataUrl, kind: a.kind })), // Subagents is now a GLOBAL AI setting (aiConfig.subagentsEnabled), not a // per-thread flag. Ignored server-side / here for the localllm provider. diff --git a/src/components/notes/MermaidDiagram.test.ts b/src/components/notes/MermaidDiagram.test.ts new file mode 100644 index 000000000..a4f1a57d9 --- /dev/null +++ b/src/components/notes/MermaidDiagram.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { parseColor, colorLuminance } from "./MermaidDiagram"; + +describe("parseColor", () => { + it("parses 6-digit hex", () => { + expect(parseColor("#1a1917")).toEqual([26, 25, 23]); + expect(parseColor("#e8e4dc")).toEqual([232, 228, 220]); + }); + + it("parses rgb()", () => { + expect(parseColor("rgb(240, 238, 235)")).toEqual([240, 238, 235]); + }); + + it("rejects unknown formats", () => { + expect(parseColor("")).toBeNull(); + expect(parseColor("none")).toBeNull(); + expect(parseColor("url(#grad)")).toBeNull(); + expect(parseColor("#fff")).toBeNull(); + }); +}); + +describe("colorLuminance", () => { + it("returns a low value for dark colours", () => { + expect(colorLuminance("#1a1917")).not.toBeNull(); + expect(colorLuminance("#1a1917")!).toBeLessThan(0.1); + }); + + it("returns a high value for light colours", () => { + expect(colorLuminance("#f0eeeb")!).toBeGreaterThan(0.8); + }); + + it("returns null for unparseable input", () => { + expect(colorLuminance("none")).toBeNull(); + }); +}); diff --git a/src/components/notes/MermaidDiagram.tsx b/src/components/notes/MermaidDiagram.tsx index a7a140c5a..3fa819ab4 100644 --- a/src/components/notes/MermaidDiagram.tsx +++ b/src/components/notes/MermaidDiagram.tsx @@ -61,6 +61,79 @@ async function getMermaid(id: string) { return { mermaid, id }; } +// ── Subgraph label contrast ─────────────────────────────────────────────────── + +// Subgraphs (clusters) can carry an explicit fill chosen by the diagram author +// (LLM-generated charts often pick light pastels). Mermaid's label colour +// follows the theme — light text in dark mode — so an explicitly-light fill +// becomes unreadable. Fix by computing the actual fill luminance per cluster +// and pinning the label to a contrasting dark/light fill. +export const parseColor = (input: string): [number, number, number] | null => { + const s = (input ?? "").trim(); + const hex = s.match(/^#([0-9a-f]{6})$/i); + if (hex) { + const n = parseInt(hex[1], 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + } + const rgb = s.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/); + if (rgb) return [parseInt(rgb[1], 10), parseInt(rgb[2], 10), parseInt(rgb[3], 10)]; + return null; +}; + +export const colorLuminance = (input: string): number | null => { + const rgb = parseColor(input); + if (!rgb) return null; + const [r, g, b] = rgb.map((c) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; + +/** WCAG contrast ratio between two relative luminances (1–21). */ +export function contrastRatio(l1: number, l2: number): number { + const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1]; + return (hi + 0.05) / (lo + 0.05); +} + +function enforceClusterLabelContrast(container: HTMLElement) { + // FIXED (theme-independent) label candidates from the design tokens — dark + // text on light fills, light text on dark fills. + const styles = getComputedStyle(container); + const darkColor = styles.getPropertyValue("--mermaid-label-dark").trim() || "#1a1917"; + const lightColor = styles.getPropertyValue("--mermaid-label-light").trim() || "#e8e4dc"; + const darkLum = colorLuminance(darkColor); + const lightLum = colorLuminance(lightColor); + + for (const cluster of container.querySelectorAll("g.cluster")) { + const rect = cluster.querySelector("rect") ?? (cluster.firstElementChild as SVGGraphicsElement | null); + const svgLabel = cluster.querySelector(".cluster-label text, text.cluster-label"); + const htmlLabel = cluster.querySelector(".cluster-label span"); + if (!rect) continue; + + // Prefer the rect's own fill; fall back to the computed style when the + // attribute is missing/"none". If the attribute value is present but fails + // to parse (e.g. an SVG paint-server url), retry luminance with the + // computed style before giving up on this cluster. + const attrFill = rect.getAttribute("fill"); + let luminance = colorLuminance(attrFill && attrFill !== "none" ? attrFill : getComputedStyle(rect).fill); + if (luminance === null) { + luminance = colorLuminance(getComputedStyle(rect).fill); + } + if (luminance === null) continue; + + // Pick whichever candidate has the HIGHER WCAG contrast against the fill + // instead of a fixed 0.5-luminance threshold. + const useDark = darkLum !== null && lightLum !== null + ? contrastRatio(luminance, darkLum) >= contrastRatio(luminance, lightLum) + : luminance > 0.5; + const color = useDark ? darkColor : lightColor; + + if (svgLabel) svgLabel.setAttribute("fill", color); + if (htmlLabel) htmlLabel.style.color = color; + } +} + // ── Full-screen modal ───────────────────────────────────────────────────────── function DiagramModal({ chart, onClose }: { chart: string; onClose: () => void }) { @@ -78,6 +151,7 @@ function DiagramModal({ chart, onClose }: { chart: string; onClose: () => void } const { svg } = await mermaid.render(modalId, chart.trim()); if (!cancelled && containerRef.current) { containerRef.current.innerHTML = svg; + enforceClusterLabelContrast(containerRef.current); // Remove fixed width/height so SVG scales to fill the modal const svgEl = containerRef.current.querySelector("svg"); if (svgEl) { @@ -131,6 +205,7 @@ export function MermaidDiagram({ chart }: Props) { const { svg } = await mermaid.render(`mermaid-${id}`, chart.trim()); if (!cancelled && containerRef.current) { containerRef.current.innerHTML = svg; + enforceClusterLabelContrast(containerRef.current); setError(null); } } catch (e) { diff --git a/src/components/settings/AISettings.tsx b/src/components/settings/AISettings.tsx index 27de29220..1f837b65d 100644 --- a/src/components/settings/AISettings.tsx +++ b/src/components/settings/AISettings.tsx @@ -3,7 +3,7 @@ import { useCairnStore } from "@/store"; import { useShallow } from "zustand/react/shallow"; import { useEffect, useState } from "react"; -import { Cpu, Globe, Download } from "lucide-react"; +import { Cpu, Globe, Download, Plus, Trash2, Sparkles, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { contextLimitForModel, modelInfoForModel } from "@/lib/models-dev"; import { SettingsGroup, SettingsRow, Toggle, StepperSettingsRow } from "./shared"; @@ -11,11 +11,16 @@ import { MCPServerSettings } from "./MCPSettings"; import { LlamaServerConsole } from "./LlamaServerConsole"; import { ProviderManager } from "./ProviderManager"; import { BrowseProvidersModal } from "./tools/BrowseProvidersModal"; +import { BrowsePersonalitiesModal } from "@/components/chat/BrowsePersonalitiesModal"; +import { MAX_PERSONALITY_PROMPT_CHARS } from "../../../shared/chat/registry-schema"; export function AISettings() { - const { aiConfig, setAIConfig } = useCairnStore(useShallow((s) => ({ + const { aiConfig, setAIConfig, setPersonality, removePersonality, createCustomPersonality } = useCairnStore(useShallow((s) => ({ aiConfig: s.aiConfig, setAIConfig: s.setAIConfig, + setPersonality: s.setPersonality, + removePersonality: s.removePersonality, + createCustomPersonality: s.createCustomPersonality, }))); // General config destructuring. Connection fields (baseUrl/apiKey/model) are @@ -37,6 +42,11 @@ export function AISettings() { const [detectedContext, setDetectedContext] = useState(null); const [autoState, setAutoState] = useState<"idle" | "loading" | "detected" | "not_found">("idle"); const [browsingProviders, setBrowsingProviders] = useState(false); + const [browsingPersonalities, setBrowsingPersonalities] = useState(false); + const [creatingPersonality, setCreatingPersonality] = useState(false); + const [personaName, setPersonaName] = useState(""); + const [personaDescription, setPersonaDescription] = useState(""); + const [personaPrompt, setPersonaPrompt] = useState(""); useEffect(() => { if (provider === "localllm") return; let cancelled = false; @@ -271,10 +281,167 @@ export function AISettings() { )} + {/* ── Chat personalities ── */} + + + + + + {aiConfig.installedPersonalities && aiConfig.installedPersonalities.length > 0 && ( + +

+ {/* None — no personality layer */} + + {aiConfig.installedPersonalities.map((p) => { + const isActive = p.id === aiConfig.personalityId; + return ( +
+ + +
+ ); + })} +
+ + )} + + + + + + {creatingPersonality && ( +
+ setPersonaName(e.target.value)} + /> + setPersonaDescription(e.target.value)} + /> +