diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3c15c1..5b3d9c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -360,11 +360,12 @@ Shared modules live in `src/components/graph/`: `analyticsUtils.ts`, `analyticsH | `electron/ipc/agent.ts` | All `agent:*` IPC channels — PTY spawn/kill, file I/O, git diff, `assertWithinCodeDirectory` | | `electron/ipc/chat.ts` | AI chat loop — `runToolLoop` + IPC handler registration | | `electron/ipc/chat-executor.ts` | `executeTool` — all AI tool implementations | -| `electron/ipc/pi-agent.ts` | Cairn native agent IPC handler — `sessions` Map, all `pi-agent:*` channels | -| `electron/lib/pi-agent-loop.ts` | `runAgentLoop()` — multi-turn SSE loop, parallel tool execution, callbacks, retry, `transformContext` hook; `AgentToolContext` groups cwd/db/IPC/session state | -| `electron/lib/compaction.ts` | `buildCompactionTransformer` — async LLM-based context compaction; fires `pi-agent:compact` events; falls back to sliding-window pruner while summary is in flight | +| `electron/ipc/pi-agent.ts` | Cairn native agent IPC handler — `sessions` Map, all `pi-agent:*` channels; `runSession()` shared helper wires compaction + callbacks + `runAgentLoop` for both `prompt` and `approve-plan` handlers | +| `electron/lib/pi-agent-loop.ts` | `runAgentLoop()` — multi-turn SSE loop, parallel tool execution, callbacks, retry, `transformContext` hook, `shouldStopAfterTurn` hook; `AgentToolContext` groups cwd/db/IPC/session state | +| `electron/lib/compaction.ts` | `buildCompactionTransformer` — async LLM-based compaction, signal read live from session; `compactNow` — synchronous on-demand compaction for `/compact` slash command | +| `electron/lib/truncation.ts` | `truncateOutput(text, opts)` — unified byte+line cap for all coding tool outputs; exports `DEFAULT_MAX_BYTES`, `DEFAULT_MAX_LINES`, `TruncationResult` | | `electron/lib/pi-agent-prompt.ts` | System prompt builder; mandatory Cairn workflow; `spawn_subagent` description | -| `electron/lib/coding-tools/` | 8 coding tools (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, `spawn_subagent`) + `file-mutex.ts` | +| `electron/lib/coding-tools/` | 8 coding tools (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, `spawn_subagent`) + `file-mutex.ts`; all tools use `truncateOutput` from `truncation.ts` | | `electron/lib/llm.ts` | `LLMConfig`, `callLLM`, `streamCompletion`, `isLocalEndpoint`, `normaliseBaseUrl` | | `electron/lib/tools.ts` | `TOOLS` (OpenAI function definitions), `TOOL_LABELS`, `buildSystemPrompt` | | `electron/lib/context.ts` | `buildContextResponse` — canonical `get_cairn_context` response | @@ -572,6 +573,7 @@ renderer main process │ ◄── pi-agent:usage ──────────────┤ token counts → context ring │ ◄── pi-agent:retry ──────────────┤ (on transient error) countdown badge │ ◄── pi-agent:compact ────────────┤ (when compaction fires) "Compacting…" indicator + │ ◄── pi-agent:compact-result ─────┤ (/compact slash command result) │ ◄── pi-agent:done ───────────────┤ turn complete ``` @@ -581,7 +583,15 @@ The `pending` status fires during streaming as soon as a tool name is seen in th **Automatic retry** — `isRetryable(status, body)` in `pi-agent-loop.ts` detects transient errors (429, 5xx, overloaded/rate-limit body patterns). Up to `AgentLLMConfig.maxRetries` attempts (default 3) with exponential backoff starting at `baseRetryDelayMs` (default 2000 ms). `onRetry` callback fires before each sleep; `pi-agent:retry` is emitted to the renderer. -**Context compaction** — `buildCompactionTransformer` in `electron/lib/compaction.ts` returns a `transformContext` function passed to `runAgentLoop`. When `session.lastPromptTokens` exceeds 80% of `llmConfig.contextWindow`, it fires a background LLM summarisation call (non-streaming, `temperature: 0.1`). The current turn uses a sliding-window fallback; subsequent turns use the cached summary. `onCompactionStart`/`onCompactionEnd` fire `pi-agent:compact` events to the renderer. To change the threshold or number of verbatim turns preserved, edit `COMPACT_THRESHOLD` and `KEEP_RECENT_TURNS` in `compaction.ts`. +**Context compaction** — `buildCompactionTransformer` in `electron/lib/compaction.ts` returns a `transformContext` function stored on `PiAgentSession.compactionTransformer` (built once, reused across prompts so the `cachedSummary` survives multi-turn sessions). When `session.lastPromptTokens` exceeds 80% of `llmConfig.contextWindow`, it fires a background LLM summarisation call (non-streaming, `temperature: 0.1`). The signal is read live from `session.abortCtrl` each invocation. The current turn uses a sliding-window fallback; subsequent turns use the cached summary. `pi-agent:compact` IPC events drive the `"Compacting context…"` status bar indicator. To change the threshold or number of verbatim turns preserved, edit `COMPACT_THRESHOLD` and `KEEP_RECENT_TURNS` in `compaction.ts`. + +**`/compact` slash command** — typing `/compact` in the chat input calls `compactNow(session, llmConfig)` in `compaction.ts` via the `pi-agent:compact-now` IPC channel. Unlike the automatic transformer (fire-and-forget), `compactNow` awaits the LLM summary call synchronously and emits a `pi-agent:compact-result` event. The renderer shows a centred italic system message: *"Context compacted — session history summarised into N messages."* `PiAgentMessage.role` includes `"system"` for this purpose; `PiMessageBubble` renders it as muted centred text. + +**`shouldStopAfterTurn` hook** — `AgentLoopCallbacks.shouldStop?: (messages) => boolean | Promise` is checked after each turn's tool results are appended, before the next LLM call. Returning `true` fires `onDone` cleanly. Use for semantic stop conditions (e.g. stop when a linked task card reaches Done) without modifying the loop. Wire it in `runSession()` in `pi-agent.ts`. + +**Output truncation** — all coding tools use `truncateOutput(text, opts)` from `electron/lib/truncation.ts` instead of ad-hoc byte slicing. The library enforces a byte cap (`DEFAULT_MAX_BYTES = 50 000`) and optional line cap (`DEFAULT_MAX_LINES = 2 000`), returns a `TruncationResult` with metadata, and appends a consistent `[Truncated: showed X of Y. Use offset/limit to page.]` hint. When adding a new coding tool, import and call `truncateOutput` on the output before returning. + +**`runSession` invariant** — within `pi-agent.ts`, always call `runAgentLoop` via `runSession()` rather than directly. `runSession` is the single place that builds the compaction transformer, wires all IPC-forwarding callbacks, and handles `onDone`/`onError` persistence. The only legitimate direct call site for `runAgentLoop` is `pi-agent-loop.test.ts`. **Subagents** diff --git a/README.md b/README.md index 1bd55b4..8161f3e 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ The agent can delegate contained sub-tasks to a fresh sub-agent via `spawn_subag A small ring in the agent pane header shows how full the model's context window is after each step. Configure the limit for your model in **Settings → AI & Chat → Context window** (presets: 8k / 32k / 128k / 200k). -When usage reaches 80% the agent automatically summarises older context with a background LLM call — the status bar shows `"Compacting context…"` while this is in flight. If a transient API error occurs, the status bar shows a countdown (`"Transient error — retrying (1/3) in 8s…"`) and the agent retries automatically. +When usage reaches 80% the agent automatically summarises older context with a background LLM call — the status bar shows `"Compacting context…"` while this is in flight. Type `/compact` in the chat input to trigger compaction on demand at any time. If a transient API error occurs, the status bar shows a countdown (`"Transient error — retrying (1/3) in 8s…"`) and the agent retries automatically. ## MCP server diff --git a/changelogs/v1.4.0.md b/changelogs/v1.4.0.md new file mode 100644 index 0000000..93f7e2f --- /dev/null +++ b/changelogs/v1.4.0.md @@ -0,0 +1,50 @@ +# v1.4.0 + +## What's new + +### Features + +- **`/compact` slash command** — type `/compact` in the Pi agent chat input to trigger on-demand context compaction. The agent immediately summarises the full session history with a dedicated LLM call, then injects the summary as a structured context block. A centred italic system message confirms the result: *"Context compacted — session history summarised into N messages."* Previously compaction only fired automatically at the 80% token threshold. + +- **Unified output truncation** — all coding tools now share a single `truncateOutput()` library (`electron/lib/truncation.ts`) with consistent byte-budget enforcement, structured metadata (`truncated`, `totalBytes`, `shownBytes`, `totalLines`, `shownLines`), and a uniform pagination hint so the model knows to use `offset`/`limit` to retrieve the rest. `ls` gains truncation for the first time. All tools produce the same hint format: *"[Truncated: showed X of Y bytes. Use offset/limit to page.]"* + +- **`shouldStopAfterTurn` hook** — `AgentLoopCallbacks` gains an optional `shouldStop?: (messages: AgentMessage[]) => boolean | Promise` callback checked after each turn's tool results are appended. Returning `true` stops the loop cleanly with `onDone` instead of `onError`. Enables semantic stop conditions (e.g. stop when a task card reaches Done) without modifying the loop. + +- **Compaction transformer persisted across prompts** — the LLM summary cached inside the compaction transformer now survives multiple `pi-agent:prompt` calls on the same session. Previously a fresh transformer (with empty cache) was built on every prompt, discarding any summary from the previous turn. `pi-agent:clear` resets the transformer along with message history. + +- **`runSession` extracted in IPC handler** — the ~120-line duplicated block (compaction setup + all callbacks + `runAgentLoop` call) shared between the `pi-agent:prompt` and `pi-agent:approve-plan` handlers is now a single `runSession()` helper. Both handlers are reduced to ~15 lines each. Future callback additions are a single-site change. + +### Fixes + +- **Compaction signal staleness** — the `AbortSignal` used by the compaction transformer was captured at build time. Since `abortCtrl` is replaced on every new prompt, the old signal was already aborted by the time compaction ran in a multi-turn session. The signal is now read live from `session.abortCtrl.signal` inside the transformer on each invocation. + +- **`pi-agent:clear` now resets compaction state** — clearing a session previously left `compactionTransformer` and `lastPromptTokens` stale on the session object. Both are now reset alongside `messages` so the new conversation starts from a clean state. + +- **Skill system** — the pi-agent now discovers and loads `SKILL.md` files by walking up from the project `cwd` to the git root, checking `.cairn/skills/`, `.opencode/skills/`, `.cline/skills/`, `.claude/skills/`, and `.agents/skills/` at every ancestor directory. Global paths (`~/.config/cairn/skills/`, `~/.opencode/skills/`) are checked last. This means skills placed at the project root are always found even when `cwd` is a subdirectory (e.g. `packages/web/`). First occurrence per skill name wins (closest directory takes precedence). Metadata (name + description) is always injected into the system prompt as an `` XML block; the full body is loaded on demand via a new `skill` tool call. Compatible with skills authored for OpenCode, Cline, and Claude Code — same SKILL.md format, same invocation contract. Skills are allowed in both execute and plan mode. + +- **Settings → Skills & System Prompt preview** — the Coding Agents settings panel now includes a live preview section showing all discovered skills for the current workspace and the full assembled system prompt. Mode toggle (execute / plan) lets you inspect both variants. Each skill card shows name, description, file path, and optional compatibility/license badges. The system prompt preview is collapsible, shows line count, and has a one-click copy button. + +### Changes + +- `electron/lib/skills.ts` — new file; exports `discoverSkills(cwd)`, `loadSkill(name, skills)`, `renderSkillsXml(skills)`, `SkillMeta`, `SkillContent` +- `electron/lib/coding-tools/skill.ts` — new file; `skillTool(args, skills)` and `makeSkillToolDefinition(skills)` (dynamic — lists available skill names in description) +- `electron/lib/coding-tools/index.ts` — barrel exports `skillTool`, `makeSkillToolDefinition`, `SkillArgs` +- `electron/lib/pi-agent-loop.ts` — `AgentToolContext` gains `skills?: SkillMeta[]`; `getAllToolDefs` takes skills param and conditionally includes the `skill` tool; `"skill"` dispatched in `executeSingleTool`; `"skill"` added to `PLAN_MODE_ALLOWED` +- `electron/lib/pi-agent-prompt.ts` — `PiAgentPromptContext` gains `skillsXml?: string`; skills section injected into both plan and execute mode prompts when non-empty +- `electron/ipc/pi-agent.ts` — `discoverSkills` + `renderSkillsXml` called in both `pi-agent:prompt` and `pi-agent:approve-plan` handlers; `skills` passed into `toolCtx`; new `pi-agent:preview-prompt` IPC handle returns `{ systemPrompt, skills }` for the settings preview +- `electron/preload.ts` — `piAgent.previewPrompt(req)` invoke method added +- `src/components/settings/AgentSettings.tsx` — new `SkillsPreviewSection` component with skill discovery display, mode toggle, system prompt preview with expand/collapse and copy + +- `electron/lib/truncation.ts` — new file; exports `truncateOutput`, `TruncationResult`, `TruncateOptions`, `DEFAULT_MAX_BYTES`, `DEFAULT_MAX_LINES` +- `electron/lib/coding-tools/bash.ts` — imports `DEFAULT_MAX_BYTES` from truncation; hint message aligned to library format +- `electron/lib/coding-tools/grep.ts` — uses `truncateOutput`; removes manual suffix +- `electron/lib/coding-tools/find.ts` — uses `truncateOutput`; removes manual suffix +- `electron/lib/coding-tools/ls.ts` — uses `truncateOutput`; adds truncation (previously unbounded) +- `electron/lib/coding-tools/read.ts` — imports `DEFAULT_MAX_LINES`; uses `truncateOutput` for byte cap; retains precise line-range pagination hints +- `electron/lib/pi-agent-loop.ts` — `AgentLoopCallbacks` gains `shouldStop?`; `shouldStop` check wired after tool results; `setImmediate` yield documented; `runAgentLoop` export annotated with the `runSession` invariant; `PiAgentSession` gains `compactionTransformer?` +- `electron/lib/compaction.ts` — `buildCompactionTransformer` drops `signal` param (reads live from `session.abortCtrl`); new `compactNow(session, llmConfig)` export for on-demand compaction +- `electron/ipc/pi-agent.ts` — `runSession()` helper extracted; `session.compactionTransformer ??=` replaces per-call `buildCompactionTransformer`; `pi-agent:compact-now` IPC handler added; `pi-agent:compact-result` event emitted; `pi-agent:clear` resets `compactionTransformer` and `lastPromptTokens`; unused `ChatRequest` import removed +- `electron/preload.ts` — `compactNow` send method added; `onCompactResult` subscription added +- `src/components/agent/PiAgentPane.tsx` — `/compact` slash command intercepted before `sendPrompt`; `onCompactResult` subscription added; system message rendered on completion +- `src/store/slices/terminal-sessions.ts` — `PiAgentMessage.role` extended with `"system"` +- `src/components/agent/PiMessageBubble.tsx` — `"system"` role renders as centred italic muted text diff --git a/electron/ipc/pi-agent.ts b/electron/ipc/pi-agent.ts index ab6187b..e2a606b 100644 --- a/electron/ipc/pi-agent.ts +++ b/electron/ipc/pi-agent.ts @@ -19,10 +19,10 @@ import { ipcMain } from "electron"; import { runAgentLoop, type PiAgentSession, type AgentLLMConfig, type AgentToolContext } from "../lib/pi-agent-loop"; -import { buildCompactionTransformer } from "../lib/compaction"; +import { buildCompactionTransformer, compactNow } from "../lib/compaction"; import { buildPiAgentSystemPrompt } from "../lib/pi-agent-prompt"; +import { discoverSkills, renderSkillsXml } from "../lib/skills"; import { normaliseBaseUrl } from "../lib/llm"; -import type { ChatRequest } from "../lib/tools"; import type { DbContext } from "./handlers"; import * as q from "../db/queries"; import { ts } from "../db/utils"; @@ -69,6 +69,89 @@ interface PiAgentApprovePlanRequest { }; } +// ── Shared session runner ────────────────────────────────────────────────────── + +/** + * Wires the compaction transformer, builds all IPC-forwarding callbacks, and + * calls runAgentLoop. Extracted to eliminate duplication between the + * pi-agent:prompt and pi-agent:approve-plan handlers — both handlers resolve + * their differences (system prompt, initial message) before calling this. + */ +async function runSession( + session: PiAgentSession, + systemPrompt: string, + llmConfig: AgentLLMConfig, + mode: "plan" | "execute", + toolCtx: AgentToolContext, + ctx: DbContext, + send: (channel: string, payload: unknown) => void, +): Promise { + const { sessionId } = toolCtx; + + // Reuse existing transformer to preserve cachedSummary across prompts. + // Signal is read live from session.abortCtrl inside the transformer. + session.compactionTransformer ??= buildCompactionTransformer( + session, + llmConfig, + () => send("pi-agent:compact", { sessionId, status: "start" }), + () => send("pi-agent:compact", { sessionId, status: "end" }), + ); + + await runAgentLoop( + session, + systemPrompt, + llmConfig, + { + onToken: (delta) => send("pi-agent:token", { sessionId, delta }), + onToolsReady: () => send("pi-agent:tools-ready", { sessionId }), + onToolPending: (name, callId) => send("pi-agent:tool", { sessionId, name, label: name, callId, status: "pending" }), + onToolStart: (name, label, callId) => send("pi-agent:tool", { sessionId, name, label, callId, status: "start" }), + onToolEnd: (name, label, ok, output, callId) => { + send("pi-agent:tool", { sessionId, name, label, callId, status: "end", ok, output }); + // After any note-write tool, push fresh note content to the renderer + // so the plan task list can update live without a full re-hydration. + if (ok && NOTE_WRITE_TOOLS.has(name)) { + try { + const parsed = JSON.parse(output) as { id?: string }; + if (parsed?.id) { + const row = ctx.db.prepare("SELECT content FROM notes WHERE id = ?").get(parsed.id) as { content: string } | undefined; + if (row) send("pi-agent:note-updated", { sessionId, noteId: parsed.id, content: row.content ?? "" }); + } + } catch { /* non-JSON output — ignore */ } + } + }, + onStepStart: () => send("pi-agent:step", { sessionId }), + onUsage: (promptTokens, completionTokens) => send("pi-agent:usage", { sessionId, promptTokens, completionTokens }), + onRetry: (attempt, maxRetries, delayMs, error) => send("pi-agent:retry", { sessionId, attempt, maxRetries, delayMs, error }), + transformContext: session.compactionTransformer, + onDone: () => { + try { + q.saveLlmHistory(ctx.db, sessionId, session.messages); + q.updatePiSession(ctx.db, sessionId, { updatedAt: ts() }); + } catch (e) { + console.warn("[pi-agent] failed to persist session after done:", e); + } + send("pi-agent:done", { sessionId }); + }, + onError: (error) => { + try { + q.saveLlmHistory(ctx.db, sessionId, session.messages); + q.updatePiSession(ctx.db, sessionId, { status: "exited", updatedAt: ts() }); + } catch (e) { + console.warn("[pi-agent] failed to persist session after error:", e); + } + send("pi-agent:error", { sessionId, error }); + }, + onPlanNoteFound: (noteId) => { + send("pi-agent:plan-note", { sessionId, noteId }); + try { q.updatePiSession(ctx.db, sessionId, { planNoteId: noteId, updatedAt: ts() }); } catch { /* non-critical */ } + }, + }, + toolCtx, + mode, + ); +} + // ── Registration ─────────────────────────────────────────────────────────────── export function registerPiAgentHandler( @@ -91,12 +174,9 @@ export function registerPiAgentHandler( const send = (channel: string, payload: unknown) => { const win = getWin(); - if (win && !win.webContents.isDestroyed()) { - win.webContents.send(channel, payload); - } + if (win && !win.webContents.isDestroyed()) win.webContents.send(channel, payload); }; - // Resolve LLM config — renderer passes config from its aiConfig store const llmConfig: AgentLLMConfig = { baseUrl: normaliseBaseUrl(req.config?.baseUrl || "https://api.openai.com"), model: req.config?.model || "gpt-4o", @@ -105,134 +185,41 @@ export function registerPiAgentHandler( temperature: req.config?.temperature ?? 0.3, }; - // Get or create session let session = sessions.get(sessionId); if (!session) { - session = { - messages: [], - abortCtrl: new AbortController(), - }; + session = { messages: [], abortCtrl: new AbortController() }; sessions.set(sessionId, session); } else { - // New prompt in existing session — create fresh abort controller session.abortCtrl = new AbortController(); } - // Append the user message to history session.messages.push({ role: "user", content: prompt }); - // Resolve project name for system prompt const projectName = projectId ? (ctx.db.prepare("SELECT name FROM projects WHERE id = ?").get(projectId) as { name: string } | undefined)?.name ?? "Project" : "Project"; - const systemPrompt = buildPiAgentSystemPrompt({ - projectName, - cwd, - taskTitle, - workspaceId, - projectId, - mode, - }); - - // Build a minimal ChatRequest for Cairn tool execution - const chatReq: ChatRequest = { - message: prompt, - threadId: sessionId, - projectId, - workspaceId, - config: { - baseUrl: llmConfig.baseUrl, - model: llmConfig.model, - apiKey: llmConfig.apiKey, - }, - }; + const skills = discoverSkills(cwd); + const systemPrompt = buildPiAgentSystemPrompt({ projectName, cwd, taskTitle, workspaceId, projectId, mode, skillsXml: renderSkillsXml(skills) }); const toolCtx: AgentToolContext = { - cwd, - db: ctx.db, - req: chatReq, - workspacePath: ctx.workspacePath, - sessionId, - send, - getWin, + cwd, db: ctx.db, workspacePath: ctx.workspacePath, sessionId, send, getWin, skills, + req: { message: prompt, threadId: sessionId, projectId, workspaceId, + config: { baseUrl: llmConfig.baseUrl, model: llmConfig.model, apiKey: llmConfig.apiKey } }, }; - const transformContext = buildCompactionTransformer( - session, - llmConfig, - session.abortCtrl.signal, - () => send("pi-agent:compact", { sessionId, status: "start" }), - () => send("pi-agent:compact", { sessionId, status: "end" }), - ); - - await runAgentLoop( - session, - systemPrompt, - llmConfig, - { - onToken: (delta) => send("pi-agent:token", { sessionId, delta }), - onToolsReady: () => send("pi-agent:tools-ready", { sessionId }), - onToolPending: (name, callId) => send("pi-agent:tool", { sessionId, name, label: name, callId, status: "pending" }), - onToolStart: (name, label, callId) => send("pi-agent:tool", { sessionId, name, label, callId, status: "start" }), - onToolEnd: (name, label, ok, output, callId) => { - send("pi-agent:tool", { sessionId, name, label, callId, status: "end", ok, output }); - // After any note-write tool, notify the renderer with the fresh note content - // so the plan task list can update live. - if (ok && NOTE_WRITE_TOOLS.has(name)) { - try { - const parsed = JSON.parse(output) as { id?: string }; - if (parsed?.id) { - const row = ctx.db.prepare("SELECT content FROM notes WHERE id = ?").get(parsed.id) as { content: string } | undefined; - if (row) send("pi-agent:note-updated", { sessionId, noteId: parsed.id, content: row.content ?? "" }); - } - } catch { /* non-JSON output — ignore */ } - } - }, - onStepStart: () => send("pi-agent:step", { sessionId }), - onUsage: (promptTokens, completionTokens) => send("pi-agent:usage", { sessionId, promptTokens, completionTokens }), - onRetry: (attempt, maxRetries, delayMs, error) => send("pi-agent:retry", { sessionId, attempt, maxRetries, delayMs, error }), - transformContext, - onDone: () => { - try { - q.saveLlmHistory(ctx.db, sessionId, session.messages); - q.updatePiSession(ctx.db, sessionId, { updatedAt: ts() }); - } catch (e) { - console.warn("[pi-agent] failed to persist session after done:", e); - } - send("pi-agent:done", { sessionId }); - }, - onError: (error) => { - try { - q.saveLlmHistory(ctx.db, sessionId, session.messages); - q.updatePiSession(ctx.db, sessionId, { status: "exited", updatedAt: ts() }); - } catch (e) { - console.warn("[pi-agent] failed to persist session after error:", e); - } - send("pi-agent:error", { sessionId, error }); - }, - onPlanNoteFound: (noteId) => { - send("pi-agent:plan-note", { sessionId, noteId }); - try { q.updatePiSession(ctx.db, sessionId, { planNoteId: noteId, updatedAt: ts() }); } catch { /* non-critical */ } - }, - }, - toolCtx, - mode, - ); + await runSession(session, systemPrompt, llmConfig, mode, toolCtx, ctx, send); }); // ── pi-agent:approve-plan ───────────────────────────────────────────────── - // Renderer fires this when the user clicks "Approve Plan". - // Main fetches the PRD note content, switches the session to execute mode, - // injects a system message, and kicks off a new execute-mode loop turn. + // Renderer fires this when the user clicks "Approve Plan". Fetches the PRD + // note, injects the approval message, then continues in execute mode. ipcMain.on("pi-agent:approve-plan", async (_event, req: PiAgentApprovePlanRequest) => { const { sessionId, planNoteId, projectId, workspaceId, cwd, taskTitle } = req; const send = (channel: string, payload: unknown) => { const win = getWin(); - if (win && !win.webContents.isDestroyed()) { - win.webContents.send(channel, payload); - } + if (win && !win.webContents.isDestroyed()) win.webContents.send(channel, payload); }; const llmConfig: AgentLLMConfig = { @@ -251,16 +238,9 @@ export function registerPiAgentHandler( session.abortCtrl = new AbortController(); } - // Fetch the PRD note content from SQLite - const noteRow = ctx.db - .prepare("SELECT content FROM notes WHERE id = ?") - .get(planNoteId) as { content: string } | undefined; - const planContent = noteRow?.content ?? ""; + const planContent = (ctx.db.prepare("SELECT content FROM notes WHERE id = ?").get(planNoteId) as { content: string } | undefined)?.content ?? ""; - // Notify renderer the mode has switched send("pi-agent:mode-change", { sessionId, mode: "execute", planNoteId }); - - // Inject the approval message into history session.messages.push({ role: "user", content: `The plan has been approved. Begin implementation now, following the approved PRD exactly. The PRD note ID is ${planNoteId} — you can re-read it via get_note if needed.`, @@ -270,103 +250,66 @@ export function registerPiAgentHandler( ? (ctx.db.prepare("SELECT name FROM projects WHERE id = ?").get(projectId) as { name: string } | undefined)?.name ?? "Project" : "Project"; - const systemPrompt = buildPiAgentSystemPrompt({ - projectName, - cwd, - taskTitle, - workspaceId, - projectId, - mode: "execute", - planContent, - }); + const skills = discoverSkills(cwd); + const systemPrompt = buildPiAgentSystemPrompt({ projectName, cwd, taskTitle, workspaceId, projectId, mode: "execute", planContent, skillsXml: renderSkillsXml(skills) }); - const chatReq: ChatRequest = { - message: "", - threadId: sessionId, - projectId, - workspaceId, - config: { baseUrl: llmConfig.baseUrl, model: llmConfig.model, apiKey: llmConfig.apiKey }, + const toolCtx: AgentToolContext = { + cwd, db: ctx.db, workspacePath: ctx.workspacePath, sessionId, send, getWin, skills, + req: { message: "", threadId: sessionId, projectId, workspaceId, + config: { baseUrl: llmConfig.baseUrl, model: llmConfig.model, apiKey: llmConfig.apiKey } }, }; - const toolCtx: AgentToolContext = { - cwd, - db: ctx.db, - req: chatReq, - workspacePath: ctx.workspacePath, - sessionId, - send, - getWin, + await runSession(session, systemPrompt, llmConfig, "execute", toolCtx, ctx, send); + }); + + // ── pi-agent:compact-now ───────────────────────────────────────────────── + // Triggered by the /compact slash command. Immediately summarises the session + // history and returns the result. The renderer shows a status message. + ipcMain.on("pi-agent:compact-now", async (_event, req: { sessionId: string; config?: { baseUrl?: string; model?: string; apiKey?: string } }) => { + const { sessionId } = req; + const session = sessions.get(sessionId); + if (!session || session.messages.length === 0) return; + + const send = (channel: string, payload: unknown) => { + const win = getWin(); + if (win && !win.webContents.isDestroyed()) win.webContents.send(channel, payload); }; - const transformContext = buildCompactionTransformer( - session, - llmConfig, - session.abortCtrl.signal, - () => send("pi-agent:compact", { sessionId, status: "start" }), - () => send("pi-agent:compact", { sessionId, status: "end" }), - ); - - await runAgentLoop( - session, - systemPrompt, - llmConfig, - { - onToken: (delta) => send("pi-agent:token", { sessionId, delta }), - onToolsReady: () => send("pi-agent:tools-ready", { sessionId }), - onToolPending: (name, callId) => send("pi-agent:tool", { sessionId, name, label: name, callId, status: "pending" }), - onToolStart: (name, label, callId) => send("pi-agent:tool", { sessionId, name, label, callId, status: "start" }), - onToolEnd: (name, label, ok, output, callId) => { - send("pi-agent:tool", { sessionId, name, label, callId, status: "end", ok, output }); - // After any note-write tool, notify the renderer with the fresh note content - // so the plan task list can update live. - if (ok && NOTE_WRITE_TOOLS.has(name)) { - try { - const parsed = JSON.parse(output) as { id?: string }; - if (parsed?.id) { - const row = ctx.db.prepare("SELECT content FROM notes WHERE id = ?").get(parsed.id) as { content: string } | undefined; - if (row) send("pi-agent:note-updated", { sessionId, noteId: parsed.id, content: row.content ?? "" }); - } - } catch { /* non-JSON output — ignore */ } - } - }, - onStepStart: () => send("pi-agent:step", { sessionId }), - onUsage: (promptTokens, completionTokens) => send("pi-agent:usage", { sessionId, promptTokens, completionTokens }), - onRetry: (attempt, maxRetries, delayMs, error) => send("pi-agent:retry", { sessionId, attempt, maxRetries, delayMs, error }), - transformContext, - onDone: () => { - try { - q.saveLlmHistory(ctx.db, sessionId, session.messages); - q.updatePiSession(ctx.db, sessionId, { updatedAt: ts() }); - } catch (e) { - console.warn("[pi-agent] failed to persist session after done:", e); - } - send("pi-agent:done", { sessionId }); - }, - onError: (error) => { - try { - q.saveLlmHistory(ctx.db, sessionId, session.messages); - q.updatePiSession(ctx.db, sessionId, { status: "exited", updatedAt: ts() }); - } catch (e) { - console.warn("[pi-agent] failed to persist session after error:", e); - } - send("pi-agent:error", { sessionId, error }); - }, - onPlanNoteFound: (noteId) => { - send("pi-agent:plan-note", { sessionId, noteId }); - try { q.updatePiSession(ctx.db, sessionId, { planNoteId: noteId, updatedAt: ts() }); } catch { /* non-critical */ } - }, - }, - toolCtx, - "execute", - ); + const llmConfig: AgentLLMConfig = { + baseUrl: normaliseBaseUrl(req.config?.baseUrl || "https://api.openai.com"), + model: req.config?.model || "gpt-4o", + apiKey: req.config?.apiKey || "", + maxSteps: 20, + temperature: 0.1, + }; + + send("pi-agent:compact", { sessionId, status: "start" }); + try { + const result = await compactNow(session, llmConfig); + if (result) { + // Update the transformer's cache so the next runAgentLoop call uses the summary + session.compactionTransformer = undefined; // reset so it rebuilds with new context + send("pi-agent:compact-result", { sessionId, messageCount: result.messages.length, summary: result.summary }); + } else { + send("pi-agent:compact-result", { sessionId, messageCount: 0, summary: "" }); + } + } catch (e) { + send("pi-agent:error", { sessionId, error: `Compaction failed: ${(e as Error).message}` }); + } finally { + send("pi-agent:compact", { sessionId, status: "end" }); + } }); // ── pi-agent:clear ──────────────────────────────────────────────────────── - // Clears a session's message history (new conversation within same session) + // Clears a session's message history (new conversation within same session). + // Also resets the compaction transformer so the new conversation starts + // with a fresh cachedSummary. ipcMain.on("pi-agent:clear", (_event, { sessionId }: { sessionId: string }) => { const session = sessions.get(sessionId); if (session) { session.messages = []; + session.compactionTransformer = undefined; + session.lastPromptTokens = undefined; } }); @@ -380,6 +323,30 @@ export function registerPiAgentHandler( } }); + // ── pi-agent:preview-prompt ─────────────────────────────────────────────────────── + // Used by Settings → Agent Settings to show the full assembled system prompt + // and list discovered skills for the given cwd. + ipcMain.handle("pi-agent:preview-prompt", (_event, req: { + cwd: string; + projectId?: string; + mode?: "plan" | "execute"; + }) => { + try { + const { cwd, projectId, mode = "execute" } = req; + const projectName = projectId + ? (ctx.db.prepare("SELECT name FROM projects WHERE id = ?").get(projectId) as { name: string } | undefined)?.name ?? "Project" + : "Project"; + const skills = discoverSkills(cwd); + const systemPrompt = buildPiAgentSystemPrompt({ + projectName, cwd, mode, + skillsXml: renderSkillsXml(skills), + }); + return { data: { systemPrompt, skills } }; + } catch (e) { + return { error: (e as Error).message }; + } + }); + // ── pi-agent:restore-context ─────────────────────────────────────────────────────── // Loads the persisted LLM message history for a session back into the // in-memory sessions Map so the model can continue from where it left off. diff --git a/electron/lib/coding-tools/bash.ts b/electron/lib/coding-tools/bash.ts index 4330287..81e3084 100644 --- a/electron/lib/coding-tools/bash.ts +++ b/electron/lib/coding-tools/bash.ts @@ -9,8 +9,16 @@ */ import { spawn } from "child_process"; +import { DEFAULT_MAX_BYTES } from "../truncation"; -const MAX_OUTPUT_BYTES = 50_000; +// Local byte formatter for streaming truncation hints +function fmt(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +const MAX_OUTPUT_BYTES = DEFAULT_MAX_BYTES; const DEFAULT_TIMEOUT_MS = 120_000; // 2 minutes // ── Detached child PID tracking ─────────────────────────────────────────────── @@ -90,12 +98,12 @@ export async function bashTool( const available = MAX_OUTPUT_BYTES - Buffer.byteLength(output, "utf8"); if (available <= 0) { truncated = true; - output += "\n[Output truncated — exceeded 50 KB limit]"; + output += `\n\n[Truncated: showed ${fmt(MAX_OUTPUT_BYTES)} of ${fmt(Buffer.byteLength(output, "utf8"))}. Use offset/limit to page.]`; } else { output += text.slice(0, available); if (Buffer.byteLength(text, "utf8") > available) { truncated = true; - output += "\n[Output truncated — exceeded 50 KB limit]"; + output += `\n\n[Truncated: showed ${fmt(MAX_OUTPUT_BYTES)} of more. Use offset/limit to page.]`; } } } @@ -148,7 +156,7 @@ export const bashToolDefinition = { name: "bash", description: "Execute a bash command in the project's root directory. " + - "Output is streamed and capped at 50 KB. " + + `Output is streamed and capped at ${MAX_OUTPUT_BYTES / 1000}KB. ` + "Use for running tests, builds, grep, git commands, etc. " + "Avoid interactive commands or long-running processes.", parameters: { diff --git a/electron/lib/coding-tools/find.ts b/electron/lib/coding-tools/find.ts index b743578..e56c8d6 100644 --- a/electron/lib/coding-tools/find.ts +++ b/electron/lib/coding-tools/find.ts @@ -6,6 +6,7 @@ import fs from "fs"; import path from "path"; +import { truncateOutput } from "../truncation"; const MAX_RESULTS = 50; const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".next", "out", "build", ".turbo"]); @@ -65,9 +66,13 @@ export async function findTool(args: FindArgs, cwd: string): Promise { if (results.length === 0) return `No files matching "${args.pattern}" found.`; - const output = results.join("\n"); - const suffix = results.length >= MAX_RESULTS ? `\n\n[Showing first ${MAX_RESULTS} results]` : ""; - return output + suffix; + const raw = results.join("\n"); + const { text } = truncateOutput(raw, { + hint: results.length >= MAX_RESULTS + ? `[Showing first ${MAX_RESULTS} results — narrow the search path or pattern to see more.]` + : undefined, + }); + return text; } export const findToolDefinition = { diff --git a/electron/lib/coding-tools/grep.ts b/electron/lib/coding-tools/grep.ts index bbe58ed..63b7976 100644 --- a/electron/lib/coding-tools/grep.ts +++ b/electron/lib/coding-tools/grep.ts @@ -6,6 +6,7 @@ import fs from "fs"; import path from "path"; +import { truncateOutput } from "../truncation"; const MAX_RESULTS = 100; @@ -101,9 +102,13 @@ export async function grepTool(args: GrepArgs, cwd: string): Promise { return `${rel}:${r.line}: ${r.content}`; }); - const output = lines.join("\n"); - const suffix = results.length >= MAX_RESULTS ? `\n\n[Showing first ${MAX_RESULTS} matches]` : ""; - return output + suffix; + const raw = lines.join("\n"); + const { text } = truncateOutput(raw, { + hint: results.length >= MAX_RESULTS + ? `[Showing first ${MAX_RESULTS} matches — use a more specific pattern to narrow results.]` + : undefined, + }); + return text; } export const grepToolDefinition = { diff --git a/electron/lib/coding-tools/index.ts b/electron/lib/coding-tools/index.ts index 1ec7768..e58307c 100644 --- a/electron/lib/coding-tools/index.ts +++ b/electron/lib/coding-tools/index.ts @@ -14,6 +14,7 @@ export { grepTool, grepToolDefinition } from "./grep"; export { findTool, findToolDefinition } from "./find"; export { lsTool, lsToolDefinition } from "./ls"; export { spawnSubagentDefinition, spawnSubagentTool } from "./subagent"; +export { skillTool, makeSkillToolDefinition } from "./skill"; export type { ReadArgs } from "./read"; export type { WriteArgs } from "./write"; @@ -23,6 +24,7 @@ export type { GrepArgs } from "./grep"; export type { FindArgs } from "./find"; export type { LsArgs } from "./ls"; export type { SpawnSubagentArgs } from "./subagent"; +export type { SkillArgs } from "./skill"; import { readToolDefinition } from "./read"; import { writeToolDefinition } from "./write"; diff --git a/electron/lib/coding-tools/ls.ts b/electron/lib/coding-tools/ls.ts index b680458..090cbb9 100644 --- a/electron/lib/coding-tools/ls.ts +++ b/electron/lib/coding-tools/ls.ts @@ -6,6 +6,7 @@ import fs from "fs"; import path from "path"; +import { truncateOutput } from "../truncation"; export interface LsArgs { path?: string; // directory to list (default: cwd) @@ -36,7 +37,8 @@ export async function lsTool(args: LsArgs, cwd: string): Promise { }) .map((e) => (e.isDirectory() ? `${e.name}/` : e.name)); - return lines.join("\n"); + const { text } = truncateOutput(lines.join("\n")); + return text; } export const lsToolDefinition = { diff --git a/electron/lib/coding-tools/read.ts b/electron/lib/coding-tools/read.ts index 47e3cae..75cd0b3 100644 --- a/electron/lib/coding-tools/read.ts +++ b/electron/lib/coding-tools/read.ts @@ -7,8 +7,9 @@ import fs from "fs"; import path from "path"; +import { truncateOutput, DEFAULT_MAX_LINES } from "../truncation"; -const MAX_LINES = 2000; +const MAX_LINES = DEFAULT_MAX_LINES; const MAX_BYTES = 200_000; export interface ReadArgs { @@ -40,27 +41,26 @@ export async function readTool(args: ReadArgs, cwd: string): Promise { const end = Math.min(start + limit, total); const slice = lines.slice(start, end); - let result = slice.map((line, i) => `${start + i + 1}: ${line}`).join("\n"); + const numbered = slice.map((line, i) => `${start + i + 1}: ${line}`).join("\n"); - // Byte cap - if (Buffer.byteLength(result, "utf8") > MAX_BYTES) { - const truncated: string[] = []; - let bytes = 0; - for (const line of slice) { - const lineBytes = Buffer.byteLength(line + "\n", "utf8"); - if (bytes + lineBytes > MAX_BYTES) break; - truncated.push(line); - bytes += lineBytes; - } - result = truncated.map((line, i) => `${start + i + 1}: ${line}`).join("\n"); - result += `\n\n[Output truncated. Use offset=${start + truncated.length + 1} to continue.]`; + // Apply byte cap via truncateOutput. + const truncResult = truncateOutput(numbered, { maxBytes: MAX_BYTES }); + + if (truncResult.truncated) { + // Build a precise pagination hint using the actual lines shown. + const linesShown = truncResult.shownLines ?? slice.length; + return truncResult.text.replace( + /\n\n\[Truncated:.*\]$/, + `\n\n[Output truncated. Use offset=${start + linesShown + 1} to continue.]`, + ); } + // If there are more lines beyond the current window, append a pagination hint. if (end < total) { - result += `\n\n[Showing lines ${offset}–${end} of ${total}. Use offset=${end + 1} to continue.]`; + return truncResult.text + `\n\n[Showing lines ${offset}–${end} of ${total}. Use offset=${end + 1} to continue.]`; } - return result; + return truncResult.text; } export const readToolDefinition = { diff --git a/electron/lib/coding-tools/skill.ts b/electron/lib/coding-tools/skill.ts new file mode 100644 index 0000000..2fd75aa --- /dev/null +++ b/electron/lib/coding-tools/skill.ts @@ -0,0 +1,64 @@ +/** + * skill — coding tool + * + * Loads the full body of a SKILL.md file on demand. The agent sees only + * name+description in the system prompt (low token cost). When a skill is + * relevant, the agent calls this tool to inject the full instructions. + * + * Compatible with the SKILL.md convention used by OpenCode, Cline, and + * Claude Code — skills authored for those agents work here unchanged. + */ + +import type { SkillMeta } from "../skills"; +import { loadSkill } from "../skills"; + +export interface SkillArgs { + /** Skill name (kebab-case, must match an entry in ). */ + name: string; +} + +export function makeSkillToolDefinition(skills: SkillMeta[]) { + const names = skills.map((s) => s.name); + const nameList = names.length > 0 ? names.join(", ") : "(none discovered)"; + + return { + type: "function" as const, + function: { + name: "skill", + description: + "Load the full instructions for a skill listed in . " + + "Call this when a skill's description matches the current task. " + + "The skill body will be injected into your context so you can follow its workflow. " + + `Available skills: ${nameList}.`, + parameters: { + type: "object", + properties: { + name: { + type: "string", + description: "The skill name to load (must match a in ).", + }, + }, + required: ["name"], + }, + }, + }; +} + +export function skillTool(args: SkillArgs, skills: SkillMeta[]): string { + const { name } = args; + + const content = loadSkill(name, skills); + if (!content) { + const available = skills.map((s) => s.name).join(", "); + return JSON.stringify({ + error: `Skill "${name}" not found. Available skills: ${available || "none"}.`, + }); + } + + const resourceSection = + content.resources.length > 0 + ? `\n\n## Bundled resources\nThe following files are co-located with this skill and can be read with the \`read\` tool:\n${content.resources.map((r) => `- ${r} (full path: ${content.dirPath}/${r})`).join("\n")}` + : ""; + + return `## Skill: ${content.name}\n\n${content.body}${resourceSection}`; +} diff --git a/electron/lib/compaction.ts b/electron/lib/compaction.ts index 9baef23..cf4b0bc 100644 --- a/electron/lib/compaction.ts +++ b/electron/lib/compaction.ts @@ -162,7 +162,6 @@ async function generateSummary( export function buildCompactionTransformer( session: PiAgentSession, llmConfig: AgentLLMConfig, - signal: AbortSignal, onCompactionStart?: () => void, onCompactionEnd?: (summary: string) => void, ): (messages: AgentMessage[]) => AgentMessage[] { @@ -173,6 +172,9 @@ export function buildCompactionTransformer( return (messages: AgentMessage[]): AgentMessage[] => { const lastPromptTokens = session.lastPromptTokens ?? 0; + // Always read the live signal from the session so a refreshed AbortController + // (created on each new prompt) is used rather than the one captured at build time. + const signal = session.abortCtrl.signal; // Below threshold — pass through unchanged if (lastPromptTokens === 0 || lastPromptTokens < contextWindow * COMPACT_THRESHOLD) { @@ -273,5 +275,27 @@ function slidingWindowFallback(messages: AgentMessage[]): AgentMessage[] { return pruned; } +/** + * Immediately summarise the full session history and return a compacted + * context array. Unlike buildCompactionTransformer (which is fire-and-forget), + * this awaits the LLM call and resolves with the final messages. + * + * Used by the /compact slash command to compact on demand. + * Returns null if the session has too few messages to be worth compacting. + */ +export async function compactNow( + session: PiAgentSession, + llmConfig: AgentLLMConfig, +): Promise<{ messages: AgentMessage[]; summary: string } | null> { + if (session.messages.length < 4) return null; // nothing meaningful to summarise + + const { toSummarise, toKeep } = splitMessages(session.messages); + if (toSummarise.length === 0) return null; + + const summary = await generateSummary(toSummarise, llmConfig, session.abortCtrl.signal); + const messages = buildCompactedContext(summary, session.messages[0], toKeep); + return { messages, summary }; +} + // Re-export threshold for use in pi-agent-loop default pruner export { COMPACT_THRESHOLD, FALLBACK_KEEP_TURNS, FALLBACK_THRESHOLD }; diff --git a/electron/lib/pi-agent-loop.ts b/electron/lib/pi-agent-loop.ts index 6183235..6307e8a 100644 --- a/electron/lib/pi-agent-loop.ts +++ b/electron/lib/pi-agent-loop.ts @@ -26,9 +26,11 @@ import { findTool, findToolDefinition, lsTool, lsToolDefinition, spawnSubagentDefinition, spawnSubagentTool, + skillTool, makeSkillToolDefinition, } from "./coding-tools/index"; import { executeTool } from "../ipc/chat-executor"; import type { ChatRequest, ToolArgs } from "./tools"; +import type { SkillMeta } from "./skills"; // ── LLM config ─────────────────────────────────────────────────────────────── @@ -89,6 +91,11 @@ export interface AgentToolContext { send: (channel: string, payload: unknown) => void; /** Returns the current BrowserWindow (may be null if destroyed). */ getWin?: () => BrowserWindow | null; + /** + * Discovered skills for this session. Passed to the `skill` tool so it can + * load the full body of a SKILL.md on demand. Defaults to empty array. + */ + skills?: SkillMeta[]; } // ── Tool label helper ───────────────────────────────────────────────────────── @@ -132,6 +139,12 @@ export interface AgentLoopCallbacks { * Use for context pruning, injection, or summarisation. */ transformContext?: (messages: AgentMessage[]) => AgentMessage[]; + /** + * Called after each turn's tool results are appended, before the next LLM + * call. Return true to stop the loop cleanly (fires onDone, not onError). + * Useful for semantic stop conditions e.g. "task card reached Done column". + */ + shouldStop?: (messages: AgentMessage[]) => boolean | Promise; } // ── Session state ───────────────────────────────────────────────────────────── @@ -141,6 +154,12 @@ export interface PiAgentSession { abortCtrl: AbortController; /** Most recent prompt_tokens count from the last onUsage callback. Updated each turn. */ lastPromptTokens?: number; + /** + * Compaction transformer for this session. Stored here so the cachedSummary + * inside it survives across multiple pi-agent:prompt calls on the same session. + * Built once on first use and reused; the signal is updated via abortCtrl each turn. + */ + compactionTransformer?: (messages: AgentMessage[]) => AgentMessage[]; } // ── All tool definitions ────────────────────────────────────────────────────── @@ -204,15 +223,19 @@ const PLAN_MODE_ALLOWED = new Set([ "ensure_note", // Renderer-side: renders inline question form "ask_questions", + // Skills — allowed in both modes so agents can load workflow instructions + "skill", ]); // ── Fetch all tool definitions (coding + Cairn subset) ──────────────────────── import { TOOLS as ALL_CAIRN_TOOLS } from "./tools"; -function getAllToolDefs(mode: "plan" | "execute" = "execute") { +function getAllToolDefs(mode: "plan" | "execute" = "execute", skills: SkillMeta[] = []) { const cairnSubset = ALL_CAIRN_TOOLS.filter((t) => CAIRN_TOOL_NAMES.has(t.function.name)); - const all = [...CODING_TOOL_DEFS, ...cairnSubset]; + // Only include the skill tool when at least one skill is available + const skillDef = skills.length > 0 ? [makeSkillToolDefinition(skills)] : []; + const all = [...CODING_TOOL_DEFS, ...skillDef, ...cairnSubset]; if (mode === "plan") { return all.filter((t) => PLAN_MODE_ALLOWED.has(t.function.name)); } @@ -332,6 +355,10 @@ async function executeSingleTool( case "grep": return grepTool(args as Parameters[0], cwd); case "find": return findTool(args as Parameters[0], cwd); case "ls": return lsTool(args as Parameters[0], cwd); + case "skill": return skillTool( + args as Parameters[0], + toolCtx.skills ?? [], + ); case "spawn_subagent": return spawnSubagentTool( args as Parameters[0], toolCtx, @@ -360,6 +387,16 @@ async function executeSingleTool( } // ── Main loop ───────────────────────────────────────────────────────────────── +// +// INVARIANT: within the Electron main process, always call runAgentLoop via +// runSession() in electron/ipc/pi-agent.ts — never directly. runSession() +// ensures the compaction transformer is built once per session and the correct +// IPC callbacks are wired. Direct callers bypass compaction and the note-update +// side effect in onToolEnd. +// +// The function is exported so pi-agent-loop.test.ts can call it directly with +// a mock toolCtx (no real Electron window). This is the only intended direct +// call site outside of runSession(). export async function runAgentLoop( session: PiAgentSession, @@ -370,7 +407,7 @@ export async function runAgentLoop( mode: "plan" | "execute" = "execute", ): Promise { const { signal } = session.abortCtrl; - const allTools = getAllToolDefs(mode); + const allTools = getAllToolDefs(mode, toolCtx.skills ?? []); const { baseUrl, model, apiKey, maxSteps, temperature: configTemp, @@ -596,7 +633,11 @@ export async function runAgentLoop( const pendingCallId = streamCallIds.get(tcIdx); callbacks.onToolStart(tc.function.name, label, pendingCallId); - // Yield so the renderer processes the onToolStart IPC before execution begins + // Yield to the event loop so the IPC layer dispatches the onToolStart event + // to the renderer before execution begins — this makes the chip appear in + // "running" state immediately rather than jumping straight to "done". + // A two-way IPC handshake (renderer acks → loop continues) would be more + // robust but isn't worth the added complexity here. await new Promise((r) => setImmediate(r)); let resultContent: string; @@ -639,6 +680,12 @@ export async function runAgentLoop( content: resultContent, }); } + + // Check semantic stop condition before starting the next LLM call. + if (callbacks.shouldStop && await callbacks.shouldStop(session.messages)) { + callbacks.onDone(); + return; + } // Loop continues → next LLM call with tool results appended } diff --git a/electron/lib/pi-agent-prompt.ts b/electron/lib/pi-agent-prompt.ts index a976978..0ede79d 100644 --- a/electron/lib/pi-agent-prompt.ts +++ b/electron/lib/pi-agent-prompt.ts @@ -13,6 +13,12 @@ export interface PiAgentPromptContext { mode?: "plan" | "execute"; /** In execute mode after plan approval: the full markdown content of the approved PRD */ planContent?: string; + /** + * Pre-rendered XML block. + * Generated by renderSkillsXml() from discovered SkillMeta[]. + * Empty string when no skills are available. + */ + skillsXml?: string; } export function buildPiAgentSystemPrompt(ctx: PiAgentPromptContext): string { @@ -31,6 +37,10 @@ function buildPlanModePrompt(ctx: PiAgentPromptContext): string { ? `\n**Active task:** ${ctx.taskTitle}` : ""; + const skillsSection = ctx.skillsXml + ? `\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. ## Context @@ -93,7 +103,7 @@ Use \`ensure_note\` with the title **"Plan: "** — derive t - **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_tasks** / **get_task** / **list_ready_tasks** — read the board +- **search_tasks** / **get_task** / **list_ready_tasks** — read the board${skillsSection} Tone: collaborative, curious, like a senior engineer helping clarify scope before diving in.`; } @@ -115,6 +125,10 @@ function buildExecuteModePrompt(ctx: PiAgentPromptContext): string { ? `\n\nThe active task is **"${ctx.taskTitle}"**. Your first tool call must be \`get_active_context\` to obtain column IDs, then immediately move this task to the **In Progress** column via \`update_task\` (pass \`columnId\`). When your work is complete, move it to **Review** (or **Done** if it is fully resolved).` : ""; + const skillsSection = ctx.skillsXml + ? `\n\n## Skills\nLoad a skill's full instructions when the task matches its description.\n- **skill** — load a skill by name\n\n${ctx.skillsXml}` + : ""; + return `You are the Cairn coding agent — an expert software engineer embedded inside the Cairn desktop app. You are not just a code executor. You are an active participant in the project: you read and write code, AND you keep the Cairn board and notes up to date as you work. This is non-negotiable. @@ -140,7 +154,7 @@ You are not just a code executor. You are an active participant in the project: - **patch_note** / **append_to_note** — targeted edit or append to an existing note - **search_notes** / **get_note** — find and read project notes - **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 +- **search_tasks** / **list_ready_tasks** — find tasks; list_ready_tasks returns only unblocked work${skillsSection} ## Mandatory Cairn workflow diff --git a/electron/lib/skills.ts b/electron/lib/skills.ts new file mode 100644 index 0000000..a13092b --- /dev/null +++ b/electron/lib/skills.ts @@ -0,0 +1,294 @@ +/** + * Skill loader for the Cairn native coding agent. + * + * Implements the industry-standard SKILL.md convention compatible with + * OpenCode, Cline, and Claude Code. Skills live in: + * + * Project-local (highest precedence, first match wins per name): + * .cairn/skills//SKILL.md + * .opencode/skills//SKILL.md + * .cline/skills//SKILL.md + * .claude/skills//SKILL.md + * .agents/skills//SKILL.md + * + * Global (fallback): + * ~/.config/cairn/skills//SKILL.md + * ~/.opencode/skills//SKILL.md + * + * Three-level loading model (token-efficient): + * 1. Metadata (name + description) — always injected into system prompt as XML + * 2. Full body — loaded on demand when the agent calls the `skill` tool + * 3. Bundled resources — co-located files the agent can read via the `read` tool + * + * Name rules: ^[a-z0-9]+(-[a-z0-9]+)*$ (1–64 chars, kebab-case, matches dir) + */ + +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import matter from "gray-matter"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** Parsed SKILL.md metadata (frontmatter only — always available). */ +export interface SkillMeta { + /** Kebab-case name, validated against dir name. */ + name: string; + /** One-line description shown to the model for relevance decisions. */ + description: string; + /** Optional SPDX license identifier. */ + license?: string; + /** Agent compatibility tag (e.g. "opencode", "cline", "cairo"). */ + compatibility?: string; + /** Arbitrary string-to-string metadata. */ + metadata?: Record; + /** Absolute path to the SKILL.md file. */ + filePath: string; + /** Absolute path to the skill directory (for bundled resource access). */ + dirPath: string; +} + +/** Full skill content returned when the agent calls the `skill` tool. */ +export interface SkillContent extends SkillMeta { + /** Raw markdown body of SKILL.md (everything below the frontmatter). */ + body: string; + /** Relative paths of co-located resource files (docs/, templates/, scripts/). */ + resources: string[]; +} + +// ── Name validation ─────────────────────────────────────────────────────────── + +const SKILL_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +function isValidSkillName(name: string): boolean { + return SKILL_NAME_RE.test(name) && name.length >= 1 && name.length <= 64; +} + +// ── Discovery paths ─────────────────────────────────────────────────────────── + +const SKILL_SUBDIRS = [ + path.join(".cairn", "skills"), + path.join(".opencode", "skills"), + path.join(".cline", "skills"), + path.join(".claude", "skills"), + path.join(".agents", "skills"), +]; + +/** + * Walk from `dir` up to the filesystem root, stopping early at a git root. + * Returns all ancestor directories in order (closest first). + */ +function walkUpToRoot(dir: string): string[] { + const dirs: string[] = []; + let current = path.resolve(dir); + const root = path.parse(current).root; + + while (true) { + dirs.push(current); + // Stop at git root so we don't escape the project + if (fs.existsSync(path.join(current, ".git"))) break; + const parent = path.dirname(current); + if (parent === current || current === root) break; + current = parent; + } + + return dirs; +} + +/** + * Returns ordered list of skill root directories to scan for a given cwd. + * Walks up from cwd to the git root so skills placed at the project root + * are found even when cwd is a subdirectory (e.g. packages/web/). + * Project-local paths take precedence; global paths are checked last. + */ +function getSkillSearchPaths(cwd: string): string[] { + const ancestors = walkUpToRoot(cwd); + + // For each ancestor dir, add all skill subdir variants (in ancestor order, + // so closer dirs win). Deduplicate in case cwd is already the git root. + const seen = new Set(); + const local: string[] = []; + for (const dir of ancestors) { + for (const sub of SKILL_SUBDIRS) { + const p = path.join(dir, sub); + if (!seen.has(p)) { + seen.add(p); + local.push(p); + } + } + } + + const home = os.homedir(); + const global = [ + path.join(home, ".config", "cairn", "skills"), + path.join(home, ".opencode", "skills"), + ]; + + return [...local, ...global]; +} + +// ── Parsing ─────────────────────────────────────────────────────────────────── + +/** Parse a SKILL.md file and return its metadata. Returns null if invalid. */ +function parseSkillFile(filePath: string, dirName: string): SkillMeta | null { + let raw: string; + try { + raw = fs.readFileSync(filePath, "utf8"); + } catch { + return null; + } + + let parsed: matter.GrayMatterFile; + try { + parsed = matter(raw); + } catch { + return null; + } + + const { data } = parsed; + + // Validate required fields + const name = typeof data.name === "string" ? data.name.trim() : ""; + const description = typeof data.description === "string" ? data.description.trim() : ""; + + if (!name || !description) return null; + if (!isValidSkillName(name)) return null; + + // Name must match the directory name for unambiguous lookup + if (name !== dirName) return null; + + return { + name, + description, + license: typeof data.license === "string" ? data.license : undefined, + compatibility: typeof data.compatibility === "string" ? data.compatibility : undefined, + metadata: data.metadata && typeof data.metadata === "object" + ? (data.metadata as Record) + : undefined, + filePath, + dirPath: path.dirname(filePath), + }; +} + +// ── Resource discovery ──────────────────────────────────────────────────────── + +const RESOURCE_SUBDIRS = ["docs", "templates", "scripts"]; + +/** + * Returns relative paths (from skill dir) of all co-located resource files. + * Only looks in docs/, templates/, scripts/ subdirectories. + */ +function listSkillResources(dirPath: string): string[] { + const resources: string[] = []; + + for (const sub of RESOURCE_SUBDIRS) { + const subDir = path.join(dirPath, sub); + if (!fs.existsSync(subDir)) continue; + + try { + const entries = fs.readdirSync(subDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile()) { + resources.push(path.join(sub, entry.name)); + } + } + } catch { + // Skip unreadable subdirectories + } + } + + return resources; +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Discover all available skills for the given working directory. + * + * Scans all search paths in order. First occurrence of a skill name wins + * (project-local takes precedence over global). + * + * Only metadata is loaded — the body is not read until `loadSkill()` is called. + */ +export function discoverSkills(cwd: string): SkillMeta[] { + const seen = new Set(); + const skills: SkillMeta[] = []; + + for (const searchPath of getSkillSearchPaths(cwd)) { + if (!fs.existsSync(searchPath)) continue; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(searchPath, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const dirName = entry.name; + if (seen.has(dirName)) continue; // project takes precedence over global + + const skillFile = path.join(searchPath, dirName, "SKILL.md"); + if (!fs.existsSync(skillFile)) continue; + + const meta = parseSkillFile(skillFile, dirName); + if (!meta) continue; + + seen.add(dirName); + skills.push(meta); + } + } + + return skills; +} + +/** + * Load the full body and resource list for a skill by name. + * + * Called when the agent invokes the `skill` tool. Returns null if the skill + * is not found or cannot be read. + */ +export function loadSkill(name: string, skills: SkillMeta[]): SkillContent | null { + const meta = skills.find((s) => s.name === name); + if (!meta) return null; + + let raw: string; + try { + raw = fs.readFileSync(meta.filePath, "utf8"); + } catch { + return null; + } + + let parsed: matter.GrayMatterFile; + try { + parsed = matter(raw); + } catch { + return null; + } + + return { + ...meta, + body: parsed.content.trim(), + resources: listSkillResources(meta.dirPath), + }; +} + +/** + * Renders the available skills as an XML block for injection into the system + * prompt. Only name and description are included — the full body is lazy. + * + * Returns an empty string if there are no skills. + */ +export function renderSkillsXml(skills: SkillMeta[]): string { + if (skills.length === 0) return ""; + + const items = skills + .map( + (s) => + ` \n ${s.name}\n ${s.description}\n `, + ) + .join("\n"); + + return `\n${items}\n`; +} diff --git a/electron/lib/truncation.ts b/electron/lib/truncation.ts new file mode 100644 index 0000000..7d3207c --- /dev/null +++ b/electron/lib/truncation.ts @@ -0,0 +1,128 @@ +/** + * Unified output truncation for coding tool results. + * + * Replaces ad-hoc per-tool truncation with a single consistent contract: + * - Byte-budget enforcement (hard cap) + * - Optional line-count cap + * - Structured result so callers can append pagination hints + * + * The model receives a `[Truncated …]` suffix so it knows output is incomplete + * and can use offset/limit parameters to page through large results. + */ + +export interface TruncationResult { + /** The (possibly truncated) text to return to the model. */ + text: string; + /** True if the output was cut short. */ + truncated: boolean; + /** Total byte length of the original output. */ + totalBytes: number; + /** Byte length of the text actually returned. */ + shownBytes: number; + /** Total line count of the original output (undefined if not line-based). */ + totalLines?: number; + /** Line count returned (undefined if not line-based). */ + shownLines?: number; +} + +export interface TruncateOptions { + /** Hard byte cap. Defaults to DEFAULT_MAX_BYTES. */ + maxBytes?: number; + /** Optional line cap applied before byte cap. */ + maxLines?: number; + /** + * Hint appended when truncated. Use {shown} and {total} as placeholders. + * Defaults to a generic message. + */ + hint?: string; +} + +export const DEFAULT_MAX_BYTES = 50_000; +export const DEFAULT_MAX_LINES = 2_000; + +/** + * Truncate `text` to fit within byte and/or line budgets. + * Always appends a human-readable hint when truncation occurs so the model + * knows to use offset/limit to retrieve the rest. + */ +export function truncateOutput(text: string, opts: TruncateOptions = {}): TruncationResult { + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES; + const maxLines = opts.maxLines; + const totalBytes = Buffer.byteLength(text, "utf8"); + + let working = text; + let totalLines: number | undefined; + let shownLines: number | undefined; + + // ── Line cap ────────────────────────────────────────────────────────────── + if (maxLines !== undefined) { + const lines = working.split("\n"); + totalLines = lines.length; + if (lines.length > maxLines) { + working = lines.slice(0, maxLines).join("\n"); + shownLines = maxLines; + } else { + shownLines = lines.length; + } + } + + // ── Byte cap ────────────────────────────────────────────────────────────── + const workingBytes = Buffer.byteLength(working, "utf8"); + if (workingBytes <= maxBytes) { + // No byte truncation needed + const truncated = working.length < text.length; // line-cap fired + const shownBytes = workingBytes; + if (truncated) { + const hint = buildHint(opts.hint, shownLines, totalLines, shownBytes, totalBytes); + return { text: working + hint, truncated: true, totalBytes, shownBytes, totalLines, shownLines }; + } + return { text: working, truncated: false, totalBytes, shownBytes, totalLines, shownLines }; + } + + // Byte-trim: walk chars until budget exhausted + let budget = maxBytes; + // Use a buffer approach to avoid O(n²) string concat + const encoder = new TextEncoder(); + const chars: string[] = []; + for (const char of working) { + const charBytes = encoder.encode(char).length; + if (budget - charBytes < 0) break; + chars.push(char); + budget -= charBytes; + } + + const trimmed = chars.join(""); + const shownBytes = Buffer.byteLength(trimmed, "utf8"); + + // Update shownLines after byte trim + if (maxLines !== undefined) { + shownLines = trimmed.split("\n").length; + } + + const hint = buildHint(opts.hint, shownLines, totalLines, shownBytes, totalBytes); + return { text: trimmed + hint, truncated: true, totalBytes, shownBytes, totalLines, shownLines }; +} + +function buildHint( + template: string | undefined, + shownLines: number | undefined, + totalLines: number | undefined, + shownBytes: number, + totalBytes: number, +): string { + if (template) { + return "\n\n" + template + .replace("{shown}", String(shownLines ?? shownBytes)) + .replace("{total}", String(totalLines ?? totalBytes)); + } + if (shownLines !== undefined && totalLines !== undefined) { + return `\n\n[Truncated: showed ${shownLines} of ${totalLines} lines (${fmt(shownBytes)} of ${fmt(totalBytes)}). Use offset/limit to page.]`; + } + return `\n\n[Truncated: showed ${fmt(shownBytes)} of ${fmt(totalBytes)}. Use offset/limit to page.]`; +} + +function fmt(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} diff --git a/electron/preload.ts b/electron/preload.ts index 841eff0..298886c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -278,6 +278,8 @@ const api = { clear: (sessionId: string) => ipcRenderer.send("pi-agent:clear", { sessionId }), /** Destroy a session when the tab is closed. */ destroy: (sessionId: string) => ipcRenderer.send("pi-agent:destroy", { sessionId }), + /** Trigger immediate LLM-based compaction on demand (/compact command). */ + compactNow: (req: unknown) => ipcRenderer.send("pi-agent:compact-now", req), onToken: (cb: (e: { sessionId: string; delta: string }) => void) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -335,6 +337,13 @@ const api = { ipcRenderer.on("pi-agent:compact", handler); return () => ipcRenderer.off("pi-agent:compact", handler); }, + /** Fired after a /compact slash command completes with the result. */ + onCompactResult: (cb: (e: { sessionId: string; messageCount: number; summary: string }) => void) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = (_: any, e: { sessionId: string; messageCount: number; summary: string }) => cb(e); + ipcRenderer.on("pi-agent:compact-result", handler); + return () => ipcRenderer.off("pi-agent:compact-result", handler); + }, onSubagent: (cb: (e: { parentSessionId: string; childSessionId: string; status: "start" | "done"; result?: string }) => void) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const handler = (_: any, e: { parentSessionId: string; childSessionId: string; status: "start" | "done"; result?: string }) => cb(e); @@ -383,6 +392,14 @@ const api = { saveMessages: (sessionId: string, messages: unknown[]) => invoke("db:piSession:saveMessages", { sessionId, messages }), /** Restore LLM context for a session (loads history into main-process Map) — fire-and-forget */ restoreContext: (sessionId: string) => ipcRenderer.send("pi-agent:restore-context", { sessionId }), + /** + * Preview the assembled system prompt and discovered skills for a given cwd. + * Used by Settings → Coding Agents to show what the agent will receive. + */ + previewPrompt: (req: { cwd: string; projectId?: string; mode?: "plan" | "execute" }) => + invoke<{ systemPrompt: string; skills: Array<{ name: string; description: string; filePath: string; dirPath: string; license?: string; compatibility?: string }> }>( + "pi-agent:preview-prompt", req + ), }, } as const; diff --git a/src/components/agent/PiAgentPane.tsx b/src/components/agent/PiAgentPane.tsx index eee624f..b8c16ce 100644 --- a/src/components/agent/PiAgentPane.tsx +++ b/src/components/agent/PiAgentPane.tsx @@ -366,6 +366,15 @@ export function PiAgentPane({ session, isActive }: PiAgentPaneProps) { setIsCompacting(e.status === "start"); }); + // /compact result — inject a system message confirming compaction + const unsubCompactResult = electron.piAgent.onCompactResult((e) => { + if (e.sessionId !== sessionId) return; + const msg = e.messageCount > 0 + ? `Context compacted — session history summarised into ${e.messageCount} messages.` + : "Nothing to compact — session history is too short."; + addPiMessage(sessionId, { id: id(), role: "system" as const, content: msg, timestamp: new Date().toISOString() }); + }); + return () => { unsubToken(); unsubUsage(); @@ -384,6 +393,7 @@ export function PiAgentPane({ session, isActive }: PiAgentPaneProps) { unsubNoteUpdated(); unsubRetry(); unsubCompact(); + unsubCompactResult(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [session.sessionId]); @@ -392,6 +402,20 @@ export function PiAgentPane({ session, isActive }: PiAgentPaneProps) { const trimmed = text.trim(); if (!trimmed || isLoading || !session.cwd) return; + // ── Slash commands ───────────────────────────────────────────────────── + if (trimmed === "/compact") { + setInput(""); + window.electron?.piAgent.compactNow({ + sessionId: session.sessionId, + config: { + baseUrl: aiConfig.baseUrl || undefined, + model: aiConfig.model || undefined, + apiKey: aiConfig.apiKey || undefined, + }, + }); + return; + } + setInput(""); setIsLoading(true); setPendingQuestions(null); diff --git a/src/components/agent/PiMessageBubble.tsx b/src/components/agent/PiMessageBubble.tsx index c986890..3e8baca 100644 --- a/src/components/agent/PiMessageBubble.tsx +++ b/src/components/agent/PiMessageBubble.tsx @@ -269,8 +269,18 @@ interface PiMessageBubbleProps { } export function PiMessageBubble({ message }: PiMessageBubbleProps) { - const isUser = message.role === "user"; - const isError = message.role === "error"; + const isUser = message.role === "user"; + const isError = message.role === "error"; + const isSystem = message.role === "system"; + + // SYSTEM bubble — centred, muted, italic — used for slash command feedback + if (isSystem) { + return ( +
+ {message.content} +
+ ); + } // USER bubble — right-aligned, accent background, User icon if (isUser) { diff --git a/src/components/settings/AgentSettings.tsx b/src/components/settings/AgentSettings.tsx index cf3fa00..ec6979e 100644 --- a/src/components/settings/AgentSettings.tsx +++ b/src/components/settings/AgentSettings.tsx @@ -6,15 +6,19 @@ * Lets users register/edit/delete AI coding agent CLI configurations * (Claude Code, OpenCode, Aider, etc.) and set a global default. * Also shows the code directory for the active project. + * + * Includes a Skills & System Prompt preview section showing which + * SKILL.md files were discovered and the full assembled system prompt. */ -import { useState, useEffect } from "react"; -import { Plus, Trash2, Star, FolderOpen, Check } from "lucide-react"; +import { useState, useEffect, useCallback } from "react"; +import { Plus, Trash2, Star, FolderOpen, Check, BookOpen, ChevronDown, ChevronUp, Copy, CheckCircle, RefreshCw, FileCode } from "lucide-react"; import { useCairnStore } from "@/store"; import { useShallow } from "zustand/react/shallow"; import { Button } from "@/components/ui/button"; -import { id } from "@/lib/utils"; +import { id, cn } from "@/lib/utils"; import type { CodingAgent } from "@/store/slices/coding-agents"; +import { SettingsGroup } from "./shared"; // ── Agent form ──────────────────────────────────────────────────────────────── @@ -89,7 +93,7 @@ function AgentForm({ initial, onSave, onCancel }: AgentFormProps) { +
+ +
+
+
+ {skill.name} + {skill.compatibility && ( + + {skill.compatibility} + + )} + {skill.license && ( + + {skill.license} + + )} +
+

