@mewbleh/purrx 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ import http from "node:http";
2
+ import { URL } from "node:url";
3
+ import {
4
+ OAUTH_CLIENT_ID,
5
+ OAUTH_ISSUER,
6
+ OAUTH_PORT,
7
+ OAUTH_SCOPE,
8
+ } from "../config.js";
9
+ import { generatePkce, generateState } from "./pkce.js";
10
+ import {
11
+ buildAuthFromTokens,
12
+ writeAuth,
13
+ jwtAuthClaims,
14
+ } from "./tokens.js";
15
+ import { openBrowser } from "../platform.js";
16
+
17
+ function buildAuthorizeUrl(redirectUri, pkce, state) {
18
+ const params = new URLSearchParams({
19
+ response_type: "code",
20
+ client_id: OAUTH_CLIENT_ID,
21
+ redirect_uri: redirectUri,
22
+ scope: OAUTH_SCOPE,
23
+ code_challenge: pkce.codeChallenge,
24
+ code_challenge_method: "S256",
25
+ id_token_add_organizations: "true",
26
+ codex_cli_simplified_flow: "true",
27
+ state,
28
+ });
29
+ return `${OAUTH_ISSUER}/oauth/authorize?${params.toString()}`;
30
+ }
31
+
32
+ // Exchange the authorization code for id/access/refresh tokens.
33
+ /**
34
+ * @returns {Promise<{id_token: string, access_token: string, refresh_token: string}>}
35
+ */
36
+ async function exchangeCodeForTokens(code, redirectUri, pkce) {
37
+ const body = new URLSearchParams({
38
+ grant_type: "authorization_code",
39
+ code,
40
+ redirect_uri: redirectUri,
41
+ client_id: OAUTH_CLIENT_ID,
42
+ code_verifier: pkce.codeVerifier,
43
+ });
44
+ const resp = await fetch(`${OAUTH_ISSUER}/oauth/token`, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
47
+ body: body.toString(),
48
+ });
49
+ if (!resp.ok) {
50
+ const text = await resp.text();
51
+ throw new Error(`Token exchange failed (${resp.status}): ${text}`);
52
+ }
53
+ return /** @type {any} */ (await resp.json());
54
+ }
55
+
56
+ // Token-exchange the id_token for an OpenAI API key (best effort).
57
+ /**
58
+ * @param {string} idToken
59
+ * @returns {Promise<string|null>}
60
+ */
61
+ async function obtainApiKey(idToken) {
62
+ const body = new URLSearchParams({
63
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
64
+ client_id: OAUTH_CLIENT_ID,
65
+ requested_token: "openai-api-key",
66
+ subject_token: idToken,
67
+ subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
68
+ });
69
+ try {
70
+ const resp = await fetch(`${OAUTH_ISSUER}/oauth/token`, {
71
+ method: "POST",
72
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
73
+ body: body.toString(),
74
+ });
75
+ if (!resp.ok) return null;
76
+ const json = /** @type {any} */ (await resp.json());
77
+ return json.access_token || null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ // Runs the interactive ChatGPT OAuth login. Resolves once the callback is
84
+ // handled and credentials are persisted.
85
+ export function loginWithChatGPT() {
86
+ return new Promise((resolve, reject) => {
87
+ const pkce = generatePkce();
88
+ const state = generateState();
89
+ const redirectUri = `http://localhost:${OAUTH_PORT}/auth/callback`;
90
+ const authUrl = buildAuthorizeUrl(redirectUri, pkce, state);
91
+
92
+ const server = http.createServer(async (req, res) => {
93
+ let parsed;
94
+ try {
95
+ parsed = new URL(req.url, `http://localhost:${OAUTH_PORT}`);
96
+ } catch {
97
+ res.writeHead(400).end("Bad Request");
98
+ return;
99
+ }
100
+
101
+ if (parsed.pathname !== "/auth/callback") {
102
+ res.writeHead(404).end("Not Found");
103
+ return;
104
+ }
105
+
106
+ const params = parsed.searchParams;
107
+ const returnedState = params.get("state");
108
+ const error = params.get("error");
109
+ const code = params.get("code");
110
+
111
+ if (error) {
112
+ const desc = params.get("error_description") || error;
113
+ res
114
+ .writeHead(400, { "Content-Type": "text/html" })
115
+ .end(`<h1>Sign-in failed</h1><p>${desc}</p>`);
116
+ cleanup();
117
+ reject(new Error(`OAuth error: ${desc}`));
118
+ return;
119
+ }
120
+
121
+ if (returnedState !== state) {
122
+ res.writeHead(400).end("State mismatch");
123
+ cleanup();
124
+ reject(new Error("OAuth state mismatch"));
125
+ return;
126
+ }
127
+
128
+ if (!code) {
129
+ res.writeHead(400).end("Missing authorization code");
130
+ cleanup();
131
+ reject(new Error("Missing authorization code"));
132
+ return;
133
+ }
134
+
135
+ try {
136
+ const tokens = await exchangeCodeForTokens(code, redirectUri, pkce);
137
+ const apiKey = await obtainApiKey(tokens.id_token);
138
+ const auth = buildAuthFromTokens({
139
+ apiKey,
140
+ idToken: tokens.id_token,
141
+ accessToken: tokens.access_token,
142
+ refreshToken: tokens.refresh_token,
143
+ });
144
+ const file = writeAuth(auth);
145
+
146
+ const claims = jwtAuthClaims(tokens.id_token);
147
+ const plan = claims["chatgpt_plan_type"] || "unknown";
148
+
149
+ res.writeHead(200, { "Content-Type": "text/html" }).end(
150
+ `<html><body style="font-family:sans-serif;text-align:center;padding-top:80px">
151
+ <h1>✓ Signed in to purrx</h1>
152
+ <p>You can close this tab and return to your terminal.</p>
153
+ </body></html>`
154
+ );
155
+ cleanup();
156
+ resolve({ file, plan });
157
+ } catch (err) {
158
+ res
159
+ .writeHead(500, { "Content-Type": "text/html" })
160
+ .end(`<h1>Sign-in failed</h1><p>${err.message}</p>`);
161
+ cleanup();
162
+ reject(err);
163
+ }
164
+ });
165
+
166
+ function cleanup() {
167
+ setTimeout(() => server.close(), 100);
168
+ }
169
+
170
+ server.on("error", (err) => {
171
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === "EADDRINUSE") {
172
+ reject(
173
+ new Error(
174
+ `Port ${OAUTH_PORT} is already in use. Close any other Codex/purrx login and try again.`
175
+ )
176
+ );
177
+ } else {
178
+ reject(err);
179
+ }
180
+ });
181
+
182
+ server.listen(OAUTH_PORT, "127.0.0.1", () => {
183
+ console.log("\nOpening your browser to sign in with ChatGPT...");
184
+ console.log("If it doesn't open, paste this URL:\n");
185
+ console.log(` ${authUrl}\n`);
186
+ openBrowser(authUrl);
187
+ });
188
+ });
189
+ }
190
+
191
+ // Stores a bare API key as auth.json.
192
+ export function loginWithApiKey(apiKey) {
193
+ const auth = {
194
+ OPENAI_API_KEY: apiKey,
195
+ tokens: null,
196
+ last_refresh: new Date().toISOString(),
197
+ };
198
+ return writeAuth(auth);
199
+ }
@@ -0,0 +1,34 @@
1
+ import crypto from "node:crypto";
2
+
3
+ /**
4
+ * @param {Buffer} buf
5
+ * @returns {string}
6
+ */
7
+ function base64url(buf) {
8
+ return buf
9
+ .toString("base64")
10
+ .replace(/\+/g, "-")
11
+ .replace(/\//g, "_")
12
+ .replace(/=+$/, "");
13
+ }
14
+
15
+ /**
16
+ * Generate a PKCE code_verifier / code_challenge (S256) pair, matching the
17
+ * Codex login flow.
18
+ * @returns {{ codeVerifier: string, codeChallenge: string }}
19
+ */
20
+ export function generatePkce() {
21
+ const codeVerifier = base64url(crypto.randomBytes(64));
22
+ const codeChallenge = base64url(
23
+ crypto.createHash("sha256").update(codeVerifier).digest()
24
+ );
25
+ return { codeVerifier, codeChallenge };
26
+ }
27
+
28
+ /**
29
+ * Random opaque state value for CSRF protection.
30
+ * @returns {string}
31
+ */
32
+ export function generateState() {
33
+ return base64url(crypto.randomBytes(32));
34
+ }
@@ -0,0 +1,186 @@
1
+ import fs from "node:fs";
2
+ import {
3
+ authFilePath,
4
+ purrxHome,
5
+ OAUTH_CLIENT_ID,
6
+ OAUTH_ISSUER,
7
+ } from "../config.js";
8
+
9
+ /** @typedef {import("../types.js").AuthDotJson} AuthDotJson */
10
+ /** @typedef {import("../types.js").AuthInfo} AuthInfo */
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // auth.json read / write
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** @returns {AuthDotJson|null} */
17
+ export function readAuth() {
18
+ const file = authFilePath();
19
+ try {
20
+ const raw = fs.readFileSync(file, "utf8");
21
+ return JSON.parse(raw);
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * @param {AuthDotJson} auth
29
+ * @returns {string} the path written to
30
+ */
31
+ export function writeAuth(auth) {
32
+ const dir = purrxHome();
33
+ fs.mkdirSync(dir, { recursive: true });
34
+ const file = authFilePath();
35
+ fs.writeFileSync(file, JSON.stringify(auth, null, 2), { mode: 0o600 });
36
+ try {
37
+ fs.chmodSync(file, 0o600);
38
+ } catch {
39
+ // best effort on platforms without POSIX perms (Windows)
40
+ }
41
+ return file;
42
+ }
43
+
44
+ export function clearAuth() {
45
+ const file = authFilePath();
46
+ try {
47
+ fs.rmSync(file);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // JWT helpers
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export function decodeJwt(jwt) {
59
+ try {
60
+ const parts = jwt.split(".");
61
+ if (parts.length < 2) return {};
62
+ const payload = Buffer.from(parts[1], "base64url").toString("utf8");
63
+ return JSON.parse(payload);
64
+ } catch {
65
+ return {};
66
+ }
67
+ }
68
+
69
+ // Extracts the OpenAI auth claims object embedded in the id/access token.
70
+ export function jwtAuthClaims(jwt) {
71
+ const claims = decodeJwt(jwt);
72
+ return claims["https://api.openai.com/auth"] || {};
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Build an AuthDotJson-compatible structure from exchanged tokens
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * @param {{apiKey?: string|null, idToken: string, accessToken: string, refreshToken: string}} params
81
+ * @returns {AuthDotJson}
82
+ */
83
+ export function buildAuthFromTokens({ apiKey, idToken, accessToken, refreshToken }) {
84
+ const authClaims = jwtAuthClaims(idToken);
85
+ const accountId = authClaims["chatgpt_account_id"] || null;
86
+ return {
87
+ OPENAI_API_KEY: apiKey || null,
88
+ tokens: {
89
+ id_token: idToken,
90
+ access_token: accessToken,
91
+ refresh_token: refreshToken,
92
+ account_id: accountId,
93
+ },
94
+ last_refresh: new Date().toISOString(),
95
+ };
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Token refresh
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * @param {string} refreshToken
104
+ * @returns {Promise<{access_token?: string, id_token?: string, refresh_token?: string}>}
105
+ */
106
+ export async function refreshTokens(refreshToken) {
107
+ const resp = await fetch(`${OAUTH_ISSUER}/oauth/token`, {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify({
111
+ client_id: OAUTH_CLIENT_ID,
112
+ grant_type: "refresh_token",
113
+ refresh_token: refreshToken,
114
+ scope: "openid profile email",
115
+ }),
116
+ });
117
+ if (!resp.ok) {
118
+ const text = await resp.text();
119
+ throw new Error(`Token refresh failed (${resp.status}): ${text}`);
120
+ }
121
+ return /** @type {any} */ (await resp.json());
122
+ }
123
+
124
+ // Returns a valid auth object, refreshing ChatGPT tokens if they are older than
125
+ // ~28 days. Persists any refresh back to disk.
126
+ export async function ensureFreshAuth() {
127
+ const auth = readAuth();
128
+ if (!auth) return null;
129
+
130
+ // API-key only auth needs no refresh.
131
+ if (!auth.tokens) return auth;
132
+
133
+ const lastRefresh = auth.last_refresh ? new Date(auth.last_refresh) : null;
134
+ const ageMs = lastRefresh ? Date.now() - lastRefresh.getTime() : Infinity;
135
+ const TWENTY_EIGHT_DAYS = 28 * 24 * 60 * 60 * 1000;
136
+
137
+ if (ageMs < TWENTY_EIGHT_DAYS && auth.tokens.access_token) {
138
+ return auth;
139
+ }
140
+
141
+ if (!auth.tokens.refresh_token) return auth;
142
+
143
+ try {
144
+ const refreshed = await refreshTokens(auth.tokens.refresh_token);
145
+ auth.tokens.access_token = refreshed.access_token || auth.tokens.access_token;
146
+ auth.tokens.id_token = refreshed.id_token || auth.tokens.id_token;
147
+ if (refreshed.refresh_token) {
148
+ auth.tokens.refresh_token = refreshed.refresh_token;
149
+ }
150
+ auth.last_refresh = new Date().toISOString();
151
+ writeAuth(auth);
152
+ } catch (err) {
153
+ console.error(`Warning: could not refresh tokens: ${err.message}`);
154
+ }
155
+ return auth;
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Auth selection: how do we talk to the API?
160
+ // ---------------------------------------------------------------------------
161
+
162
+ // Returns { mode, accessToken, accountId } describing how to authenticate.
163
+ // mode = "apikey" -> use OPENAI_API_KEY against api.openai.com
164
+ // mode = "chatgpt" -> use OAuth access token against the codex backend
165
+ /**
166
+ * @param {AuthDotJson|null} auth
167
+ * @returns {AuthInfo|null}
168
+ */
169
+ export function resolveAuthMode(auth) {
170
+ // Environment variable always wins for explicit API-key usage.
171
+ if (process.env.OPENAI_API_KEY) {
172
+ return { mode: "apikey", apiKey: process.env.OPENAI_API_KEY };
173
+ }
174
+ if (!auth) return null;
175
+ if (auth.tokens?.access_token) {
176
+ return {
177
+ mode: "chatgpt",
178
+ accessToken: auth.tokens.access_token,
179
+ accountId: auth.tokens.account_id || null,
180
+ };
181
+ }
182
+ if (auth.OPENAI_API_KEY) {
183
+ return { mode: "apikey", apiKey: auth.OPENAI_API_KEY };
184
+ }
185
+ return null;
186
+ }
package/src/config.js ADDED
@@ -0,0 +1,57 @@
1
+ import path from "node:path";
2
+ import { dataHome } from "./platform.js";
3
+
4
+ // OAuth constants taken from the official OpenAI Codex CLI so we can reuse the
5
+ // same ChatGPT-account backend and auth.json files.
6
+ export const OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
7
+ export const OAUTH_ISSUER = "https://auth.openai.com";
8
+ export const OAUTH_PORT = 1455;
9
+ export const OAUTH_SCOPE =
10
+ "openid profile email offline_access api.connectors.read api.connectors.invoke";
11
+
12
+ // API endpoints.
13
+ export const API_BASE = "https://api.openai.com/v1";
14
+ export const CHATGPT_BASE = "https://chatgpt.com/backend-api/codex";
15
+
16
+ // Default model. Used as a fallback when discovery is unavailable.
17
+ // gpt-5-codex is the default Codex coding model.
18
+ export const DEFAULT_MODEL = process.env.PURRX_MODEL || "gpt-5-codex";
19
+
20
+ // Approximate context window (in tokens) used to decide when to auto-compact
21
+ // the conversation history. gpt-5 family models have large windows; we keep a
22
+ // conservative default and trigger compaction well before the hard limit.
23
+ // Override with PURRX_CONTEXT_LIMIT.
24
+ export const CONTEXT_LIMIT = Number(process.env.PURRX_CONTEXT_LIMIT) || 256_000;
25
+
26
+ // When estimated/used tokens exceed this fraction of CONTEXT_LIMIT, compact.
27
+ export const COMPACT_THRESHOLD =
28
+ Number(process.env.PURRX_COMPACT_THRESHOLD) || 0.8;
29
+
30
+ // Where we store credentials and sessions. Honours PURRX_HOME / CODEX_HOME and
31
+ // otherwise uses an OS-appropriate location (see platform.dataHome).
32
+ export function purrxHome() {
33
+ return dataHome();
34
+ }
35
+
36
+ export function authFilePath() {
37
+ return path.join(purrxHome(), "auth.json");
38
+ }
39
+
40
+ export function sessionsDir() {
41
+ return path.join(purrxHome(), "sessions");
42
+ }
43
+
44
+ export function configFilePath() {
45
+ return path.join(purrxHome(), "config.json");
46
+ }
47
+
48
+ // Directory of persistent memory files (*.md) injected into every session.
49
+ export function memoriesDir() {
50
+ return path.join(purrxHome(), "memories.d");
51
+ }
52
+
53
+ // Global skills directory. Each skill is a folder containing SKILL.md, or a
54
+ // bare <name>.md file.
55
+ export function skillsDir() {
56
+ return path.join(purrxHome(), "skills");
57
+ }
@@ -0,0 +1,197 @@
1
+ import { streamResponse } from "../api/client.js";
2
+ import { detectPlatform } from "../platform.js";
3
+ import { createSpinner, c, sym, randomWorkingPhrase } from "../ui/theme.js";
4
+ import { renderMarkdown } from "../ui/render.js";
5
+ import { buildContextBlock } from "./context.js";
6
+ import { maybeCompact } from "./compact.js";
7
+
8
+ function systemInstructions(cwd, registry) {
9
+ const platform = detectPlatform();
10
+ const mcpNote =
11
+ registry && registry.mcpServerCount() > 0
12
+ ? `\n- You have access to MCP tools (prefixed mcp__). Use them when relevant.`
13
+ : "";
14
+ const base = `You are purrx, a lightweight AI coding agent running in the user's terminal.
15
+ You help with software engineering tasks: reading and writing code, running commands, searching, and explaining things.
16
+
17
+ Environment:
18
+ - Operating system: ${platform}
19
+ - Working directory: ${cwd}
20
+
21
+ Guidelines:
22
+ - Use the provided tools to inspect and modify the project rather than guessing.
23
+ - Read files before editing them. Prefer edit_file for surgical changes.
24
+ - Use search_files and find_files to explore unfamiliar code.
25
+ - Keep responses concise. Show your work through tool calls.
26
+ - Use shell commands appropriate for the ${platform} platform.
27
+ - When running commands, prefer non-destructive operations and explain risky ones.
28
+ - Use web_search when you need current information beyond your training data.
29
+ - When the user shares a durable preference or project convention, save it with the remember tool.
30
+ - Before a specialized task, check list_skills and load the relevant one with use_skill.${mcpNote}`;
31
+
32
+ const context = buildContextBlock(cwd);
33
+ return context ? `${base}\n\n${context}` : base;
34
+ }
35
+
36
+ // Runs a single user turn to completion, looping over tool calls until the
37
+ // model produces a final answer.
38
+ /**
39
+ * @param {Object} opts
40
+ * @param {import("../types.js").AuthInfo} opts.authInfo
41
+ * @param {import("../types.js").HistoryItem[]} opts.history running history (mutated)
42
+ * @param {string} opts.userMessage
43
+ * @param {string} opts.cwd
44
+ * @param {string} opts.model
45
+ * @param {import("../tools/registry.js").ToolRegistry} opts.registry
46
+ * @param {{requestApproval: (name: string, label: string) => Promise<string>}} [opts.approval]
47
+ * @param {() => void} [opts.onChange]
48
+ * @returns {Promise<import("../types.js").HistoryItem[]>}
49
+ */
50
+ export async function runTurn({
51
+ authInfo,
52
+ history,
53
+ userMessage,
54
+ cwd,
55
+ model,
56
+ registry,
57
+ approval,
58
+ onChange,
59
+ }) {
60
+ history.push({
61
+ type: "message",
62
+ role: "user",
63
+ content: [{ type: "input_text", text: userMessage }],
64
+ });
65
+ if (onChange) onChange();
66
+
67
+ const tools = registry ? registry.definitions() : [];
68
+ const spinner = createSpinner(randomWorkingPhrase());
69
+ let lastUsedTokens = 0;
70
+
71
+ while (true) {
72
+ const toolCalls = [];
73
+ let buffered = "";
74
+
75
+ // Auto-compact before sending if we're near the context limit. This keeps
76
+ // each request within budget and preserves recent turns + a summary.
77
+ await maybeCompact({
78
+ authInfo,
79
+ history,
80
+ model,
81
+ usedTokens: lastUsedTokens,
82
+ onInfo: (msg) => console.log(c.dim(` ${sym.bullet} ${msg}`)),
83
+ });
84
+
85
+ spinner.start();
86
+
87
+ const finalResponse = await streamResponse(
88
+ {
89
+ authInfo,
90
+ model,
91
+ input: history,
92
+ tools,
93
+ instructions: systemInstructions(cwd, registry),
94
+ },
95
+ {
96
+ onText: (delta) => {
97
+ buffered += delta;
98
+ },
99
+ }
100
+ );
101
+
102
+ spinner.stop();
103
+
104
+ if (!finalResponse) {
105
+ throw new Error("No response received from the model.");
106
+ }
107
+
108
+ // Track real token usage when the API reports it.
109
+ const used = finalResponse.usage?.total_tokens;
110
+ if (typeof used === "number" && used > 0) lastUsedTokens = used;
111
+
112
+ // Render the assistant's prose as styled Markdown once the turn settles.
113
+ if (buffered.trim()) {
114
+ process.stdout.write(renderMarkdown(buffered) + "\n");
115
+ }
116
+
117
+ const output = finalResponse.output || [];
118
+ for (const item of output) {
119
+ history.push(item);
120
+ if (item.type === "function_call") {
121
+ toolCalls.push(item);
122
+ }
123
+ }
124
+ if (onChange) onChange();
125
+
126
+ if (toolCalls.length === 0) {
127
+ return history;
128
+ }
129
+
130
+ for (const call of toolCalls) {
131
+ let args = {};
132
+ try {
133
+ args = call.arguments ? JSON.parse(call.arguments) : {};
134
+ } catch {
135
+ args = {};
136
+ }
137
+
138
+ const label = describeCall(call.name, args);
139
+
140
+ let decision = "approve";
141
+ if (approval) {
142
+ decision = await approval.requestApproval(call.name, label);
143
+ }
144
+
145
+ let result;
146
+ if (decision === "reject") {
147
+ console.log(`${sym.fail} ${c.dim("skipped:")} ${label}`);
148
+ result = "The user declined to run this action.";
149
+ } else {
150
+ console.log(`${sym.tool} ${c.dim(label)}`);
151
+ result = await registry.execute(call.name, args, cwd);
152
+ }
153
+
154
+ history.push({
155
+ type: "function_call_output",
156
+ call_id: call.call_id,
157
+ output: typeof result === "string" ? result : JSON.stringify(result),
158
+ });
159
+ if (onChange) onChange();
160
+ }
161
+ }
162
+ }
163
+
164
+ function describeCall(name, args) {
165
+ switch (name) {
166
+ case "read_file":
167
+ return `read ${args.path}`;
168
+ case "write_file":
169
+ return `write ${args.path}`;
170
+ case "edit_file":
171
+ return `edit ${args.path}`;
172
+ case "list_directory":
173
+ return `list ${args.path || "."}`;
174
+ case "search_files":
175
+ return `search /${args.pattern}/${args.glob ? ` in ${args.glob}` : ""}`;
176
+ case "find_files":
177
+ return `find ${args.glob}`;
178
+ case "delete_file":
179
+ return `delete ${args.path}`;
180
+ case "run_command":
181
+ return `run: ${args.command}`;
182
+ case "fetch_url":
183
+ return `fetch ${args.url}`;
184
+ case "remember":
185
+ return `remember: ${(args.text || "").slice(0, 50)}`;
186
+ case "use_skill":
187
+ return `use skill ${args.name}`;
188
+ case "list_skills":
189
+ return `list skills`;
190
+ default:
191
+ if (name.startsWith("mcp__")) {
192
+ const [, server, tool] = name.split("__");
193
+ return `mcp ${server}/${tool}`;
194
+ }
195
+ return name;
196
+ }
197
+ }