From 2d7c5f8c53505fffd7b19266afb378e03bc43848 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 22 Dec 2025 18:05:44 +0100 Subject: [PATCH] refactor: migrate embedded pi to sdk --- docs/agent.md | 6 ++ package.json | 6 +- pnpm-lock.yaml | 46 ++++----- src/agents/context.ts | 23 ++--- src/agents/pi-embedded.ts | 200 ++++++++++++++++++++---------------- src/agents/pi-oauth.ts | 112 -------------------- src/agents/skills.ts | 5 +- src/agents/system-prompt.ts | 66 ++---------- src/config/sessions.ts | 2 + src/gateway/server.test.ts | 96 ++++++++--------- src/gateway/server.ts | 46 +++++---- src/web/logout.test.ts | 54 ++++++---- 12 files changed, 276 insertions(+), 386 deletions(-) delete mode 100644 src/agents/pi-oauth.ts diff --git a/docs/agent.md b/docs/agent.md index e304da09e..65b0ad5a0 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -41,6 +41,12 @@ Clawdis loads skills from three locations (workspace wins on name conflict): Skills can be gated by config/env (see `skills.*` in `docs/configuration.md`). +## SDK integration + +The embedded agent uses the `@mariozechner/pi-coding-agent` SDK for sessions and discovery. +- Hooks, custom tools, and slash commands are discovered via the SDK (from `~/.pi/agent` and `/.pi` settings). +- Bootstrap files are injected as SDK project context (see “Project Context” in the system prompt). + ## Peter @ steipete (only) Apply these notes **only** when the user is Peter Steinberger at steipete. diff --git a/package.json b/package.json index 0bb4008fb..759032407 100644 --- a/package.json +++ b/package.json @@ -67,9 +67,9 @@ "dependencies": { "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.4", - "@mariozechner/pi-agent-core": "^0.25.0", - "@mariozechner/pi-ai": "^0.25.0", - "@mariozechner/pi-coding-agent": "^0.25.0", + "@mariozechner/pi-agent-core": "^0.26.0", + "@mariozechner/pi-ai": "^0.26.0", + "@mariozechner/pi-coding-agent": "^0.26.0", "@sinclair/typebox": "^0.34.41", "@whiskeysockets/baileys": "7.0.0-rc.9", "ajv": "^8.17.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5141bd556..6ca49372d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,14 +15,14 @@ importers: specifier: ^1.3.4 version: 1.3.4 '@mariozechner/pi-agent-core': - specifier: ^0.25.0 - version: 0.25.0(ws@8.18.3)(zod@4.2.1) + specifier: ^0.26.0 + version: 0.26.1(ws@8.18.3)(zod@4.2.1) '@mariozechner/pi-ai': - specifier: ^0.25.0 - version: 0.25.0(ws@8.18.3)(zod@4.2.1) + specifier: ^0.26.0 + version: 0.26.1(ws@8.18.3)(zod@4.2.1) '@mariozechner/pi-coding-agent': - specifier: ^0.25.0 - version: 0.25.0(ws@8.18.3)(zod@4.2.1) + specifier: ^0.26.0 + version: 0.26.1(ws@8.18.3)(zod@4.2.1) '@sinclair/typebox': specifier: ^0.34.41 version: 0.34.41 @@ -659,21 +659,21 @@ packages: peerDependencies: lit: ^3.3.1 - '@mariozechner/pi-agent-core@0.25.0': - resolution: {integrity: sha512-aiM0GvkmHJtFudNGlXiuLr/IqRot1Sus9vqrarVf/gF5ooubYyGYhP6QotAfbFqI0z6HpFa2O3mx8KEp0AiBKg==} + '@mariozechner/pi-agent-core@0.26.1': + resolution: {integrity: sha512-yH15oPK9l8F2vGrz2mXl0dRydKkw0x4p1WChVuQALqDaFOf48V2XbLS7SvTE3qx095ylNp/Q+RQ+NiB5I2myFA==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-ai@0.25.0': - resolution: {integrity: sha512-N3INs/PNIEYx/U8tM6NaV75Gpx263o4b+YYxsD1Ag9ratdzz+JxL2ATYENi+Ma+BjsMaowPCMO2oeotHdsr/cA==} + '@mariozechner/pi-ai@0.26.1': + resolution: {integrity: sha512-VEH9kwQoo0N1KtBQnAHDZaIwe0nLwikGytNvjCV3RltQirywwUUsw0xQ/2YUXaN3vl3nqDO/VY1qgdSnVZE5iA==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-coding-agent@0.25.0': - resolution: {integrity: sha512-docYKq6zEVZcO5ngb0NTpayeipr+pLCMCeNfwdiC55zNI5nKMg1O4s6aMv2clJ4fUisHP0uhyK9URIohqSadbw==} + '@mariozechner/pi-coding-agent@0.26.1': + resolution: {integrity: sha512-o1WOhzwPQTiUBNxlANDXJ9bTOIIpxxkwRh9+nnz9F28uEzkSfTrJLTgJoWxuRAU7Xvj5//pkKYaUPfhCd69R9g==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-tui@0.25.0': - resolution: {integrity: sha512-7pU/EPFTYgyEsfcDBb+fzp6BQWr6tmykgMMGZx3Pxvet3NF5HmphAdLBitjmThri+M7lrGaJVrpIRHjQM1CPVQ==} + '@mariozechner/pi-tui@0.26.1': + resolution: {integrity: sha512-qGKS4SwxJw4pinttl3UvzylC1IuB31QpuoM3X36mz/GmLq52RNYnriK4si52GpeTrqNm8vXDpeevI0zhPQPjYw==} engines: {node: '>=20.0.0'} '@mistralai/mistralai@1.10.0': @@ -2991,10 +2991,10 @@ snapshots: transitivePeerDependencies: - tailwindcss - '@mariozechner/pi-agent-core@0.25.0(ws@8.18.3)(zod@4.2.1)': + '@mariozechner/pi-agent-core@0.26.1(ws@8.18.3)(zod@4.2.1)': dependencies: - '@mariozechner/pi-ai': 0.25.0(ws@8.18.3)(zod@4.2.1) - '@mariozechner/pi-tui': 0.25.0 + '@mariozechner/pi-ai': 0.26.1(ws@8.18.3)(zod@4.2.1) + '@mariozechner/pi-tui': 0.26.1 transitivePeerDependencies: - '@modelcontextprotocol/sdk' - bufferutil @@ -3003,7 +3003,7 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.25.0(ws@8.18.3)(zod@4.2.1)': + '@mariozechner/pi-ai@0.26.1(ws@8.18.3)(zod@4.2.1)': dependencies: '@anthropic-ai/sdk': 0.71.2(zod@4.2.1) '@google/genai': 1.34.0 @@ -3023,11 +3023,11 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.25.0(ws@8.18.3)(zod@4.2.1)': + '@mariozechner/pi-coding-agent@0.26.1(ws@8.18.3)(zod@4.2.1)': dependencies: - '@mariozechner/pi-agent-core': 0.25.0(ws@8.18.3)(zod@4.2.1) - '@mariozechner/pi-ai': 0.25.0(ws@8.18.3)(zod@4.2.1) - '@mariozechner/pi-tui': 0.25.0 + '@mariozechner/pi-agent-core': 0.26.1(ws@8.18.3)(zod@4.2.1) + '@mariozechner/pi-ai': 0.26.1(ws@8.18.3)(zod@4.2.1) + '@mariozechner/pi-tui': 0.26.1 chalk: 5.6.2 cli-highlight: 2.1.11 diff: 8.0.2 @@ -3042,7 +3042,7 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.25.0': + '@mariozechner/pi-tui@0.26.1': dependencies: '@types/mime-types': 2.1.4 chalk: 5.6.2 diff --git a/src/agents/context.ts b/src/agents/context.ts index 061a16f8b..eb442820f 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -1,24 +1,17 @@ -// Lazy-load pi-ai model metadata so we can infer context windows when the agent -// reports a model id. pi-coding-agent depends on @mariozechner/pi-ai, so it -// should be present whenever CLAWDIS is installed from npm. +// Lazy-load pi-coding-agent model metadata so we can infer context windows when +// the agent reports a model id. This includes custom models.json entries. type ModelEntry = { id: string; contextWindow?: number }; const MODEL_CACHE = new Map(); const loadPromise = (async () => { try { - const piAi = (await import("@mariozechner/pi-ai")) as { - getProviders: () => string[]; - getModels: (provider: string) => ModelEntry[]; - }; - const providers = piAi.getProviders(); - for (const p of providers) { - const models = piAi.getModels(p) as ModelEntry[]; - for (const m of models) { - if (!m?.id) continue; - if (typeof m.contextWindow === "number" && m.contextWindow > 0) { - MODEL_CACHE.set(m.id, m.contextWindow); - } + const { discoverModels } = await import("@mariozechner/pi-coding-agent"); + const models = discoverModels() as ModelEntry[]; + for (const m of models) { + if (!m?.id) continue; + if (typeof m.contextWindow === "number" && m.contextWindow > 0) { + MODEL_CACHE.set(m.id, m.contextWindow); } } } catch { diff --git a/src/agents/pi-embedded.ts b/src/agents/pi-embedded.ts index 39e91ddcd..70683e586 100644 --- a/src/agents/pi-embedded.ts +++ b/src/agents/pi-embedded.ts @@ -1,28 +1,26 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { - Agent, - type AgentEvent, - type AppMessage, - ProviderTransport, - type ThinkingLevel, +import type { + AgentEvent, + AppMessage, + ThinkingLevel, } from "@mariozechner/pi-agent-core"; -import { - type AgentToolResult, - type Api, - type AssistantMessage, - getApiKey, - getModels, - getProviders, - type KnownProvider, - type Model, +import type { + AgentToolResult, + Api, + AssistantMessage, + Model, } from "@mariozechner/pi-ai"; import { - AgentSession, - messageTransformer, + buildSystemPrompt, + createAgentSession, + defaultGetApiKey, + findModel, SessionManager, SettingsManager, + type Skill, } from "@mariozechner/pi-coding-agent"; import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js"; import { @@ -39,7 +37,6 @@ import { extractAssistantText, inferToolMetaFromArgs, } from "./pi-embedded-utils.js"; -import { getAnthropicOAuthToken } from "./pi-oauth.js"; import { createClawdisCodingTools, sanitizeContentBlocksImages, @@ -49,10 +46,14 @@ import { applySkillEnvOverridesFromSnapshot, buildWorkspaceSkillSnapshot, loadWorkspaceSkillEntries, + type SkillEntry, type SkillSnapshot, } from "./skills.js"; -import { buildAgentSystemPrompt } from "./system-prompt.js"; -import { loadWorkspaceBootstrapFiles } from "./workspace.js"; +import { buildAgentSystemPromptAppend } from "./system-prompt.js"; +import { + loadWorkspaceBootstrapFiles, + type WorkspaceBootstrapFile, +} from "./workspace.js"; export type EmbeddedPiAgentMeta = { sessionId: string; @@ -106,18 +107,16 @@ function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel { return level; } -function isKnownProvider(provider: string): provider is KnownProvider { - return getProviders().includes(provider as KnownProvider); -} - function resolveModel( provider: string, modelId: string, -): Model | undefined { - if (!isKnownProvider(provider)) return undefined; - const models = getModels(provider); - const model = models.find((m) => m.id === modelId); - return model as Model | undefined; + agentDir?: string, +): { model?: Model; error?: string } { + const result = findModel(provider, modelId, agentDir); + return { + model: (result.model ?? undefined) as Model | undefined, + error: result.error ?? undefined, + }; } async function ensureSessionHeader(params: { @@ -148,20 +147,22 @@ async function ensureSessionHeader(params: { await fs.writeFile(file, `${JSON.stringify(entry)}\n`, "utf-8"); } -async function getApiKeyForProvider( - provider: string, -): Promise { - if (provider === "anthropic") { - const oauthToken = await getAnthropicOAuthToken(); - if (oauthToken) return oauthToken; +const defaultApiKey = defaultGetApiKey(); + +async function getApiKeyForModel(model: { provider: string }): Promise { + if (model.provider === "anthropic") { const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN; if (oauthEnv?.trim()) return oauthEnv.trim(); } - return getApiKey(provider) ?? undefined; + const key = await defaultApiKey(model as unknown as Model); + if (key) return key; + throw new Error(`No API key found for provider "${model.provider}"`); } type ContentBlock = AgentToolResult["content"][number]; +type ContextFile = { path: string; content: string }; + async function sanitizeSessionMessagesImages( messages: AppMessage[], label: string, @@ -205,6 +206,36 @@ async function sanitizeSessionMessagesImages( return out; } +function buildBootstrapContextFiles( + files: WorkspaceBootstrapFile[], +): ContextFile[] { + return files.map((file) => ({ + path: file.name, + content: file.missing + ? `[MISSING] Expected at: ${file.path}` + : (file.content ?? ""), + })); +} + +function resolvePromptSkills( + snapshot: SkillSnapshot, + entries: SkillEntry[], +): Skill[] { + if (snapshot.resolvedSkills?.length) { + return snapshot.resolvedSkills; + } + + const snapshotNames = snapshot.skills.map((entry) => entry.name); + if (snapshotNames.length === 0) return []; + + const entryByName = new Map( + entries.map((entry) => [entry.skill.name, entry.skill]), + ); + return snapshotNames + .map((name) => entryByName.get(name)) + .filter((skill): skill is Skill => Boolean(skill)); +} + function formatAssistantErrorText(msg: AssistantMessage): string | undefined { if (msg.stopReason !== "error") return undefined; const raw = (msg.errorMessage ?? "").trim(); @@ -259,9 +290,12 @@ export async function runEmbeddedPiAgent(params: { const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; - const model = resolveModel(provider, modelId); + const agentDir = + process.env.PI_CODING_AGENT_DIR ?? + path.join(os.homedir(), ".pi", "agent"); + const { model, error } = resolveModel(provider, modelId, agentDir); if (!model) { - throw new Error(`Unknown model: ${provider}/${modelId}`); + throw new Error(error ?? `Unknown model: ${provider}/${modelId}`); } const thinkingLevel = mapThinkingLevel(params.thinkLevel); @@ -279,11 +313,11 @@ export async function runEmbeddedPiAgent(params: { let restoreSkillEnv: (() => void) | undefined; process.chdir(resolvedWorkspace); try { - const skillEntries = params.skillsSnapshot - ? undefined - : loadWorkspaceSkillEntries(resolvedWorkspace, { - config: params.config, - }); + const shouldLoadSkillEntries = + !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills; + const skillEntries = shouldLoadSkillEntries + ? loadWorkspaceSkillEntries(resolvedWorkspace) + : []; const skillsSnapshot = params.skillsSnapshot ?? buildWorkspaceSkillSnapshot(resolvedWorkspace, { @@ -302,60 +336,48 @@ export async function runEmbeddedPiAgent(params: { const bootstrapFiles = await loadWorkspaceBootstrapFiles(resolvedWorkspace); - const systemPrompt = buildAgentSystemPrompt({ - workspaceDir: resolvedWorkspace, - bootstrapFiles: bootstrapFiles.map((f) => ({ - name: f.name, - path: f.path, - content: f.content, - missing: f.missing, - })), - defaultThinkLevel: params.thinkLevel, - }); - const systemPromptWithSkills = systemPrompt + skillsSnapshot.prompt; - - const sessionManager = new SessionManager(false, params.sessionFile); - const settingsManager = new SettingsManager(); - - const agent = new Agent({ - initialState: { - systemPrompt: systemPromptWithSkills, - model, - thinkingLevel, - // TODO(steipete): Once pi-mono publishes file-magic MIME detection in `read` image payloads, - // remove `createClawdisCodingTools()` and use upstream `codingTools` again. - tools: createClawdisCodingTools(), - }, - messageTransformer, - queueMode: settingsManager.getQueueMode(), - transport: new ProviderTransport({ - getApiKey: async (providerName) => { - const key = await getApiKeyForProvider(providerName); - if (!key) { - throw new Error( - `No API key found for provider "${providerName}"`, - ); - } - return key; - }, + const contextFiles = buildBootstrapContextFiles(bootstrapFiles); + const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries); + const tools = createClawdisCodingTools(); + const systemPrompt = buildSystemPrompt({ + appendPrompt: buildAgentSystemPromptAppend({ + workspaceDir: resolvedWorkspace, + defaultThinkLevel: params.thinkLevel, }), + contextFiles, + skills: promptSkills, + cwd: resolvedWorkspace, + }); + + const sessionManager = SessionManager.open(params.sessionFile, agentDir); + const settingsManager = SettingsManager.create( + resolvedWorkspace, + agentDir, + ); + + const { session } = await createAgentSession({ + cwd: resolvedWorkspace, + agentDir, + model, + thinkingLevel, + systemPrompt, + // TODO(steipete): Once pi-mono publishes file-magic MIME detection in `read` image payloads, + // remove `createClawdisCodingTools()` and use upstream `codingTools` again. + tools, + sessionManager, + settingsManager, + getApiKey: getApiKeyForModel, + skills: promptSkills, + contextFiles, }); - // Resume messages from the transcript if present. - const priorRaw = sessionManager.loadSession().messages; const prior = await sanitizeSessionMessagesImages( - priorRaw, + session.messages, "session:history", ); if (prior.length > 0) { - agent.replaceMessages(prior); + session.agent.replaceMessages(prior); } - - const session = new AgentSession({ - agent, - sessionManager, - settingsManager, - }); const queueHandle: EmbeddedPiQueueHandle = { queueMessage: async (text: string) => { await session.queueMessage(text); diff --git a/src/agents/pi-oauth.ts b/src/agents/pi-oauth.ts deleted file mode 100644 index b4e96f1af..000000000 --- a/src/agents/pi-oauth.ts +++ /dev/null @@ -1,112 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR"; - -type OAuthCredentials = { - type: "oauth"; - refresh: string; - access: string; - /** Unix ms timestamp (already includes buffer) */ - expires: number; -}; - -type OAuthStorageFormat = Record; - -const ANTHROPIC_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; -const ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"; - -function getPiAgentDir(): string { - const override = process.env[PI_AGENT_DIR_ENV]; - if (override?.trim()) return override.trim(); - return path.join(os.homedir(), ".pi", "agent"); -} - -function getPiOAuthPath(): string { - return path.join(getPiAgentDir(), "oauth.json"); -} - -async function loadOAuthStorage(): Promise { - const filePath = getPiOAuthPath(); - try { - const raw = await fs.readFile(filePath, "utf-8"); - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === "object") { - return parsed as OAuthStorageFormat; - } - } catch { - // missing/invalid: treat as empty - } - return {}; -} - -async function saveOAuthStorage(storage: OAuthStorageFormat): Promise { - const filePath = getPiOAuthPath(); - await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); - await fs.writeFile(filePath, JSON.stringify(storage, null, 2), { - encoding: "utf-8", - mode: 0o600, - }); - try { - await fs.chmod(filePath, 0o600); - } catch { - // best effort (windows / restricted fs) - } -} - -async function refreshAnthropicToken( - refreshToken: string, -): Promise { - const tokenResponse = await fetch(ANTHROPIC_TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - grant_type: "refresh_token", - client_id: ANTHROPIC_CLIENT_ID, - refresh_token: refreshToken, - }), - }); - - if (!tokenResponse.ok) { - const error = await tokenResponse.text(); - throw new Error(`Anthropic OAuth token refresh failed: ${error}`); - } - - const tokenData = (await tokenResponse.json()) as { - refresh_token: string; - access_token: string; - expires_in: number; - }; - - // 5 min buffer - const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000; - return { - type: "oauth", - refresh: tokenData.refresh_token, - access: tokenData.access_token, - expires: expiresAt, - }; -} - -export async function getAnthropicOAuthToken(): Promise { - const storage = await loadOAuthStorage(); - const creds = storage.anthropic; - if (!creds) return null; - - // If expired, attempt refresh; on failure, remove creds. - if (Date.now() >= creds.expires) { - try { - const refreshed = await refreshAnthropicToken(creds.refresh); - storage.anthropic = refreshed; - await saveOAuthStorage(storage); - return refreshed.access; - } catch { - delete storage.anthropic; - await saveOAuthStorage(storage); - return null; - } - } - - return creds.access; -} diff --git a/src/agents/skills.ts b/src/agents/skills.ts index e686490b6..9a041b638 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -51,6 +51,7 @@ export type SkillEntry = { export type SkillSnapshot = { prompt: string; skills: Array<{ name: string; primaryEnv?: string }>; + resolvedSkills?: Skill[]; }; function resolveBundledSkillsDir(): string | undefined { @@ -505,12 +506,14 @@ export function buildWorkspaceSkillSnapshot( ): SkillSnapshot { const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); const eligible = filterSkillEntries(skillEntries, opts?.config); + const resolvedSkills = eligible.map((entry) => entry.skill); return { - prompt: formatSkillsForPrompt(eligible.map((entry) => entry.skill)), + prompt: formatSkillsForPrompt(resolvedSkills), skills: eligible.map((entry) => ({ name: entry.skill.name, primaryEnv: entry.clawdis?.primaryEnv, })), + resolvedSkills, }; } diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index a98895502..99a080cb3 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -1,58 +1,9 @@ import type { ThinkLevel } from "../auto-reply/thinking.js"; -type BootstrapFile = { - name: - | "AGENTS.md" - | "SOUL.md" - | "TOOLS.md" - | "IDENTITY.md" - | "USER.md" - | "BOOTSTRAP.md"; - path: string; - content?: string; - missing: boolean; -}; - -function formatBootstrapFile(file: BootstrapFile): string { - if (file.missing) { - return `## ${file.name}\n\n[MISSING] Expected at: ${file.path}`; - } - return `## ${file.name}\n\n${file.content ?? ""}`.trimEnd(); -} - -function describeBuiltInTools(): string { - // Keep this short and stable; TOOLS.md is for user-editable external tool notes. - return [ - "- read: read file contents", - "- bash: run shell commands", - "- edit: apply precise in-file replacements", - "- write: create/overwrite files", - "- whatsapp_login: generate a WhatsApp QR code and wait for linking", - ].join("\n"); -} - -function formatDateTime(now: Date): string { - return now.toLocaleString("en-US", { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - timeZoneName: "short", - }); -} - -export function buildAgentSystemPrompt(params: { +export function buildAgentSystemPromptAppend(params: { workspaceDir: string; - bootstrapFiles: BootstrapFile[]; - now?: Date; defaultThinkLevel?: ThinkLevel; }) { - const now = params.now ?? new Date(); - const boot = params.bootstrapFiles.map(formatBootstrapFile).join("\n\n"); - const thinkHint = params.defaultThinkLevel && params.defaultThinkLevel !== "off" ? `Default thinking level: ${params.defaultThinkLevel}.` @@ -61,17 +12,20 @@ export function buildAgentSystemPrompt(params: { return [ "You are Clawd, a personal assistant running inside Clawdis.", "", - "## Built-in Tools (internal)", - "These tools are always available. TOOLS.md does not control tool availability; it is user guidance for how to use external tools.", - describeBuiltInTools(), + "## Tooling", + "Pi lists the standard tools above. This runtime enables:", + "- grep: search file contents for patterns", + "- find: find files by glob pattern", + "- ls: list directory contents", + "- whatsapp_login: generate a WhatsApp QR code and wait for linking", + "TOOLS.md does not control tool availability; it is user guidance for how to use external tools.", "", "## Workspace", `Your working directory is: ${params.workspaceDir}`, "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.", "", "## Workspace Files (injected)", - "These user-editable files are loaded by Clawdis and included here directly (no separate read step):", - boot, + "These user-editable files are loaded by Clawdis and included below in Project Context.", "", "## Messaging Safety", "Never send streaming/partial replies to external messaging surfaces; only final replies should be delivered there.", @@ -82,8 +36,6 @@ export function buildAgentSystemPrompt(params: { 'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.', "", "## Runtime", - `Current date and time: ${formatDateTime(now)}`, - `Current working directory: ${params.workspaceDir}`, thinkHint, ] .filter(Boolean) diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 13bc720e4..fedd02e0d 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { Skill } from "@mariozechner/pi-coding-agent"; import JSON5 from "json5"; import type { MsgContext } from "../auto-reply/templating.js"; import { normalizeE164 } from "../utils.js"; @@ -31,6 +32,7 @@ export type SessionEntry = { export type SessionSkillSnapshot = { prompt: string; skills: Array<{ name: string; primaryEnv?: string }>; + resolvedSkills?: Skill[]; }; export function resolveSessionTranscriptsDir(): string { diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 3730748d2..07ea8a6b7 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -63,32 +63,28 @@ const testTailnetIPv4 = vi.hoisted(() => ({ value: undefined as string | undefined, })); -const piAiMock = vi.hoisted(() => ({ +const piSdkMock = vi.hoisted(() => ({ enabled: false, - getModelsCalls: [] as string[], - providers: ["openai", "anthropic"], - modelsByProvider: {} as Record< - string, - Array<{ id: string; name?: string; contextWindow?: number }> - >, + discoverCalls: 0, + models: [] as Array<{ + id: string; + name?: string; + provider: string; + contextWindow?: number; + }>, })); -vi.mock("@mariozechner/pi-ai", async () => { - const actual = await vi.importActual<{ - getProviders: () => string[]; - getModels: ( - provider: string, - ) => Array<{ id: string; name?: string; contextWindow?: number }>; - }>("@mariozechner/pi-ai"); +vi.mock("@mariozechner/pi-coding-agent", async () => { + const actual = await vi.importActual< + typeof import("@mariozechner/pi-coding-agent") + >("@mariozechner/pi-coding-agent"); return { ...actual, - getProviders: () => - piAiMock.enabled ? piAiMock.providers : actual.getProviders(), - getModels: (provider: string) => { - if (!piAiMock.enabled) return actual.getModels(provider); - piAiMock.getModelsCalls.push(provider); - return piAiMock.modelsByProvider[provider] ?? []; + discoverModels: () => { + if (!piSdkMock.enabled) return actual.discoverModels(); + piSdkMock.discoverCalls += 1; + return piSdkMock.models; }, }; }); @@ -252,10 +248,9 @@ beforeEach(async () => { testGatewayBind = undefined; testGatewayAuth = undefined; __resetModelCatalogCacheForTest(); - piAiMock.enabled = false; - piAiMock.getModelsCalls.length = 0; - piAiMock.providers = ["openai", "anthropic"]; - piAiMock.modelsByProvider = { openai: [], anthropic: [] }; + piSdkMock.enabled = false; + piSdkMock.discoverCalls = 0; + piSdkMock.models = []; }); afterEach(async () => { @@ -471,18 +466,28 @@ describe("gateway server", () => { }); test("models.list returns model catalog", async () => { - piAiMock.enabled = true; - piAiMock.providers = ["openai", "anthropic"]; - piAiMock.modelsByProvider = { - openai: [ - { id: "gpt-test-z", contextWindow: 0 }, - { id: "gpt-test-a", name: "A-Model", contextWindow: 8000 }, - ], - anthropic: [ - { id: "claude-test-b", name: "B-Model", contextWindow: 1000 }, - { id: "claude-test-a", name: "A-Model", contextWindow: 200_000 }, - ], - }; + piSdkMock.enabled = true; + piSdkMock.models = [ + { id: "gpt-test-z", provider: "openai", contextWindow: 0 }, + { + id: "gpt-test-a", + name: "A-Model", + provider: "openai", + contextWindow: 8000, + }, + { + id: "claude-test-b", + name: "B-Model", + provider: "anthropic", + contextWindow: 1000, + }, + { + id: "claude-test-a", + name: "A-Model", + provider: "anthropic", + contextWindow: 200_000, + }, + ]; const { server, ws } = await startServerWithClient(); await connectOk(ws); @@ -535,16 +540,16 @@ describe("gateway server", () => { }, ]); - // Cached across requests: should only call getModels once per provider. - expect(piAiMock.getModelsCalls).toEqual(["openai", "anthropic"]); + // Cached across requests: should only call discoverModels once. + expect(piSdkMock.discoverCalls).toBe(1); ws.close(); await server.close(); }); test("models.list rejects unknown params", async () => { - piAiMock.providers = ["openai"]; - piAiMock.modelsByProvider = { openai: [{ id: "gpt-test-a", name: "A" }] }; + piSdkMock.enabled = true; + piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; const { server, ws } = await startServerWithClient(); await connectOk(ws); @@ -558,9 +563,8 @@ describe("gateway server", () => { }); test("bridge RPC supports models.list and validates params", async () => { - piAiMock.enabled = true; - piAiMock.providers = ["openai"]; - piAiMock.modelsByProvider = { openai: [{ id: "gpt-test-a", name: "A" }] }; + piSdkMock.enabled = true; + piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; const { server, ws } = await startServerWithClient(); await connectOk(ws); @@ -2503,7 +2507,7 @@ describe("gateway server", () => { await server.close(); }); - test("chat.history caps payload bytes", async () => { + test("chat.history caps payload bytes", { timeout: 15_000 }, async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( @@ -2524,9 +2528,9 @@ describe("gateway server", () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); - const bigText = "x".repeat(300_000); + const bigText = "x".repeat(200_000); const largeLines: string[] = []; - for (let i = 0; i < 60; i += 1) { + for (let i = 0; i < 40; i += 1) { largeLines.push( JSON.stringify({ message: { diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 12f744f83..94638fb63 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -217,33 +217,39 @@ async function loadGatewayModelCatalog(): Promise { if (modelCatalogPromise) return modelCatalogPromise; modelCatalogPromise = (async () => { - const piAi = (await import("@mariozechner/pi-ai")) as unknown as { - getProviders: () => string[]; - getModels: (provider: string) => Array<{ + const piSdk = (await import("@mariozechner/pi-coding-agent")) as { + discoverModels: () => Array<{ id: string; name?: string; + provider: string; contextWindow?: number; }>; }; + let entries: Array<{ + id: string; + name?: string; + provider: string; + contextWindow?: number; + }> = []; + try { + entries = piSdk.discoverModels(); + } catch { + entries = []; + } + const models: GatewayModelChoice[] = []; - for (const provider of piAi.getProviders()) { - let entries: Array<{ id: string; name?: string; contextWindow?: number }>; - try { - entries = piAi.getModels(provider); - } catch { - continue; - } - for (const entry of entries) { - const id = String(entry?.id ?? "").trim(); - if (!id) continue; - const name = String(entry?.name ?? id).trim() || id; - const contextWindow = - typeof entry?.contextWindow === "number" && entry.contextWindow > 0 - ? entry.contextWindow - : undefined; - models.push({ id, name, provider, contextWindow }); - } + for (const entry of entries) { + const id = String(entry?.id ?? "").trim(); + if (!id) continue; + const provider = String(entry?.provider ?? "").trim(); + if (!provider) continue; + const name = String(entry?.name ?? id).trim() || id; + const contextWindow = + typeof entry?.contextWindow === "number" && entry.contextWindow > 0 + ? entry.contextWindow + : undefined; + models.push({ id, name, provider, contextWindow }); } return models.sort((a, b) => { diff --git a/src/web/logout.test.ts b/src/web/logout.test.ts index 1a3c7928b..4ed53edd1 100644 --- a/src/web/logout.test.ts +++ b/src/web/logout.test.ts @@ -19,10 +19,20 @@ describe("web logout", () => { vi.clearAllMocks(); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-logout-")); vi.spyOn(os, "homedir").mockReturnValue(tmpDir); + vi.resetModules(); + vi.doMock("../utils.js", async () => { + const actual = + await vi.importActual("../utils.js"); + return { + ...actual, + CONFIG_DIR: path.join(tmpDir, ".clawdis"), + }; + }); }); afterEach(async () => { vi.restoreAllMocks(); + vi.doUnmock("../utils.js"); await fsPromises .rm(tmpDir, { recursive: true, force: true }) .catch(() => {}); @@ -31,29 +41,33 @@ describe("web logout", () => { (os.homedir as unknown as typeof origHomedir) = origHomedir; }); - it("deletes cached credentials when present", async () => { - const credsDir = path.join(tmpDir, ".clawdis", "credentials"); - fs.mkdirSync(credsDir, { recursive: true }); - fs.writeFileSync(path.join(credsDir, "creds.json"), "{}"); - const sessionsPath = path.join( - tmpDir, - ".clawdis", - "sessions", - "sessions.json", - ); - fs.mkdirSync(path.dirname(sessionsPath), { recursive: true }); - fs.writeFileSync(sessionsPath, "{}"); - const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js"); + it( + "deletes cached credentials when present", + { timeout: 15_000 }, + async () => { + const credsDir = path.join(tmpDir, ".clawdis", "credentials"); + fs.mkdirSync(credsDir, { recursive: true }); + fs.writeFileSync(path.join(credsDir, "creds.json"), "{}"); + const sessionsPath = path.join( + tmpDir, + ".clawdis", + "sessions", + "sessions.json", + ); + fs.mkdirSync(path.dirname(sessionsPath), { recursive: true }); + fs.writeFileSync(sessionsPath, "{}"); + const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js"); - expect(WA_WEB_AUTH_DIR.startsWith(tmpDir)).toBe(true); - const result = await logoutWeb(runtime as never); + expect(WA_WEB_AUTH_DIR.startsWith(tmpDir)).toBe(true); + const result = await logoutWeb(runtime as never); - expect(result).toBe(true); - expect(fs.existsSync(credsDir)).toBe(false); - expect(fs.existsSync(sessionsPath)).toBe(false); - }); + expect(result).toBe(true); + expect(fs.existsSync(credsDir)).toBe(false); + expect(fs.existsSync(sessionsPath)).toBe(false); + }, + ); - it("no-ops when nothing to delete", async () => { + it("no-ops when nothing to delete", { timeout: 15_000 }, async () => { const { logoutWeb } = await import("./session.js"); const result = await logoutWeb(runtime as never); expect(result).toBe(false);