refactor: migrate embedded pi to sdk

main
Peter Steinberger 2025-12-22 18:05:44 +01:00
parent 79c0fd27a0
commit 2d7c5f8c53
12 changed files with 276 additions and 386 deletions

View File

@ -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`). 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 `<workspace>/.pi` settings).
- Bootstrap files are injected as SDK project context (see “Project Context” in the system prompt).
## Peter @ steipete (only) ## Peter @ steipete (only)
Apply these notes **only** when the user is Peter Steinberger at steipete. Apply these notes **only** when the user is Peter Steinberger at steipete.

View File

@ -67,9 +67,9 @@
"dependencies": { "dependencies": {
"@grammyjs/transformer-throttler": "^1.2.1", "@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4", "@homebridge/ciao": "^1.3.4",
"@mariozechner/pi-agent-core": "^0.25.0", "@mariozechner/pi-agent-core": "^0.26.0",
"@mariozechner/pi-ai": "^0.25.0", "@mariozechner/pi-ai": "^0.26.0",
"@mariozechner/pi-coding-agent": "^0.25.0", "@mariozechner/pi-coding-agent": "^0.26.0",
"@sinclair/typebox": "^0.34.41", "@sinclair/typebox": "^0.34.41",
"@whiskeysockets/baileys": "7.0.0-rc.9", "@whiskeysockets/baileys": "7.0.0-rc.9",
"ajv": "^8.17.1", "ajv": "^8.17.1",

View File

@ -15,14 +15,14 @@ importers:
specifier: ^1.3.4 specifier: ^1.3.4
version: 1.3.4 version: 1.3.4
'@mariozechner/pi-agent-core': '@mariozechner/pi-agent-core':
specifier: ^0.25.0 specifier: ^0.26.0
version: 0.25.0(ws@8.18.3)(zod@4.2.1) version: 0.26.1(ws@8.18.3)(zod@4.2.1)
'@mariozechner/pi-ai': '@mariozechner/pi-ai':
specifier: ^0.25.0 specifier: ^0.26.0
version: 0.25.0(ws@8.18.3)(zod@4.2.1) version: 0.26.1(ws@8.18.3)(zod@4.2.1)
'@mariozechner/pi-coding-agent': '@mariozechner/pi-coding-agent':
specifier: ^0.25.0 specifier: ^0.26.0
version: 0.25.0(ws@8.18.3)(zod@4.2.1) version: 0.26.1(ws@8.18.3)(zod@4.2.1)
'@sinclair/typebox': '@sinclair/typebox':
specifier: ^0.34.41 specifier: ^0.34.41
version: 0.34.41 version: 0.34.41
@ -659,21 +659,21 @@ packages:
peerDependencies: peerDependencies:
lit: ^3.3.1 lit: ^3.3.1
'@mariozechner/pi-agent-core@0.25.0': '@mariozechner/pi-agent-core@0.26.1':
resolution: {integrity: sha512-aiM0GvkmHJtFudNGlXiuLr/IqRot1Sus9vqrarVf/gF5ooubYyGYhP6QotAfbFqI0z6HpFa2O3mx8KEp0AiBKg==} resolution: {integrity: sha512-yH15oPK9l8F2vGrz2mXl0dRydKkw0x4p1WChVuQALqDaFOf48V2XbLS7SvTE3qx095ylNp/Q+RQ+NiB5I2myFA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@mariozechner/pi-ai@0.25.0': '@mariozechner/pi-ai@0.26.1':
resolution: {integrity: sha512-N3INs/PNIEYx/U8tM6NaV75Gpx263o4b+YYxsD1Ag9ratdzz+JxL2ATYENi+Ma+BjsMaowPCMO2oeotHdsr/cA==} resolution: {integrity: sha512-VEH9kwQoo0N1KtBQnAHDZaIwe0nLwikGytNvjCV3RltQirywwUUsw0xQ/2YUXaN3vl3nqDO/VY1qgdSnVZE5iA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@mariozechner/pi-coding-agent@0.25.0': '@mariozechner/pi-coding-agent@0.26.1':
resolution: {integrity: sha512-docYKq6zEVZcO5ngb0NTpayeipr+pLCMCeNfwdiC55zNI5nKMg1O4s6aMv2clJ4fUisHP0uhyK9URIohqSadbw==} resolution: {integrity: sha512-o1WOhzwPQTiUBNxlANDXJ9bTOIIpxxkwRh9+nnz9F28uEzkSfTrJLTgJoWxuRAU7Xvj5//pkKYaUPfhCd69R9g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
hasBin: true hasBin: true
'@mariozechner/pi-tui@0.25.0': '@mariozechner/pi-tui@0.26.1':
resolution: {integrity: sha512-7pU/EPFTYgyEsfcDBb+fzp6BQWr6tmykgMMGZx3Pxvet3NF5HmphAdLBitjmThri+M7lrGaJVrpIRHjQM1CPVQ==} resolution: {integrity: sha512-qGKS4SwxJw4pinttl3UvzylC1IuB31QpuoM3X36mz/GmLq52RNYnriK4si52GpeTrqNm8vXDpeevI0zhPQPjYw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@mistralai/mistralai@1.10.0': '@mistralai/mistralai@1.10.0':
@ -2991,10 +2991,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- tailwindcss - 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: dependencies:
'@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)
'@mariozechner/pi-tui': 0.25.0 '@mariozechner/pi-tui': 0.26.1
transitivePeerDependencies: transitivePeerDependencies:
- '@modelcontextprotocol/sdk' - '@modelcontextprotocol/sdk'
- bufferutil - bufferutil
@ -3003,7 +3003,7 @@ snapshots:
- ws - ws
- zod - 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: dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.2.1) '@anthropic-ai/sdk': 0.71.2(zod@4.2.1)
'@google/genai': 1.34.0 '@google/genai': 1.34.0
@ -3023,11 +3023,11 @@ snapshots:
- ws - ws
- zod - 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: dependencies:
'@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)
'@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)
'@mariozechner/pi-tui': 0.25.0 '@mariozechner/pi-tui': 0.26.1
chalk: 5.6.2 chalk: 5.6.2
cli-highlight: 2.1.11 cli-highlight: 2.1.11
diff: 8.0.2 diff: 8.0.2
@ -3042,7 +3042,7 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-tui@0.25.0': '@mariozechner/pi-tui@0.26.1':
dependencies: dependencies:
'@types/mime-types': 2.1.4 '@types/mime-types': 2.1.4
chalk: 5.6.2 chalk: 5.6.2

View File

@ -1,26 +1,19 @@
// Lazy-load pi-ai model metadata so we can infer context windows when the agent // Lazy-load pi-coding-agent model metadata so we can infer context windows when
// reports a model id. pi-coding-agent depends on @mariozechner/pi-ai, so it // the agent reports a model id. This includes custom models.json entries.
// should be present whenever CLAWDIS is installed from npm.
type ModelEntry = { id: string; contextWindow?: number }; type ModelEntry = { id: string; contextWindow?: number };
const MODEL_CACHE = new Map<string, number>(); const MODEL_CACHE = new Map<string, number>();
const loadPromise = (async () => { const loadPromise = (async () => {
try { try {
const piAi = (await import("@mariozechner/pi-ai")) as { const { discoverModels } = await import("@mariozechner/pi-coding-agent");
getProviders: () => string[]; const models = discoverModels() as ModelEntry[];
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) { for (const m of models) {
if (!m?.id) continue; if (!m?.id) continue;
if (typeof m.contextWindow === "number" && m.contextWindow > 0) { if (typeof m.contextWindow === "number" && m.contextWindow > 0) {
MODEL_CACHE.set(m.id, m.contextWindow); MODEL_CACHE.set(m.id, m.contextWindow);
} }
} }
}
} catch { } catch {
// If pi-ai isn't available, leave cache empty; lookup will fall back. // If pi-ai isn't available, leave cache empty; lookup will fall back.
} }

View File

@ -1,28 +1,26 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { import type {
Agent, AgentEvent,
type AgentEvent, AppMessage,
type AppMessage, ThinkingLevel,
ProviderTransport,
type ThinkingLevel,
} from "@mariozechner/pi-agent-core"; } from "@mariozechner/pi-agent-core";
import { import type {
type AgentToolResult, AgentToolResult,
type Api, Api,
type AssistantMessage, AssistantMessage,
getApiKey, Model,
getModels,
getProviders,
type KnownProvider,
type Model,
} from "@mariozechner/pi-ai"; } from "@mariozechner/pi-ai";
import { import {
AgentSession, buildSystemPrompt,
messageTransformer, createAgentSession,
defaultGetApiKey,
findModel,
SessionManager, SessionManager,
SettingsManager, SettingsManager,
type Skill,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js"; import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
import { import {
@ -39,7 +37,6 @@ import {
extractAssistantText, extractAssistantText,
inferToolMetaFromArgs, inferToolMetaFromArgs,
} from "./pi-embedded-utils.js"; } from "./pi-embedded-utils.js";
import { getAnthropicOAuthToken } from "./pi-oauth.js";
import { import {
createClawdisCodingTools, createClawdisCodingTools,
sanitizeContentBlocksImages, sanitizeContentBlocksImages,
@ -49,10 +46,14 @@ import {
applySkillEnvOverridesFromSnapshot, applySkillEnvOverridesFromSnapshot,
buildWorkspaceSkillSnapshot, buildWorkspaceSkillSnapshot,
loadWorkspaceSkillEntries, loadWorkspaceSkillEntries,
type SkillEntry,
type SkillSnapshot, type SkillSnapshot,
} from "./skills.js"; } from "./skills.js";
import { buildAgentSystemPrompt } from "./system-prompt.js"; import { buildAgentSystemPromptAppend } from "./system-prompt.js";
import { loadWorkspaceBootstrapFiles } from "./workspace.js"; import {
loadWorkspaceBootstrapFiles,
type WorkspaceBootstrapFile,
} from "./workspace.js";
export type EmbeddedPiAgentMeta = { export type EmbeddedPiAgentMeta = {
sessionId: string; sessionId: string;
@ -106,18 +107,16 @@ function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel {
return level; return level;
} }
function isKnownProvider(provider: string): provider is KnownProvider {
return getProviders().includes(provider as KnownProvider);
}
function resolveModel( function resolveModel(
provider: string, provider: string,
modelId: string, modelId: string,
): Model<Api> | undefined { agentDir?: string,
if (!isKnownProvider(provider)) return undefined; ): { model?: Model<Api>; error?: string } {
const models = getModels(provider); const result = findModel(provider, modelId, agentDir);
const model = models.find((m) => m.id === modelId); return {
return model as Model<Api> | undefined; model: (result.model ?? undefined) as Model<Api> | undefined,
error: result.error ?? undefined,
};
} }
async function ensureSessionHeader(params: { async function ensureSessionHeader(params: {
@ -148,20 +147,22 @@ async function ensureSessionHeader(params: {
await fs.writeFile(file, `${JSON.stringify(entry)}\n`, "utf-8"); await fs.writeFile(file, `${JSON.stringify(entry)}\n`, "utf-8");
} }
async function getApiKeyForProvider( const defaultApiKey = defaultGetApiKey();
provider: string,
): Promise<string | undefined> { async function getApiKeyForModel(model: { provider: string }): Promise<string> {
if (provider === "anthropic") { if (model.provider === "anthropic") {
const oauthToken = await getAnthropicOAuthToken();
if (oauthToken) return oauthToken;
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN; const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
if (oauthEnv?.trim()) return oauthEnv.trim(); if (oauthEnv?.trim()) return oauthEnv.trim();
} }
return getApiKey(provider) ?? undefined; const key = await defaultApiKey(model as unknown as Model<Api>);
if (key) return key;
throw new Error(`No API key found for provider "${model.provider}"`);
} }
type ContentBlock = AgentToolResult<unknown>["content"][number]; type ContentBlock = AgentToolResult<unknown>["content"][number];
type ContextFile = { path: string; content: string };
async function sanitizeSessionMessagesImages( async function sanitizeSessionMessagesImages(
messages: AppMessage[], messages: AppMessage[],
label: string, label: string,
@ -205,6 +206,36 @@ async function sanitizeSessionMessagesImages(
return out; 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 { function formatAssistantErrorText(msg: AssistantMessage): string | undefined {
if (msg.stopReason !== "error") return undefined; if (msg.stopReason !== "error") return undefined;
const raw = (msg.errorMessage ?? "").trim(); const raw = (msg.errorMessage ?? "").trim();
@ -259,9 +290,12 @@ export async function runEmbeddedPiAgent(params: {
const provider = const provider =
(params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; 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) { if (!model) {
throw new Error(`Unknown model: ${provider}/${modelId}`); throw new Error(error ?? `Unknown model: ${provider}/${modelId}`);
} }
const thinkingLevel = mapThinkingLevel(params.thinkLevel); const thinkingLevel = mapThinkingLevel(params.thinkLevel);
@ -279,11 +313,11 @@ export async function runEmbeddedPiAgent(params: {
let restoreSkillEnv: (() => void) | undefined; let restoreSkillEnv: (() => void) | undefined;
process.chdir(resolvedWorkspace); process.chdir(resolvedWorkspace);
try { try {
const skillEntries = params.skillsSnapshot const shouldLoadSkillEntries =
? undefined !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
: loadWorkspaceSkillEntries(resolvedWorkspace, { const skillEntries = shouldLoadSkillEntries
config: params.config, ? loadWorkspaceSkillEntries(resolvedWorkspace)
}); : [];
const skillsSnapshot = const skillsSnapshot =
params.skillsSnapshot ?? params.skillsSnapshot ??
buildWorkspaceSkillSnapshot(resolvedWorkspace, { buildWorkspaceSkillSnapshot(resolvedWorkspace, {
@ -302,60 +336,48 @@ export async function runEmbeddedPiAgent(params: {
const bootstrapFiles = const bootstrapFiles =
await loadWorkspaceBootstrapFiles(resolvedWorkspace); await loadWorkspaceBootstrapFiles(resolvedWorkspace);
const systemPrompt = buildAgentSystemPrompt({ const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
const tools = createClawdisCodingTools();
const systemPrompt = buildSystemPrompt({
appendPrompt: buildAgentSystemPromptAppend({
workspaceDir: resolvedWorkspace, workspaceDir: resolvedWorkspace,
bootstrapFiles: bootstrapFiles.map((f) => ({
name: f.name,
path: f.path,
content: f.content,
missing: f.missing,
})),
defaultThinkLevel: params.thinkLevel, defaultThinkLevel: params.thinkLevel,
}),
contextFiles,
skills: promptSkills,
cwd: resolvedWorkspace,
}); });
const systemPromptWithSkills = systemPrompt + skillsSnapshot.prompt;
const sessionManager = new SessionManager(false, params.sessionFile); const sessionManager = SessionManager.open(params.sessionFile, agentDir);
const settingsManager = new SettingsManager(); const settingsManager = SettingsManager.create(
resolvedWorkspace,
agentDir,
);
const agent = new Agent({ const { session } = await createAgentSession({
initialState: { cwd: resolvedWorkspace,
systemPrompt: systemPromptWithSkills, agentDir,
model, model,
thinkingLevel, thinkingLevel,
systemPrompt,
// TODO(steipete): Once pi-mono publishes file-magic MIME detection in `read` image payloads, // TODO(steipete): Once pi-mono publishes file-magic MIME detection in `read` image payloads,
// remove `createClawdisCodingTools()` and use upstream `codingTools` again. // remove `createClawdisCodingTools()` and use upstream `codingTools` again.
tools: createClawdisCodingTools(), tools,
}, sessionManager,
messageTransformer, settingsManager,
queueMode: settingsManager.getQueueMode(), getApiKey: getApiKeyForModel,
transport: new ProviderTransport({ skills: promptSkills,
getApiKey: async (providerName) => { contextFiles,
const key = await getApiKeyForProvider(providerName);
if (!key) {
throw new Error(
`No API key found for provider "${providerName}"`,
);
}
return key;
},
}),
}); });
// Resume messages from the transcript if present.
const priorRaw = sessionManager.loadSession().messages;
const prior = await sanitizeSessionMessagesImages( const prior = await sanitizeSessionMessagesImages(
priorRaw, session.messages,
"session:history", "session:history",
); );
if (prior.length > 0) { if (prior.length > 0) {
agent.replaceMessages(prior); session.agent.replaceMessages(prior);
} }
const session = new AgentSession({
agent,
sessionManager,
settingsManager,
});
const queueHandle: EmbeddedPiQueueHandle = { const queueHandle: EmbeddedPiQueueHandle = {
queueMessage: async (text: string) => { queueMessage: async (text: string) => {
await session.queueMessage(text); await session.queueMessage(text);

View File

@ -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<string, OAuthCredentials | undefined>;
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<OAuthStorageFormat> {
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<void> {
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<OAuthCredentials> {
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<string | null> {
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;
}

View File

@ -51,6 +51,7 @@ export type SkillEntry = {
export type SkillSnapshot = { export type SkillSnapshot = {
prompt: string; prompt: string;
skills: Array<{ name: string; primaryEnv?: string }>; skills: Array<{ name: string; primaryEnv?: string }>;
resolvedSkills?: Skill[];
}; };
function resolveBundledSkillsDir(): string | undefined { function resolveBundledSkillsDir(): string | undefined {
@ -505,12 +506,14 @@ export function buildWorkspaceSkillSnapshot(
): SkillSnapshot { ): SkillSnapshot {
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
const eligible = filterSkillEntries(skillEntries, opts?.config); const eligible = filterSkillEntries(skillEntries, opts?.config);
const resolvedSkills = eligible.map((entry) => entry.skill);
return { return {
prompt: formatSkillsForPrompt(eligible.map((entry) => entry.skill)), prompt: formatSkillsForPrompt(resolvedSkills),
skills: eligible.map((entry) => ({ skills: eligible.map((entry) => ({
name: entry.skill.name, name: entry.skill.name,
primaryEnv: entry.clawdis?.primaryEnv, primaryEnv: entry.clawdis?.primaryEnv,
})), })),
resolvedSkills,
}; };
} }

View File

@ -1,58 +1,9 @@
import type { ThinkLevel } from "../auto-reply/thinking.js"; import type { ThinkLevel } from "../auto-reply/thinking.js";
type BootstrapFile = { export function buildAgentSystemPromptAppend(params: {
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: {
workspaceDir: string; workspaceDir: string;
bootstrapFiles: BootstrapFile[];
now?: Date;
defaultThinkLevel?: ThinkLevel; defaultThinkLevel?: ThinkLevel;
}) { }) {
const now = params.now ?? new Date();
const boot = params.bootstrapFiles.map(formatBootstrapFile).join("\n\n");
const thinkHint = const thinkHint =
params.defaultThinkLevel && params.defaultThinkLevel !== "off" params.defaultThinkLevel && params.defaultThinkLevel !== "off"
? `Default thinking level: ${params.defaultThinkLevel}.` ? `Default thinking level: ${params.defaultThinkLevel}.`
@ -61,17 +12,20 @@ export function buildAgentSystemPrompt(params: {
return [ return [
"You are Clawd, a personal assistant running inside Clawdis.", "You are Clawd, a personal assistant running inside Clawdis.",
"", "",
"## Built-in Tools (internal)", "## Tooling",
"These tools are always available. TOOLS.md does not control tool availability; it is user guidance for how to use external tools.", "Pi lists the standard tools above. This runtime enables:",
describeBuiltInTools(), "- 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", "## Workspace",
`Your working directory is: ${params.workspaceDir}`, `Your working directory is: ${params.workspaceDir}`,
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.", "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
"", "",
"## Workspace Files (injected)", "## Workspace Files (injected)",
"These user-editable files are loaded by Clawdis and included here directly (no separate read step):", "These user-editable files are loaded by Clawdis and included below in Project Context.",
boot,
"", "",
"## Messaging Safety", "## Messaging Safety",
"Never send streaming/partial replies to external messaging surfaces; only final replies should be delivered there.", "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.', 'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
"", "",
"## Runtime", "## Runtime",
`Current date and time: ${formatDateTime(now)}`,
`Current working directory: ${params.workspaceDir}`,
thinkHint, thinkHint,
] ]
.filter(Boolean) .filter(Boolean)

View File

@ -3,6 +3,7 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { Skill } from "@mariozechner/pi-coding-agent";
import JSON5 from "json5"; import JSON5 from "json5";
import type { MsgContext } from "../auto-reply/templating.js"; import type { MsgContext } from "../auto-reply/templating.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
@ -31,6 +32,7 @@ export type SessionEntry = {
export type SessionSkillSnapshot = { export type SessionSkillSnapshot = {
prompt: string; prompt: string;
skills: Array<{ name: string; primaryEnv?: string }>; skills: Array<{ name: string; primaryEnv?: string }>;
resolvedSkills?: Skill[];
}; };
export function resolveSessionTranscriptsDir(): string { export function resolveSessionTranscriptsDir(): string {

View File

@ -63,32 +63,28 @@ const testTailnetIPv4 = vi.hoisted(() => ({
value: undefined as string | undefined, value: undefined as string | undefined,
})); }));
const piAiMock = vi.hoisted(() => ({ const piSdkMock = vi.hoisted(() => ({
enabled: false, enabled: false,
getModelsCalls: [] as string[], discoverCalls: 0,
providers: ["openai", "anthropic"], models: [] as Array<{
modelsByProvider: {} as Record< id: string;
string, name?: string;
Array<{ id: string; name?: string; contextWindow?: number }> provider: string;
>, contextWindow?: number;
}>,
})); }));
vi.mock("@mariozechner/pi-ai", async () => { vi.mock("@mariozechner/pi-coding-agent", async () => {
const actual = await vi.importActual<{ const actual = await vi.importActual<
getProviders: () => string[]; typeof import("@mariozechner/pi-coding-agent")
getModels: ( >("@mariozechner/pi-coding-agent");
provider: string,
) => Array<{ id: string; name?: string; contextWindow?: number }>;
}>("@mariozechner/pi-ai");
return { return {
...actual, ...actual,
getProviders: () => discoverModels: () => {
piAiMock.enabled ? piAiMock.providers : actual.getProviders(), if (!piSdkMock.enabled) return actual.discoverModels();
getModels: (provider: string) => { piSdkMock.discoverCalls += 1;
if (!piAiMock.enabled) return actual.getModels(provider); return piSdkMock.models;
piAiMock.getModelsCalls.push(provider);
return piAiMock.modelsByProvider[provider] ?? [];
}, },
}; };
}); });
@ -252,10 +248,9 @@ beforeEach(async () => {
testGatewayBind = undefined; testGatewayBind = undefined;
testGatewayAuth = undefined; testGatewayAuth = undefined;
__resetModelCatalogCacheForTest(); __resetModelCatalogCacheForTest();
piAiMock.enabled = false; piSdkMock.enabled = false;
piAiMock.getModelsCalls.length = 0; piSdkMock.discoverCalls = 0;
piAiMock.providers = ["openai", "anthropic"]; piSdkMock.models = [];
piAiMock.modelsByProvider = { openai: [], anthropic: [] };
}); });
afterEach(async () => { afterEach(async () => {
@ -471,18 +466,28 @@ describe("gateway server", () => {
}); });
test("models.list returns model catalog", async () => { test("models.list returns model catalog", async () => {
piAiMock.enabled = true; piSdkMock.enabled = true;
piAiMock.providers = ["openai", "anthropic"]; piSdkMock.models = [
piAiMock.modelsByProvider = { { id: "gpt-test-z", provider: "openai", contextWindow: 0 },
openai: [ {
{ id: "gpt-test-z", contextWindow: 0 }, id: "gpt-test-a",
{ id: "gpt-test-a", name: "A-Model", contextWindow: 8000 }, name: "A-Model",
], provider: "openai",
anthropic: [ contextWindow: 8000,
{ id: "claude-test-b", name: "B-Model", contextWindow: 1000 }, },
{ id: "claude-test-a", name: "A-Model", contextWindow: 200_000 }, {
], 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(); const { server, ws } = await startServerWithClient();
await connectOk(ws); await connectOk(ws);
@ -535,16 +540,16 @@ describe("gateway server", () => {
}, },
]); ]);
// Cached across requests: should only call getModels once per provider. // Cached across requests: should only call discoverModels once.
expect(piAiMock.getModelsCalls).toEqual(["openai", "anthropic"]); expect(piSdkMock.discoverCalls).toBe(1);
ws.close(); ws.close();
await server.close(); await server.close();
}); });
test("models.list rejects unknown params", async () => { test("models.list rejects unknown params", async () => {
piAiMock.providers = ["openai"]; piSdkMock.enabled = true;
piAiMock.modelsByProvider = { openai: [{ id: "gpt-test-a", name: "A" }] }; piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
const { server, ws } = await startServerWithClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws); await connectOk(ws);
@ -558,9 +563,8 @@ describe("gateway server", () => {
}); });
test("bridge RPC supports models.list and validates params", async () => { test("bridge RPC supports models.list and validates params", async () => {
piAiMock.enabled = true; piSdkMock.enabled = true;
piAiMock.providers = ["openai"]; piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
piAiMock.modelsByProvider = { openai: [{ id: "gpt-test-a", name: "A" }] };
const { server, ws } = await startServerWithClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws); await connectOk(ws);
@ -2503,7 +2507,7 @@ describe("gateway server", () => {
await server.close(); 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-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
testSessionStorePath = path.join(dir, "sessions.json"); testSessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile( await fs.writeFile(
@ -2524,9 +2528,9 @@ describe("gateway server", () => {
const { server, ws } = await startServerWithClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws); await connectOk(ws);
const bigText = "x".repeat(300_000); const bigText = "x".repeat(200_000);
const largeLines: string[] = []; const largeLines: string[] = [];
for (let i = 0; i < 60; i += 1) { for (let i = 0; i < 40; i += 1) {
largeLines.push( largeLines.push(
JSON.stringify({ JSON.stringify({
message: { message: {

View File

@ -217,26 +217,33 @@ async function loadGatewayModelCatalog(): Promise<GatewayModelChoice[]> {
if (modelCatalogPromise) return modelCatalogPromise; if (modelCatalogPromise) return modelCatalogPromise;
modelCatalogPromise = (async () => { modelCatalogPromise = (async () => {
const piAi = (await import("@mariozechner/pi-ai")) as unknown as { const piSdk = (await import("@mariozechner/pi-coding-agent")) as {
getProviders: () => string[]; discoverModels: () => Array<{
getModels: (provider: string) => Array<{
id: string; id: string;
name?: string; name?: string;
provider: string;
contextWindow?: number; contextWindow?: number;
}>; }>;
}; };
const models: GatewayModelChoice[] = []; let entries: Array<{
for (const provider of piAi.getProviders()) { id: string;
let entries: Array<{ id: string; name?: string; contextWindow?: number }>; name?: string;
provider: string;
contextWindow?: number;
}> = [];
try { try {
entries = piAi.getModels(provider); entries = piSdk.discoverModels();
} catch { } catch {
continue; entries = [];
} }
const models: GatewayModelChoice[] = [];
for (const entry of entries) { for (const entry of entries) {
const id = String(entry?.id ?? "").trim(); const id = String(entry?.id ?? "").trim();
if (!id) continue; if (!id) continue;
const provider = String(entry?.provider ?? "").trim();
if (!provider) continue;
const name = String(entry?.name ?? id).trim() || id; const name = String(entry?.name ?? id).trim() || id;
const contextWindow = const contextWindow =
typeof entry?.contextWindow === "number" && entry.contextWindow > 0 typeof entry?.contextWindow === "number" && entry.contextWindow > 0
@ -244,7 +251,6 @@ async function loadGatewayModelCatalog(): Promise<GatewayModelChoice[]> {
: undefined; : undefined;
models.push({ id, name, provider, contextWindow }); models.push({ id, name, provider, contextWindow });
} }
}
return models.sort((a, b) => { return models.sort((a, b) => {
const p = a.provider.localeCompare(b.provider); const p = a.provider.localeCompare(b.provider);

View File

@ -19,10 +19,20 @@ describe("web logout", () => {
vi.clearAllMocks(); vi.clearAllMocks();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-logout-")); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-logout-"));
vi.spyOn(os, "homedir").mockReturnValue(tmpDir); vi.spyOn(os, "homedir").mockReturnValue(tmpDir);
vi.resetModules();
vi.doMock("../utils.js", async () => {
const actual =
await vi.importActual<typeof import("../utils.js")>("../utils.js");
return {
...actual,
CONFIG_DIR: path.join(tmpDir, ".clawdis"),
};
});
}); });
afterEach(async () => { afterEach(async () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.doUnmock("../utils.js");
await fsPromises await fsPromises
.rm(tmpDir, { recursive: true, force: true }) .rm(tmpDir, { recursive: true, force: true })
.catch(() => {}); .catch(() => {});
@ -31,7 +41,10 @@ describe("web logout", () => {
(os.homedir as unknown as typeof origHomedir) = origHomedir; (os.homedir as unknown as typeof origHomedir) = origHomedir;
}); });
it("deletes cached credentials when present", async () => { it(
"deletes cached credentials when present",
{ timeout: 15_000 },
async () => {
const credsDir = path.join(tmpDir, ".clawdis", "credentials"); const credsDir = path.join(tmpDir, ".clawdis", "credentials");
fs.mkdirSync(credsDir, { recursive: true }); fs.mkdirSync(credsDir, { recursive: true });
fs.writeFileSync(path.join(credsDir, "creds.json"), "{}"); fs.writeFileSync(path.join(credsDir, "creds.json"), "{}");
@ -51,9 +64,10 @@ describe("web logout", () => {
expect(result).toBe(true); expect(result).toBe(true);
expect(fs.existsSync(credsDir)).toBe(false); expect(fs.existsSync(credsDir)).toBe(false);
expect(fs.existsSync(sessionsPath)).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 { logoutWeb } = await import("./session.js");
const result = await logoutWeb(runtime as never); const result = await logoutWeb(runtime as never);
expect(result).toBe(false); expect(result).toBe(false);