{skill.description}

+

+ {skill.filePath} +

+
+ + ); +} + +// ── System prompt preview ───────────────────────────────────────────────────── + +interface PromptPreviewProps { + systemPrompt: string; +} + +function PromptPreview({ systemPrompt }: PromptPreviewProps) { + const [expanded, setExpanded] = useState(false); + const [copied, setCopied] = useState(false); + + const lines = systemPrompt.split("\n"); + const PREVIEW_LINES = 6; + const preview = lines.slice(0, PREVIEW_LINES).join("\n"); + const hasMore = lines.length > PREVIEW_LINES; + + function copy() { + navigator.clipboard.writeText(systemPrompt); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( +
+ {/* Header */} +
+
+ + System prompt + ({lines.length} lines) +
+ +
+ + {/* Content */} +
+
+          {expanded ? systemPrompt : preview}
+        
+ {!expanded && hasMore && ( +
+ )} +
+ + {/* Expand toggle */} + {hasMore && ( + + )} +
+ ); +} + +// ── Skills & prompt preview section ────────────────────────────────────────── + +function SkillsPreviewSection() { + const { projects, activeProjectId } = useCairnStore(useShallow((s) => ({ + projects: s.projects, + activeProjectId: s.activeProjectId, + }))); + + const [workspacePath, setWorkspacePath] = useState(null); + const [mode, setMode] = useState<"execute" | "plan">("execute"); + const [skills, setSkills] = useState(null); + const [systemPrompt, setSystemPrompt] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const activeProject = projects.find((p) => p.id === activeProjectId) ?? projects[0]; + const activeProjectIdForLoad = activeProject?.id; + + useEffect(() => { + window.electron?.getWorkspacePath().then((p) => setWorkspacePath(p ?? null)); + }, []); + + const load = useCallback(async () => { + if (!workspacePath) return; + setLoading(true); + setError(null); + try { + const result = await window.electron?.piAgent.previewPrompt({ + cwd: workspacePath, + projectId: activeProjectIdForLoad ?? undefined, + mode, + }); + if (result) { + setSkills(result.skills); + setSystemPrompt(result.systemPrompt); + } + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, [workspacePath, activeProjectIdForLoad, mode]); + + // Auto-load on mount and when deps change + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => { load(); }, [load]); + + return ( + + {/* Mode toggle + refresh */} +
+
+ {(["execute", "plan"] as const).map((m) => ( + + ))} +
+ +
+ + {/* Workspace path context */} + {workspacePath && ( +
+ + {workspacePath} +
+ )} + + {error && ( +
+

{error}

+
+ )} + + {/* Skills list */} + {skills !== null && ( +
+
+

+ Discovered Skills +

+ + {skills.length === 0 ? "none" : `${skills.length} skill${skills.length !== 1 ? "s" : ""}`} + +
+ + {skills.length === 0 ? ( +
+ +

No skills found

+

+ Create a .cairn/skills/<name>/SKILL.md file in your workspace to get started. Compatible with OpenCode, Cline, and Claude Code skill formats. +

+
+ ) : ( +
+ {skills.map((skill) => ( + + ))} +
+ )} +
+ )} + + {/* System prompt preview */} + {systemPrompt !== null && ( +
+

+ Assembled System Prompt +

+ +
+ )} + + {loading && skills === null && ( +
+ +
+ )} +
+ ); +} + // ── AgentSettings ───────────────────────────────────────────────────────────── export function AgentSettings() { @@ -252,6 +508,9 @@ export function AgentSettings() { ) )}
+ + {/* Skills & system prompt preview */} + ); } diff --git a/src/store/slices/terminal-sessions.ts b/src/store/slices/terminal-sessions.ts index 89c31ba..6fce732 100644 --- a/src/store/slices/terminal-sessions.ts +++ b/src/store/slices/terminal-sessions.ts @@ -27,7 +27,7 @@ export interface PiSubagentMessage { export interface PiAgentMessage { id: string; - role: "user" | "assistant" | "error"; + role: "user" | "assistant" | "error" | "system"; content: string; /** Tool calls that occurred before or during this assistant message */ toolCalls?: {