Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
de97985
fix(notes): make mermaid subgraph labels contrast with their fill
ddutchie Aug 11, 2026
a1b7392
feat(chat): community personalities — style-layer system prompt + picker
ddutchie Aug 11, 2026
d8c1c9b
perf(agent): drop redundant tool listings from the coding-agent syste…
ddutchie Aug 11, 2026
a78932a
fix(chat): stabilize personality picker selectors + null-safe list ac…
ddutchie Aug 11, 2026
e3a91b0
feat(settings): user writing style — persona, full guide + cheat shee…
ddutchie Aug 11, 2026
36285e3
fix(chat): 'None' personality selection actually sticks across restarts
ddutchie Aug 11, 2026
d920116
chore: lint fixes — unused import, useCallback deps, escaped entities
ddutchie Aug 11, 2026
c1fb5b4
fix(settings): writing-style wizard — Next/Skip navigation + custom S…
ddutchie Aug 11, 2026
cbc6222
fix(settings): make writing-style generation robust against garbage o…
ddutchie Aug 11, 2026
262765f
test(settings): live generation test for Writing Style + extract shar…
ddutchie Aug 11, 2026
b5642d5
fix(llm): unify callLLM streaming on the loop parser; non-streaming o…
ddutchie Aug 11, 2026
7c9b16e
feat(settings): streamed writing-style generation + Optimize pass + m…
ddutchie Aug 11, 2026
8c78601
perf(settings): reasoning_effort none for writing-style generation (~…
ddutchie Aug 11, 2026
3b572e2
fix(llm): fall back without reasoning_effort on strict endpoints (400…
ddutchie Aug 11, 2026
b42a89a
fix: address CodeRabbit review comments on PR #123
ddutchie Aug 11, 2026
053d482
fix(compaction): route chat compaction through the shared SSE parser …
ddutchie Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions changelogs/v2.6.12.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions electron/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
18 changes: 18 additions & 0 deletions electron/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion electron/db/usage-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UsageSource, string> = {
Expand All @@ -39,6 +40,7 @@ export const USAGE_SOURCE_LABELS: Record<UsageSource, string> = {
"flow-ai-summary": "Idea Flow summary",
summary: "Compaction",
"tool-builder": "Tool builder",
"writing-style": "Writing style",
};

export interface LlmUsageRecord {
Expand Down
54 changes: 54 additions & 0 deletions electron/db/user-style-queries.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
97 changes: 97 additions & 0 deletions electron/db/user-style-queries.ts
Original file line number Diff line number Diff line change
@@ -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);
}
32 changes: 32 additions & 0 deletions electron/ipc/chat-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1355,3 +1356,34 @@ 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(full.markdown).toContain("Voice in one line");
});
});
24 changes: 24 additions & 0 deletions electron/ipc/chat-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,30 @@ 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,
markdown: mode === "full" ? style.fullGuide : style.cheatsheet,
persona: style.persona,
updatedAt: style.updatedAt,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

default:
return { error: `Unknown tool: ${name}` };
}
Expand Down
6 changes: 3 additions & 3 deletions electron/ipc/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -160,7 +160,7 @@ export function registerChatHandler(db: Database.Database, workspacePath: string
const messages: OpenAIMessage[] = [
{
role: resolveSystemRole({ isReasoningModel: req.config?.isReasoningModel, baseUrl, provider, modelId: model }),
content: buildSystemPrompt(req),
content: withPersonality(buildSystemPrompt(req), req.personality),
},
...(req.history ?? [])
// Drop assistant turns that carry neither content nor tool_calls — a
Expand Down Expand Up @@ -236,7 +236,7 @@ export function registerChatHandler(db: Database.Database, workspacePath: string
costUsd = (costUsd ?? 0) + cost;
}
try {
const rawBreakdown = calculatePromptBreakdown(buildSystemPrompt(req), messages, allTools);
const rawBreakdown = calculatePromptBreakdown(withPersonality(buildSystemPrompt(req), req.personality), messages, allTools);
lastBreakdown = scaleBreakdown(rawBreakdown, promptTokens);
} catch (err) {
console.error("[chat] failed to calculate breakdown:", err);
Expand Down
5 changes: 5 additions & 0 deletions electron/ipc/community-registry-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
refreshProvidersManifest,
fetchAutomationsManifest,
refreshAutomationsManifest,
fetchPersonalitiesManifest,
refreshPersonalitiesManifest,
} from "../lib/community-registry";

export function registerCommunityRegistryHandlers(): void {
Expand All @@ -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()));
}
2 changes: 2 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down
Loading