diff --git a/.gitignore b/.gitignore index 6f425c1..c6e80df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ # Stow metadata .stow-local-ignore +pi/.pi/agent/profiles +pi/.pi/agent/sessions diff --git a/install.sh b/install.sh index 19973f4..ae48022 100755 --- a/install.sh +++ b/install.sh @@ -52,6 +52,14 @@ warn_missing autotiling "install via pip: pip install --user autotiling" warn_missing eww "install from https://github.com/elkowar/eww/releases" warn_missing wezterm "install from https://wezfurlong.org/wezterm/install/linux.html" +# --------------------------------------------------------------------------- +# NPM packages +# --------------------------------------------------------------------------- + +echo "" +echo "Installing npm packages..." +npm install -g @mariozechner/pi-coding-agent + # --------------------------------------------------------------------------- # Fonts # --------------------------------------------------------------------------- diff --git a/pi/.pi/agent/agents/explorer.md b/pi/.pi/agent/agents/explorer.md new file mode 100644 index 0000000..9732f10 --- /dev/null +++ b/pi/.pi/agent/agents/explorer.md @@ -0,0 +1,45 @@ +--- +name: explorer +description: Deep codebase and knowledge-base explorer using Claude Haiku with semantic search (QMD) and HDC-indexed context retrieval (opty). Use for thorough exploration, cross-cutting queries across docs and code, or when the local scout's Qwen model isn't cutting it. +tools: read, bash, write, mcp:qmd, mcp:opty +model: anthropic/claude-haiku-4-5 +output: context.md +defaultProgress: true +--- + +You are an explorer. Thoroughly investigate a codebase or knowledge base and return structured, actionable findings. + +Prefer semantic tools first: +1. Use qmd_query / qmd_get / qmd_multi_get for semantic and hybrid search of indexed docs and code +2. Use opty MCP tools for HDC-indexed context retrieval +3. Fall back to bash (grep/find) only when semantic tools don't surface what you need +4. Read key sections of files — not entire files unless necessary + +Thoroughness (infer from task, default thorough): +- Quick: targeted lookups, answer from search results alone +- Medium: follow the most important cross-references, read critical sections +- Thorough: trace all dependencies, check related files, synthesize a full picture + +Your output format (context.md): + +# Exploration Context + +## Query +What was explored and why. + +## Files & Docs Retrieved +List with exact line ranges or doc IDs: +1. `path/to/file.ts` (lines 10-50) — Description +2. `#docid` — Description + +## Key Findings +Critical types, interfaces, functions, or facts with actual snippets. + +## Architecture / Structure +How the pieces connect; data flow; key abstractions. + +## Gaps & Unknowns +What couldn't be determined and why. + +## Start Here +Which file or doc to look at first and why. diff --git a/pi/.pi/agent/agents/scout.md b/pi/.pi/agent/agents/scout.md new file mode 100644 index 0000000..77d7094 --- /dev/null +++ b/pi/.pi/agent/agents/scout.md @@ -0,0 +1,44 @@ +--- +name: scout +description: Fast codebase recon using local Qwen model — searches, reads, returns compressed findings +tools: read, grep, find, ls, bash, write, mcp:qmd, mcp:opty +model: llama-cpp/unsloth/Qwen3.5-4B-GGUF:Q5_K_M +output: context.md +defaultProgress: true +--- + +You are a scout. Quickly investigate a codebase and return structured findings. + +When running in a chain, you'll receive instructions about where to write your output. +When running solo, write to the provided output path and summarize what you found. + +Thoroughness (infer from task, default medium): +- Quick: Targeted lookups, key files only +- Medium: Follow imports, read critical sections +- Thorough: Trace all dependencies, check tests/types + +Strategy: +1. Use qmd tools for semantic/hybrid code search (preferred) +2. Use opty tools for HDC-indexed context retrieval +3. Fall back to grep/find only if qmd/opty don't find what you need +4. Read key sections (not entire files) +5. Identify types, interfaces, key functions +6. Note dependencies between files + +Your output format (context.md): + +# Code Context + +## Files Retrieved +List with exact line ranges: +1. `path/to/file.ts` (lines 10-50) - Description +2. `path/to/other.ts` (lines 100-150) - Description + +## Key Code +Critical types, interfaces, or functions with actual code snippets. + +## Architecture +Brief explanation of how the pieces connect. + +## Start Here +Which file to look at first and why. diff --git a/pi/.pi/agent/auth.json b/pi/.pi/agent/auth.json new file mode 100644 index 0000000..f1ffa5e --- /dev/null +++ b/pi/.pi/agent/auth.json @@ -0,0 +1,8 @@ +{ + "anthropic": { + "type": "oauth", + "refresh": "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", + "access": "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", + "expires": 1772856280733 + } +} \ No newline at end of file diff --git a/pi/.pi/agent/auth.json.current b/pi/.pi/agent/auth.json.current new file mode 100644 index 0000000..a340c10 --- /dev/null +++ b/pi/.pi/agent/auth.json.current @@ -0,0 +1 @@ +work \ No newline at end of file diff --git a/pi/.pi/agent/extensions/claude-account-switch.ts b/pi/.pi/agent/extensions/claude-account-switch.ts new file mode 100644 index 0000000..abe33bb --- /dev/null +++ b/pi/.pi/agent/extensions/claude-account-switch.ts @@ -0,0 +1,190 @@ +/** + * Claude Account Switch Extension + * + * Switches between two Claude Pro accounts (personal and work). + * Tokens are saved from pi's own OAuth sessions (not Claude CLI), + * so token refresh works correctly. + * + * Setup (one-time per account): + * 1. /login → authenticate with personal account + * 2. /switch-claude save personal + * 3. /login → authenticate with work account + * 4. /switch-claude save work + * + * Usage: + * /switch-claude — pick account interactively + * /switch-claude save — save current pi login as a named profile + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const HOME = os.homedir(); +const AUTH_FILE = path.join(HOME, ".pi/agent/auth.json"); +const AGENT_DIR = path.join(HOME, ".pi/agent"); +const MARKER_FILE = path.join(HOME, ".pi/agent/auth.json.current"); +const PROFILES_DIR = path.join(HOME, ".pi/agent/profiles"); + +type Account = "personal" | "work"; + +function profilePath(account: Account): string { + return path.join(PROFILES_DIR, `auth-${account}.json`); +} + +function hasProfile(account: Account): boolean { + return fs.existsSync(profilePath(account)); +} + +function saveProfile(account: Account): void { + fs.mkdirSync(PROFILES_DIR, { recursive: true }); + fs.copyFileSync(AUTH_FILE, profilePath(account)); + fs.chmodSync(profilePath(account), 0o600); + fs.writeFileSync(MARKER_FILE, account); +} + +function loadProfile(account: Account): void { + fs.copyFileSync(profilePath(account), AUTH_FILE); + fs.chmodSync(AUTH_FILE, 0o600); + fs.writeFileSync(MARKER_FILE, account); +} + +function getCurrentAccount(): Account | "unknown" { + try { + const marker = fs.readFileSync(MARKER_FILE, "utf8").trim(); + if (marker === "personal" || marker === "work") return marker; + } catch {} + return "unknown"; +} + +function statusLabel(account: Account | "unknown"): string { + switch (account) { + case "personal": return "🏠 personal"; + case "work": return "💼 work"; + default: return "❓ claude"; + } +} + +/** + * Sync auth.json into the active profile file whenever pi rotates tokens. + * + * Pi writes fresh OAuth tokens to auth.json (via direct write or atomic + * rename). Without this watcher the profile file would keep the original + * snapshot token, which Anthropic invalidates after its first use as a + * refresh-token rotation. The next /switch-claude would restore the dead + * refresh token and force a full re-login. + * + * We watch the agent directory (more reliable than watching the file + * directly across atomic renames) and copy auth.json → active profile + * on every change, debounced to avoid duplicate writes. + */ +function startProfileSyncer(getAccount: () => Account | "unknown"): fs.FSWatcher | null { + let debounceTimer: ReturnType | null = null; + + const syncNow = () => { + const account = getAccount(); + if (account === "unknown") return; + if (!fs.existsSync(AUTH_FILE)) return; + try { + const content = fs.readFileSync(AUTH_FILE, "utf8"); + JSON.parse(content); // only sync valid JSON + const dest = profilePath(account); + fs.mkdirSync(PROFILES_DIR, { recursive: true }); + fs.writeFileSync(dest, content, "utf8"); + fs.chmodSync(dest, 0o600); + } catch { + // ignore transient read errors (file mid-write, etc.) + } + }; + + try { + return fs.watch(AGENT_DIR, { persistent: false }, (_event, filename) => { + if (filename !== "auth.json") return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(syncNow, 200); + }); + } catch { + return null; + } +} + +export default function (pi: ExtensionAPI) { + let currentAccount: Account | "unknown" = "unknown"; + let syncer: fs.FSWatcher | null = null; + + pi.on("session_start", async (_event, ctx) => { + currentAccount = getCurrentAccount(); + ctx.ui.setStatus("claude-account", statusLabel(currentAccount)); + + // Start watching auth.json so token refreshes are mirrored into + // the active profile file. Only one watcher is needed per session. + if (!syncer) { + syncer = startProfileSyncer(() => currentAccount); + } + }); + + pi.on("session_shutdown", async () => { + if (syncer) { + syncer.close(); + syncer = null; + } + }); + + pi.registerCommand("switch-claude", { + description: "Switch between personal (🏠) and work (💼) Claude accounts. Use 'save ' to save current login as a profile.", + handler: async (args, ctx) => { + const trimmed = args?.trim() ?? ""; + + // Save current auth.json as a named profile + if (trimmed.startsWith("save ")) { + const name = trimmed.slice(5).trim(); + if (name !== "personal" && name !== "work") { + ctx.ui.notify("Usage: /switch-claude save personal|work", "warning"); + return; + } + saveProfile(name as Account); + currentAccount = name as Account; + ctx.ui.setStatus("claude-account", statusLabel(currentAccount)); + ctx.ui.notify(`Saved current login as ${statusLabel(name as Account)} profile`, "info"); + return; + } + + // Switch between profiles + const personalLabel = `🏠 personal${currentAccount === "personal" ? " ← current" : ""}${!hasProfile("personal") ? " (no profile saved)" : ""}`; + const workLabel = `💼 work${currentAccount === "work" ? " ← current" : ""}${!hasProfile("work") ? " (no profile saved)" : ""}`; + + const choice = await ctx.ui.select("Switch Claude account:", [personalLabel, workLabel]); + if (choice === undefined) return; + + const newAccount: Account = choice.startsWith("🏠") ? "personal" : "work"; + + if (newAccount === currentAccount) { + ctx.ui.notify(`Already using ${statusLabel(newAccount)}`, "info"); + return; + } + + if (!hasProfile(newAccount)) { + ctx.ui.notify( + `No profile saved for ${newAccount}. Run /login then /switch-claude save ${newAccount}`, + "warning" + ); + return; + } + + try { + // Persist any token refreshes pi made since the last save so + // we don't restore a stale refresh token when we come back. + if (currentAccount !== "unknown") saveProfile(currentAccount); + loadProfile(newAccount); + currentAccount = newAccount; + ctx.ui.setStatus("claude-account", statusLabel(currentAccount)); + pi.events.emit("claude-account:switched", { account: newAccount }); + ctx.ui.notify(`Switched to ${statusLabel(newAccount)} — restart pi to apply`, "info"); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + ctx.ui.notify(`Failed to switch: ${msg}`, "error"); + } + }, + }); +} diff --git a/pi/.pi/agent/extensions/llama-schema-proxy.ts b/pi/.pi/agent/extensions/llama-schema-proxy.ts new file mode 100644 index 0000000..78abaa5 --- /dev/null +++ b/pi/.pi/agent/extensions/llama-schema-proxy.ts @@ -0,0 +1,201 @@ +/** + * llama-server Schema Sanitization Proxy + * + * llama-server strictly validates JSON Schema and rejects any schema node + * that lacks a `type` field. Some of pi's built-in tools (e.g. `subagent`) + * have complex union-type parameters represented as `{"description": "..."}` with + * no `type`, which causes llama-server to return a 400 error. + * + * This extension starts a tiny local HTTP proxy on port 8081 that: + * 1. Intercepts outgoing OpenAI-compatible API calls + * 2. Walks tool schemas and adds `"type": "string"` to any schema node + * that is missing a type declaration + * 3. Forwards the fixed request to llama-server on port 8080 + * 4. Streams the response back transparently + * + * It also overrides the `llama-cpp` provider's baseUrl to point at the proxy, + * so no changes to models.json are needed (beyond what's already there). + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import * as http from "http"; + +const PROXY_PORT = 8081; +const TARGET_HOST = "127.0.0.1"; +const TARGET_PORT = 8080; + +// --------------------------------------------------------------------------- +// Schema sanitizer +// --------------------------------------------------------------------------- + +/** + * Recursively walk a JSON Schema object and add `"type": "string"` to any + * node that has no `type` and no composition keywords (oneOf/anyOf/allOf/$ref). + * This satisfies llama-server's strict validation without breaking valid nodes. + */ +function sanitizeSchema(schema: unknown): unknown { + if (!schema || typeof schema !== "object") return schema; + if (Array.isArray(schema)) return schema.map(sanitizeSchema); + + const obj = schema as Record; + const result: Record = {}; + + for (const [key, value] of Object.entries(obj)) { + if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) { + result[key] = Object.fromEntries( + Object.entries(value as Record).map(([k, v]) => [k, sanitizeSchema(v)]), + ); + } else if (key === "items") { + result[key] = sanitizeSchema(value); + } else if (key === "additionalProperties" && value && typeof value === "object") { + result[key] = sanitizeSchema(value); + } else if ( + (key === "oneOf" || key === "anyOf" || key === "allOf") && + Array.isArray(value) + ) { + result[key] = value.map(sanitizeSchema); + } else { + result[key] = value; + } + } + + // If this schema node has no type and no composition keywords, default to "string" + const hasType = "type" in result; + const hasComposition = + "oneOf" in result || "anyOf" in result || "allOf" in result || "$ref" in result; + const hasEnum = "enum" in result || "const" in result; + + if (!hasType && !hasComposition && !hasEnum) { + result["type"] = "string"; + } + + return result; +} + +/** + * Patch the `tools` array in a parsed request body, if present. + */ +function sanitizeRequestBody(body: Record): Record { + if (!Array.isArray(body.tools)) return body; + + return { + ...body, + tools: (body.tools as unknown[]).map((tool) => { + if (!tool || typeof tool !== "object") return tool; + const t = tool as Record; + if (!t.function || typeof t.function !== "object") return t; + const fn = t.function as Record; + if (!fn.parameters) return t; + return { + ...t, + function: { + ...fn, + parameters: sanitizeSchema(fn.parameters), + }, + }; + }), + }; +} + +// --------------------------------------------------------------------------- +// Proxy server +// --------------------------------------------------------------------------- + +function startProxy(): http.Server { + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const rawBody = Buffer.concat(chunks).toString("utf-8"); + + // Attempt to sanitize schemas in JSON bodies + let forwardBody = rawBody; + const contentType = req.headers["content-type"] ?? ""; + if (contentType.includes("application/json") && rawBody.trim().startsWith("{")) { + try { + const parsed = JSON.parse(rawBody) as Record; + const sanitized = sanitizeRequestBody(parsed); + forwardBody = JSON.stringify(sanitized); + } catch { + // Not valid JSON — send as-is + } + } + + const forwardBuffer = Buffer.from(forwardBody, "utf-8"); + + // Build forwarded headers, updating host and content-length + const forwardHeaders: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (k === "host") continue; // rewrite below + if (v !== undefined) forwardHeaders[k] = v as string | string[]; + } + forwardHeaders["host"] = `${TARGET_HOST}:${TARGET_PORT}`; + forwardHeaders["content-length"] = String(forwardBuffer.byteLength); + + const proxyReq = http.request( + { + host: TARGET_HOST, + port: TARGET_PORT, + path: req.url, + method: req.method, + headers: forwardHeaders, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers); + proxyRes.pipe(res, { end: true }); + }, + ); + + proxyReq.on("error", (err) => { + const msg = `Proxy error forwarding to llama-server: ${err.message}`; + if (!res.headersSent) { + res.writeHead(502, { "content-type": "text/plain" }); + } + res.end(msg); + }); + + proxyReq.write(forwardBuffer); + proxyReq.end(); + }); + + req.on("error", (err) => { + console.error("[llama-proxy] request error:", err); + }); + }); + + server.listen(PROXY_PORT, "127.0.0.1", () => { + // Server is up + }); + + server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + console.warn( + `[llama-proxy] Port ${PROXY_PORT} already in use — proxy not started. ` + + `If a previous pi session left it running, kill it and reload.`, + ); + } else { + console.error("[llama-proxy] Server error:", err); + } + }); + + return server; +} + +// --------------------------------------------------------------------------- +// Extension entry point +// --------------------------------------------------------------------------- + +export default function (pi: ExtensionAPI) { + const server = startProxy(); + + // Override the llama-cpp provider's baseUrl to route through our proxy. + // models.json model definitions are preserved; only the endpoint changes. + pi.registerProvider("llama-cpp", { + baseUrl: `http://127.0.0.1:${PROXY_PORT}/v1`, + }); + + pi.on("session_end", async () => { + server.close(); + }); +} diff --git a/pi/.pi/agent/extensions/local-explorer.ts b/pi/.pi/agent/extensions/local-explorer.ts new file mode 100644 index 0000000..bcade63 --- /dev/null +++ b/pi/.pi/agent/extensions/local-explorer.ts @@ -0,0 +1,15 @@ +/** + * Local Explorer Extension + * + * Previously auto-injected a scout hint into every cloud-model session. + * Now a no-op — scout delegation is opt-in via the `local-scout` skill. + * Invoke with /skill:local-scout or by mentioning "use scout" in a prompt. + * + * Placed in: ~/.pi/agent/extensions/local-explorer.ts + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (_pi: ExtensionAPI) { + // No automatic injection — scout is opt-in via the local-scout skill. +} diff --git a/pi/.pi/agent/extensions/new-with-context.ts b/pi/.pi/agent/extensions/new-with-context.ts new file mode 100644 index 0000000..3657ef7 --- /dev/null +++ b/pi/.pi/agent/extensions/new-with-context.ts @@ -0,0 +1,82 @@ +/** + * new-with-context extension + * + * Provides a /new-with-context command that starts a fresh session but carries + * only the last message from the current conversation into the new session. + * + * Usage: + * /new-with-context + * + * What it does: + * 1. Finds the last user or assistant message in the current branch + * 2. Starts a new session + * 3. Injects that last message as the opening context + */ + +import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("new-with-context", { + description: "Start a new session keeping only the last message as context", + handler: async (args, ctx) => { + await ctx.waitForIdle(); + + // Collect all message entries from the current branch + const branch = ctx.sessionManager.getBranch(); + const messageEntries = branch.filter( + (entry): entry is SessionEntry & { type: "message" } => entry.type === "message", + ); + + if (messageEntries.length === 0) { + ctx.ui.notify("No messages in current session to carry over.", "info"); + await ctx.newSession(); + return; + } + + // Grab the last message entry + const lastEntry = messageEntries[messageEntries.length - 1]; + const lastMessage = lastEntry.message; + + const currentSessionFile = ctx.sessionManager.getSessionFile(); + + // Create a new session and inject the last message as opening context + const result = await ctx.newSession({ + parentSession: currentSessionFile ?? undefined, + setup: async (sm) => { + sm.appendMessage(lastMessage); + }, + }); + + if (result.cancelled) { + ctx.ui.notify("New session cancelled.", "info"); + return; + } + + // Give a brief summary of what was carried over + const role = lastMessage.role; + let preview = ""; + if (role === "user" || role === "assistant") { + const content = lastMessage.content; + if (typeof content === "string") { + preview = content.slice(0, 80); + } else if (Array.isArray(content)) { + const textBlock = content.find((c: any) => c.type === "text"); + if (textBlock && "text" in textBlock) { + preview = (textBlock as { type: "text"; text: string }).text.slice(0, 80); + } + } + } else if (role === "toolResult") { + preview = `[tool result: ${(lastMessage as any).toolName}]`; + } else { + preview = `[${role} message]`; + } + + if (preview.length === 80) preview += "…"; + + ctx.ui.notify( + `New session started. Carried over last message (${role}): "${preview}"`, + "success", + ); + }, + }); +} diff --git a/pi/.pi/agent/extensions/usage-bars/core.ts b/pi/.pi/agent/extensions/usage-bars/core.ts new file mode 100644 index 0000000..513a430 --- /dev/null +++ b/pi/.pi/agent/extensions/usage-bars/core.ts @@ -0,0 +1,731 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +// --------------------------------------------------------------------------- +// Shared disk cache — lets multiple concurrent pi sessions coordinate so only +// one actually hits the API per cache window, regardless of how many sessions +// are open. Modelled after claude-pulse's cache.json approach. +// --------------------------------------------------------------------------- + +export interface UsageCache { + timestamp: number; + data: Partial>; + /** ISO timestamp until which a provider is rate-limited (429 backoff). */ + rateLimitedUntil?: Partial>; +} + +const USAGE_CACHE_FILE = path.join(os.homedir(), ".pi", "agent", "usage-cache.json"); + +export function readUsageCache(): UsageCache | null { + try { + const raw = fs.readFileSync(USAGE_CACHE_FILE, "utf-8"); + const parsed = JSON.parse(raw); + if (typeof parsed?.timestamp === "number") return parsed as UsageCache; + } catch {} + return null; +} + +export function writeUsageCache(cache: UsageCache): void { + try { + const dir = path.dirname(USAGE_CACHE_FILE); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const tmp = `${USAGE_CACHE_FILE}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync(tmp, JSON.stringify(cache, null, 2)); + fs.renameSync(tmp, USAGE_CACHE_FILE); + } catch {} +} + +export type ProviderKey = "codex" | "claude" | "zai" | "gemini" | "antigravity"; +export type OAuthProviderId = "openai-codex" | "anthropic" | "google-gemini-cli" | "google-antigravity"; + +export interface AuthData { + "openai-codex"?: { access?: string; refresh?: string; expires?: number }; + anthropic?: { access?: string; refresh?: string; expires?: number }; + zai?: { key?: string; access?: string; refresh?: string; expires?: number }; + "google-gemini-cli"?: { access?: string; refresh?: string; projectId?: string; expires?: number }; + "google-antigravity"?: { access?: string; refresh?: string; projectId?: string; expires?: number }; +} + +export interface UsageData { + session: number; + weekly: number; + sessionResetsIn?: string; + weeklyResetsIn?: string; + extraSpend?: number; + extraLimit?: number; + error?: string; +} + +export type UsageByProvider = Record; + +export interface UsageEndpoints { + zai: string; + gemini: string; + antigravity: string; + googleLoadCodeAssistEndpoints: string[]; +} + +export interface FetchResponseLike { + ok: boolean; + status: number; + json(): Promise; +} + +export type FetchLike = (input: string, init?: RequestInit) => Promise; + +export interface RequestConfig { + fetchFn?: FetchLike; + timeoutMs?: number; +} + +export interface FetchConfig extends RequestConfig { + endpoints?: UsageEndpoints; + env?: NodeJS.ProcessEnv; +} + +export interface OAuthApiKeyResult { + newCredentials: Record; + apiKey: string; +} + +export type OAuthApiKeyResolver = ( + providerId: OAuthProviderId, + credentials: Record>, +) => Promise; + +export interface EnsureFreshAuthConfig { + auth?: AuthData | null; + authFile?: string; + oauthResolver?: OAuthApiKeyResolver; + nowMs?: number; + persist?: boolean; +} + +export interface FreshAuthResult { + auth: AuthData | null; + changed: boolean; + refreshErrors: Partial>; +} + +export interface FetchAllUsagesConfig extends FetchConfig, EnsureFreshAuthConfig { + auth?: AuthData | null; + authFile?: string; +} + +const DEFAULT_FETCH_TIMEOUT_MS = 12_000; +const TOKEN_REFRESH_SKEW_MS = 60_000; + +export const DEFAULT_AUTH_FILE = path.join(os.homedir(), ".pi", "agent", "auth.json"); +export const DEFAULT_ZAI_USAGE_ENDPOINT = "https://api.z.ai/api/monitor/usage/quota/limit"; +export const GOOGLE_QUOTA_ENDPOINT = "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota"; +export const GOOGLE_LOAD_CODE_ASSIST_ENDPOINTS = [ + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist", +]; + +export function resolveUsageEndpoints(): UsageEndpoints { + return { + zai: DEFAULT_ZAI_USAGE_ENDPOINT, + gemini: GOOGLE_QUOTA_ENDPOINT, + antigravity: GOOGLE_QUOTA_ENDPOINT, + googleLoadCodeAssistEndpoints: GOOGLE_LOAD_CODE_ASSIST_ENDPOINTS, + }; +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + if (error.name === "AbortError") return "request timeout"; + return error.message || String(error); + } + return String(error); +} + +function asObject(value: unknown): Record | null { + if (!value || typeof value !== "object") return null; + return value as Record; +} + +function normalizeUsagePair(session: number, weekly: number): { session: number; weekly: number } { + const clean = (v: number) => { + if (!Number.isFinite(v)) return 0; + return Number(v.toFixed(2)); + }; + return { session: clean(session), weekly: clean(weekly) }; +} + +async function requestJson(url: string, init: RequestInit, config: RequestConfig = {}): Promise<{ ok: true; data: any } | { ok: false; error: string }> { + const fetchFn = config.fetchFn ?? ((fetch as unknown) as FetchLike); + const timeoutMs = config.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; + const controller = new AbortController(); + const timeout = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null; + + try { + const response = await fetchFn(url, { ...init, signal: controller.signal }); + if (!response.ok) return { ok: false, error: `HTTP ${response.status}` }; + + try { + const data = await response.json(); + return { ok: true, data }; + } catch { + return { ok: false, error: "invalid JSON response" }; + } + } catch (error) { + return { ok: false, error: toErrorMessage(error) }; + } finally { + if (timeout) clearTimeout(timeout); + } +} + +export function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return "now"; + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0 && h > 0) return `${d}d ${h}h`; + if (d > 0) return `${d}d`; + if (h > 0 && m > 0) return `${h}h ${m}m`; + if (h > 0) return `${h}h`; + if (m > 0) return `${m}m`; + return "<1m"; +} + +export function formatResetsAt(isoDate: string, nowMs = Date.now()): string { + const resetTime = new Date(isoDate).getTime(); + if (!Number.isFinite(resetTime)) return ""; + const diffSeconds = Math.max(0, (resetTime - nowMs) / 1000); + return formatDuration(diffSeconds); +} + +export function readAuth(authFile = DEFAULT_AUTH_FILE): AuthData | null { + try { + const parsed = JSON.parse(fs.readFileSync(authFile, "utf-8")); + return asObject(parsed) as AuthData; + } catch { + return null; + } +} + +export function writeAuth(auth: AuthData, authFile = DEFAULT_AUTH_FILE): boolean { + try { + const dir = path.dirname(authFile); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const tmpPath = `${authFile}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync(tmpPath, JSON.stringify(auth, null, 2)); + fs.renameSync(tmpPath, authFile); + return true; + } catch { + return false; + } +} + +let cachedOAuthResolver: OAuthApiKeyResolver | null = null; + +async function getDefaultOAuthResolver(): Promise { + if (cachedOAuthResolver) return cachedOAuthResolver; + + const mod = await import("@mariozechner/pi-ai"); + if (typeof (mod as any).getOAuthApiKey !== "function") { + throw new Error("oauth resolver unavailable"); + } + + cachedOAuthResolver = (providerId, credentials) => + (mod as any).getOAuthApiKey(providerId, credentials) as Promise; + + return cachedOAuthResolver; +} + +function isCredentialExpired(creds: { expires?: number } | undefined, nowMs: number): boolean { + if (!creds) return false; + if (typeof creds.expires !== "number") return false; + return nowMs + TOKEN_REFRESH_SKEW_MS >= creds.expires; +} + +export async function ensureFreshAuthForProviders( + providerIds: OAuthProviderId[], + config: EnsureFreshAuthConfig = {}, +): Promise { + const authFile = config.authFile ?? DEFAULT_AUTH_FILE; + const auth = config.auth ?? readAuth(authFile); + if (!auth) { + return { auth: null, changed: false, refreshErrors: {} }; + } + + const nowMs = config.nowMs ?? Date.now(); + const uniqueProviders = Array.from(new Set(providerIds)); + const nextAuth: AuthData = { ...auth }; + const refreshErrors: Partial> = {}; + + let changed = false; + + for (const providerId of uniqueProviders) { + const creds = (nextAuth as any)[providerId] as { access?: string; refresh?: string; expires?: number } | undefined; + if (!creds?.refresh) continue; + + const needsRefresh = !creds.access || isCredentialExpired(creds, nowMs); + if (!needsRefresh) continue; + + try { + const resolver = config.oauthResolver ?? (await getDefaultOAuthResolver()); + const resolved = await resolver(providerId, nextAuth as any); + if (!resolved?.newCredentials) { + refreshErrors[providerId] = "missing OAuth credentials"; + continue; + } + + (nextAuth as any)[providerId] = { + ...(nextAuth as any)[providerId], + ...resolved.newCredentials, + }; + changed = true; + } catch (error) { + refreshErrors[providerId] = toErrorMessage(error); + } + } + + if (changed && config.persist !== false) { + writeAuth(nextAuth, authFile); + } + + return { auth: nextAuth, changed, refreshErrors }; +} + +export function readPercentCandidate(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + + if (value >= 0 && value <= 1) { + if (Number.isInteger(value)) return value; + return value * 100; + } + + if (value >= 0 && value <= 100) return value; + return null; +} + +export function readLimitPercent(limit: any): number | null { + const direct = [ + limit?.percentage, + limit?.utilization, + limit?.used_percent, + limit?.usedPercent, + limit?.usagePercent, + limit?.usage_percent, + ] + .map(readPercentCandidate) + .find((v) => v != null); + + if (direct != null) return direct; + + const current = typeof limit?.currentValue === "number" ? limit.currentValue : null; + const remaining = typeof limit?.remaining === "number" ? limit.remaining : null; + + if (current != null && remaining != null && current + remaining > 0) { + return (current / (current + remaining)) * 100; + } + + return null; +} + +export function extractUsageFromPayload(data: any): { session: number; weekly: number } | null { + const limitArrays = [data?.data?.limits, data?.limits, data?.quota?.limits, data?.data?.quota?.limits]; + const limits = limitArrays.find((arr) => Array.isArray(arr)) as any[] | undefined; + + if (limits) { + const byType = (types: string[]) => + limits.find((l) => { + const t = String(l?.type || "").toUpperCase(); + return types.some((x) => t === x); + }); + + const sessionLimit = byType(["TIME_LIMIT", "SESSION_LIMIT", "REQUEST_LIMIT", "RPM_LIMIT", "RPD_LIMIT"]); + const weeklyLimit = byType(["TOKENS_LIMIT", "TOKEN_LIMIT", "WEEK_LIMIT", "WEEKLY_LIMIT", "TPM_LIMIT", "DAILY_LIMIT"]); + + const s = readLimitPercent(sessionLimit); + const w = readLimitPercent(weeklyLimit); + if (s != null && w != null) return normalizeUsagePair(s, w); + } + + const sessionCandidates = [ + data?.session, + data?.sessionPercent, + data?.session_percent, + data?.five_hour?.utilization, + data?.rate_limit?.primary_window?.used_percent, + data?.limits?.session?.utilization, + data?.usage?.session, + data?.data?.session, + data?.data?.sessionPercent, + data?.data?.session_percent, + data?.data?.usage?.session, + data?.quota?.session?.percentage, + data?.data?.quota?.session?.percentage, + ]; + + const weeklyCandidates = [ + data?.weekly, + data?.weeklyPercent, + data?.weekly_percent, + data?.seven_day?.utilization, + data?.rate_limit?.secondary_window?.used_percent, + data?.limits?.weekly?.utilization, + data?.usage?.weekly, + data?.data?.weekly, + data?.data?.weeklyPercent, + data?.data?.weekly_percent, + data?.data?.usage?.weekly, + data?.quota?.weekly?.percentage, + data?.data?.quota?.weekly?.percentage, + data?.quota?.daily?.percentage, + data?.data?.quota?.daily?.percentage, + ]; + + const session = sessionCandidates.map(readPercentCandidate).find((v) => v != null); + const weekly = weeklyCandidates.map(readPercentCandidate).find((v) => v != null); + + if (session == null || weekly == null) return null; + return normalizeUsagePair(session, weekly); +} + +export function googleMetadata(projectId?: string) { + return { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + ...(projectId ? { duetProject: projectId } : {}), + }; +} + +export function googleHeaders(token: string, projectId?: string) { + return { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "X-Goog-Api-Client": "gl-node/22.17.0", + "Client-Metadata": JSON.stringify(googleMetadata(projectId)), + }; +} + +export async function discoverGoogleProjectId(token: string, config: FetchConfig = {}): Promise { + const env = config.env ?? process.env; + const envProjectId = env.GOOGLE_CLOUD_PROJECT || env.GOOGLE_CLOUD_PROJECT_ID; + if (envProjectId) return envProjectId; + + const endpoints = config.endpoints ?? resolveUsageEndpoints(); + + for (const endpoint of endpoints.googleLoadCodeAssistEndpoints) { + const result = await requestJson( + endpoint, + { + method: "POST", + headers: googleHeaders(token), + body: JSON.stringify({ metadata: googleMetadata() }), + }, + config, + ); + + if (!result.ok) continue; + + const data = result.data; + if (typeof data?.cloudaicompanionProject === "string" && data.cloudaicompanionProject) { + return data.cloudaicompanionProject; + } + if (data?.cloudaicompanionProject && typeof data.cloudaicompanionProject === "object") { + const id = data.cloudaicompanionProject.id; + if (typeof id === "string" && id) return id; + } + } + + return undefined; +} + +export function usedPercentFromRemainingFraction(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + const remaining = Math.max(0, Math.min(1, value)); + return (1 - remaining) * 100; +} + +export function pickMostUsedBucket(buckets: any[]): any | null { + let best: any | null = null; + let bestUsed = -1; + for (const bucket of buckets) { + const used = usedPercentFromRemainingFraction(bucket?.remainingFraction); + if (used == null) continue; + if (used > bestUsed) { + bestUsed = used; + best = bucket; + } + } + return best; +} + +export function parseGoogleQuotaBuckets(data: any, provider: "gemini" | "antigravity"): { session: number; weekly: number } | null { + const allBuckets = Array.isArray(data?.buckets) ? data.buckets : []; + if (!allBuckets.length) return null; + + const requestBuckets = allBuckets.filter((b: any) => String(b?.tokenType || "").toUpperCase() === "REQUESTS"); + const buckets = requestBuckets.length ? requestBuckets : allBuckets; + + const modelId = (b: any) => String(b?.modelId || "").toLowerCase(); + const claudeNonThinking = buckets.filter((b: any) => modelId(b).includes("claude") && !modelId(b).includes("thinking")); + const geminiPro = buckets.filter((b: any) => modelId(b).includes("gemini") && modelId(b).includes("pro")); + const geminiFlash = buckets.filter((b: any) => modelId(b).includes("gemini") && modelId(b).includes("flash")); + + const primaryBucket = + provider === "antigravity" + ? pickMostUsedBucket(claudeNonThinking) || pickMostUsedBucket(geminiPro) || pickMostUsedBucket(geminiFlash) || pickMostUsedBucket(buckets) + : pickMostUsedBucket(geminiPro) || pickMostUsedBucket(geminiFlash) || pickMostUsedBucket(buckets); + + const secondaryBucket = pickMostUsedBucket(geminiFlash) || pickMostUsedBucket(geminiPro) || pickMostUsedBucket(buckets); + + const session = usedPercentFromRemainingFraction(primaryBucket?.remainingFraction); + const weekly = usedPercentFromRemainingFraction(secondaryBucket?.remainingFraction); + + if (session == null || weekly == null) return null; + return normalizeUsagePair(session, weekly); +} + +export async function fetchCodexUsage(token: string, config: RequestConfig = {}): Promise { + const result = await requestJson( + "https://chatgpt.com/backend-api/wham/usage", + { headers: { Authorization: `Bearer ${token}` } }, + config, + ); + + if (!result.ok) return { session: 0, weekly: 0, error: result.error }; + + const primary = result.data?.rate_limit?.primary_window; + const secondary = result.data?.rate_limit?.secondary_window; + + return { + session: readPercentCandidate(primary?.used_percent) ?? 0, + weekly: readPercentCandidate(secondary?.used_percent) ?? 0, + sessionResetsIn: typeof primary?.reset_after_seconds === "number" ? formatDuration(primary.reset_after_seconds) : undefined, + weeklyResetsIn: typeof secondary?.reset_after_seconds === "number" ? formatDuration(secondary.reset_after_seconds) : undefined, + }; +} + +export async function fetchClaudeUsage(token: string, config: RequestConfig = {}): Promise { + const result = await requestJson( + "https://api.anthropic.com/api/oauth/usage", + { + headers: { + Authorization: `Bearer ${token}`, + "anthropic-beta": "oauth-2025-04-20", + }, + }, + config, + ); + + if (!result.ok) return { session: 0, weekly: 0, error: result.error }; + + const data = result.data; + const usage: UsageData = { + session: readPercentCandidate(data?.five_hour?.utilization) ?? 0, + weekly: readPercentCandidate(data?.seven_day?.utilization) ?? 0, + sessionResetsIn: data?.five_hour?.resets_at ? formatResetsAt(data.five_hour.resets_at) : undefined, + weeklyResetsIn: data?.seven_day?.resets_at ? formatResetsAt(data.seven_day.resets_at) : undefined, + }; + + if (data?.extra_usage?.is_enabled) { + usage.extraSpend = typeof data.extra_usage.used_credits === "number" ? data.extra_usage.used_credits : undefined; + usage.extraLimit = typeof data.extra_usage.monthly_limit === "number" ? data.extra_usage.monthly_limit : undefined; + } + + return usage; +} + +export async function fetchZaiUsage(token: string, config: FetchConfig = {}): Promise { + const endpoint = (config.endpoints ?? resolveUsageEndpoints()).zai; + if (!endpoint) return { session: 0, weekly: 0, error: "usage endpoint unavailable" }; + + const result = await requestJson( + endpoint, + { headers: { Authorization: `Bearer ${token}` } }, + config, + ); + + if (!result.ok) return { session: 0, weekly: 0, error: result.error }; + + const parsed = extractUsageFromPayload(result.data); + if (!parsed) return { session: 0, weekly: 0, error: "unrecognized response shape" }; + return parsed; +} + +export async function fetchGoogleUsage( + token: string, + endpoint: string, + projectId: string | undefined, + provider: "gemini" | "antigravity", + config: FetchConfig = {}, +): Promise { + if (!endpoint) return { session: 0, weekly: 0, error: "configure endpoint" }; + + const discoveredProjectId = projectId || (await discoverGoogleProjectId(token, config)); + if (!discoveredProjectId) { + return { session: 0, weekly: 0, error: "missing projectId (try /login again)" }; + } + + const result = await requestJson( + endpoint, + { + method: "POST", + headers: googleHeaders(token, discoveredProjectId), + body: JSON.stringify({ project: discoveredProjectId }), + }, + config, + ); + + if (!result.ok) return { session: 0, weekly: 0, error: result.error }; + + const quota = parseGoogleQuotaBuckets(result.data, provider); + if (quota) return quota; + + const parsed = extractUsageFromPayload(result.data); + if (!parsed) return { session: 0, weekly: 0, error: "unrecognized response shape" }; + return parsed; +} + +export function detectProvider( + model: { provider?: string; id?: string; name?: string; api?: string } | string | undefined | null, +): ProviderKey | null { + if (!model) return null; + if (typeof model === "string") return null; + + const provider = (model.provider || "").toLowerCase(); + + if (provider === "openai-codex") return "codex"; + if (provider === "anthropic") return "claude"; + if (provider === "zai") return "zai"; + if (provider === "google-gemini-cli") return "gemini"; + if (provider === "google-antigravity") return "antigravity"; + + return null; +} + +export function providerToOAuthProviderId(active: ProviderKey | null): OAuthProviderId | null { + if (active === "codex") return "openai-codex"; + if (active === "claude") return "anthropic"; + if (active === "gemini") return "google-gemini-cli"; + if (active === "antigravity") return "google-antigravity"; + return null; +} + +export function canShowForProvider(active: ProviderKey | null, auth: AuthData | null, endpoints: UsageEndpoints): boolean { + if (!active || !auth) return false; + if (active === "codex") return !!(auth["openai-codex"]?.access || auth["openai-codex"]?.refresh); + if (active === "claude") return !!(auth.anthropic?.access || auth.anthropic?.refresh); + if (active === "zai") return !!(auth.zai?.access || auth.zai?.key) && !!endpoints.zai; + if (active === "gemini") { + return !!(auth["google-gemini-cli"]?.access || auth["google-gemini-cli"]?.refresh) && !!endpoints.gemini; + } + if (active === "antigravity") { + return !!(auth["google-antigravity"]?.access || auth["google-antigravity"]?.refresh) && !!endpoints.antigravity; + } + return false; +} + +export function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, Math.round(value))); +} + +export function colorForPercent(value: number): "success" | "warning" | "error" { + if (value >= 90) return "error"; + if (value >= 70) return "warning"; + return "success"; +} + +export async function fetchAllUsages(config: FetchAllUsagesConfig = {}): Promise { + const authFile = config.authFile ?? DEFAULT_AUTH_FILE; + const auth = config.auth ?? readAuth(authFile); + const endpoints = config.endpoints ?? resolveUsageEndpoints(); + + const results: UsageByProvider = { + codex: null, + claude: null, + zai: null, + gemini: null, + antigravity: null, + }; + + if (!auth) return results; + + const oauthProviders: OAuthProviderId[] = [ + "openai-codex", + "anthropic", + "google-gemini-cli", + "google-antigravity", + ]; + + const refreshed = await ensureFreshAuthForProviders(oauthProviders, { + ...config, + auth, + authFile, + }); + + const authData = refreshed.auth ?? auth; + + const refreshError = (providerId: OAuthProviderId): string | null => { + const error = refreshed.refreshErrors[providerId]; + return error ? `auth refresh failed (${error})` : null; + }; + + const tasks: Promise[] = []; + const assign = (provider: ProviderKey, task: Promise) => { + tasks.push( + task + .then((usage) => { + results[provider] = usage; + }) + .catch((error) => { + results[provider] = { session: 0, weekly: 0, error: toErrorMessage(error) }; + }), + ); + }; + + if (authData["openai-codex"]?.access) { + const err = refreshError("openai-codex"); + if (err) results.codex = { session: 0, weekly: 0, error: err }; + else assign("codex", fetchCodexUsage(authData["openai-codex"].access, config)); + } + + if (authData.anthropic?.access) { + const err = refreshError("anthropic"); + if (err) results.claude = { session: 0, weekly: 0, error: err }; + else assign("claude", fetchClaudeUsage(authData.anthropic.access, config)); + } + + if (authData.zai?.access || authData.zai?.key) { + assign("zai", fetchZaiUsage(authData.zai.access || authData.zai.key!, { ...config, endpoints })); + } + + if (authData["google-gemini-cli"]?.access) { + const err = refreshError("google-gemini-cli"); + if (err) { + results.gemini = { session: 0, weekly: 0, error: err }; + } else { + const creds = authData["google-gemini-cli"]; + assign( + "gemini", + fetchGoogleUsage(creds.access!, endpoints.gemini, creds.projectId, "gemini", { ...config, endpoints }), + ); + } + } + + if (authData["google-antigravity"]?.access) { + const err = refreshError("google-antigravity"); + if (err) { + results.antigravity = { session: 0, weekly: 0, error: err }; + } else { + const creds = authData["google-antigravity"]; + assign( + "antigravity", + fetchGoogleUsage(creds.access!, endpoints.antigravity, creds.projectId, "antigravity", { ...config, endpoints }), + ); + } + } + + await Promise.all(tasks); + return results; +} diff --git a/pi/.pi/agent/extensions/usage-bars/index.ts b/pi/.pi/agent/extensions/usage-bars/index.ts new file mode 100644 index 0000000..9347819 --- /dev/null +++ b/pi/.pi/agent/extensions/usage-bars/index.ts @@ -0,0 +1,576 @@ +/** + * Usage Extension - Minimal API usage indicator for pi + * + * Shows Codex (OpenAI), Anthropic (Claude), Z.AI, and optionally + * Google Gemini CLI / Antigravity usage as color-coded percentages + * in the footer status bar. + */ + +import { DynamicBorder, type ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + Container, + Input, + Spacer, + Text, + getEditorKeybindings, + type Focusable, +} from "@mariozechner/pi-tui"; +import { + canShowForProvider, + clampPercent, + colorForPercent, + detectProvider, + fetchAllUsages, + fetchClaudeUsage, + fetchCodexUsage, + fetchGoogleUsage, + fetchZaiUsage, + providerToOAuthProviderId, + readAuth, + readUsageCache, + resolveUsageEndpoints, + writeUsageCache, + type ProviderKey, + type UsageByProvider, + type UsageData, +} from "./core"; + +const CACHE_TTL_MS = 15 * 60 * 1000; // reuse cached data for 15 min +const RATE_LIMITED_BACKOFF_MS = 60 * 60 * 1000; // 1 hour back-off after 429 +const STATUS_KEY = "usage-bars"; + +const PROVIDER_LABELS: Record = { + codex: "Codex", + claude: "Claude", + zai: "Z.AI", + gemini: "Gemini", + antigravity: "Antigravity", +}; + +interface SubscriptionItem { + name: string; + provider: ProviderKey; + data: UsageData | null; + isActive: boolean; +} + +class UsageSelectorComponent extends Container implements Focusable { + private searchInput: Input; + private listContainer: Container; + private hintText: Text; + private tui: any; + private theme: any; + private onCancelCallback: () => void; + private allItems: SubscriptionItem[] = []; + private filteredItems: SubscriptionItem[] = []; + private selectedIndex = 0; + private loading = true; + private activeProvider: ProviderKey | null; + private fetchAllFn: () => Promise; + private _focused = false; + + get focused(): boolean { + return this._focused; + } + + set focused(value: boolean) { + this._focused = value; + this.searchInput.focused = value; + } + + constructor( + tui: any, + theme: any, + activeProvider: ProviderKey | null, + fetchAll: () => Promise, + onCancel: () => void, + ) { + super(); + this.tui = tui; + this.theme = theme; + this.activeProvider = activeProvider; + this.fetchAllFn = fetchAll; + this.onCancelCallback = onCancel; + + this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + this.addChild(new Spacer(1)); + + this.hintText = new Text(theme.fg("dim", "Fetching usage from all providers…"), 0, 0); + this.addChild(this.hintText); + this.addChild(new Spacer(1)); + + this.searchInput = new Input(); + this.addChild(this.searchInput); + this.addChild(new Spacer(1)); + + this.listContainer = new Container(); + this.addChild(this.listContainer); + this.addChild(new Spacer(1)); + + this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + this.fetchAllFn() + .then((results) => { + this.loading = false; + this.buildItems(results); + this.updateList(); + this.hintText.setText( + theme.fg("muted", "Only showing providers with credentials. ") + + theme.fg("dim", "✓ = active provider"), + ); + this.tui.requestRender(); + }) + .catch(() => { + this.loading = false; + this.hintText.setText(theme.fg("error", "Failed to fetch usage data")); + this.tui.requestRender(); + }); + + this.updateList(); + } + + private buildItems(results: UsageByProvider) { + const providers: Array<{ key: ProviderKey; name: string }> = [ + { key: "codex", name: "Codex" }, + { key: "claude", name: "Claude" }, + { key: "zai", name: "Z.AI" }, + { key: "gemini", name: "Gemini" }, + { key: "antigravity", name: "Antigravity" }, + ]; + + this.allItems = []; + for (const p of providers) { + if (results[p.key] !== null) { + this.allItems.push({ + name: p.name, + provider: p.key, + data: results[p.key], + isActive: this.activeProvider === p.key, + }); + } + } + + this.filteredItems = this.allItems; + this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1)); + } + + private filterItems(query: string) { + if (!query) { + this.filteredItems = this.allItems; + } else { + const q = query.toLowerCase(); + this.filteredItems = this.allItems.filter( + (item) => item.name.toLowerCase().includes(q) || item.provider.toLowerCase().includes(q), + ); + } + this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1)); + } + + private renderBar(pct: number, width = 16): string { + const value = clampPercent(pct); + const filled = Math.round((value / 100) * width); + const color = colorForPercent(value); + const full = "█".repeat(Math.max(0, filled)); + const empty = "░".repeat(Math.max(0, width - filled)); + return this.theme.fg(color, full) + this.theme.fg("dim", empty); + } + + private renderItem(item: SubscriptionItem, isSelected: boolean) { + const t = this.theme; + const pointer = isSelected ? t.fg("accent", "→ ") : " "; + const activeBadge = item.isActive ? t.fg("success", " ✓") : ""; + const name = isSelected ? t.fg("accent", t.bold(item.name)) : item.name; + + this.listContainer.addChild(new Text(`${pointer}${name}${activeBadge}`, 0, 0)); + + const indent = " "; + + if (!item.data) { + this.listContainer.addChild(new Text(indent + t.fg("dim", "No credentials"), 0, 0)); + } else if (item.data.error) { + this.listContainer.addChild(new Text(indent + t.fg("error", item.data.error), 0, 0)); + } else { + const session = clampPercent(item.data.session); + const weekly = clampPercent(item.data.weekly); + + const sessionReset = item.data.sessionResetsIn + ? t.fg("dim", ` resets in ${item.data.sessionResetsIn}`) + : ""; + const weeklyReset = item.data.weeklyResetsIn + ? t.fg("dim", ` resets in ${item.data.weeklyResetsIn}`) + : ""; + + this.listContainer.addChild( + new Text( + indent + + t.fg("muted", "Session ") + + this.renderBar(session) + + " " + + t.fg(colorForPercent(session), `${session}%`.padStart(4)) + + sessionReset, + 0, + 0, + ), + ); + + this.listContainer.addChild( + new Text( + indent + + t.fg("muted", "Weekly ") + + this.renderBar(weekly) + + " " + + t.fg(colorForPercent(weekly), `${weekly}%`.padStart(4)) + + weeklyReset, + 0, + 0, + ), + ); + + if (typeof item.data.extraSpend === "number" && typeof item.data.extraLimit === "number") { + this.listContainer.addChild( + new Text( + indent + + t.fg("muted", "Extra ") + + t.fg("dim", `$${item.data.extraSpend.toFixed(2)} / $${item.data.extraLimit}`), + 0, + 0, + ), + ); + } + } + + this.listContainer.addChild(new Spacer(1)); + } + + private updateList() { + this.listContainer.clear(); + + if (this.loading) { + this.listContainer.addChild(new Text(this.theme.fg("muted", " Loading…"), 0, 0)); + return; + } + + if (this.filteredItems.length === 0) { + this.listContainer.addChild(new Text(this.theme.fg("muted", " No matching providers"), 0, 0)); + return; + } + + for (let i = 0; i < this.filteredItems.length; i++) { + this.renderItem(this.filteredItems[i]!, i === this.selectedIndex); + } + } + + handleInput(keyData: string): void { + const kb = getEditorKeybindings(); + + if (kb.matches(keyData, "selectUp")) { + if (this.filteredItems.length === 0) return; + this.selectedIndex = + this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1; + this.updateList(); + return; + } + + if (kb.matches(keyData, "selectDown")) { + if (this.filteredItems.length === 0) return; + this.selectedIndex = + this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1; + this.updateList(); + return; + } + + if (kb.matches(keyData, "selectCancel") || kb.matches(keyData, "selectConfirm")) { + this.onCancelCallback(); + return; + } + + this.searchInput.handleInput(keyData); + this.filterItems(this.searchInput.getValue()); + this.updateList(); + } +} + +interface UsageState extends UsageByProvider { + lastPoll: number; + activeProvider: ProviderKey | null; +} + +export default function (pi: ExtensionAPI) { + const endpoints = resolveUsageEndpoints(); + const state: UsageState = { + codex: null, + claude: null, + zai: null, + gemini: null, + antigravity: null, + lastPoll: 0, + activeProvider: null, + }; + + let pollInFlight: Promise | null = null; + let pollQueued = false; + let ctx: any = null; + + function renderPercent(theme: any, value: number): string { + const v = clampPercent(value); + return theme.fg(colorForPercent(v), `${v}%`); + } + + function renderBar(theme: any, value: number): string { + const v = clampPercent(value); + const width = 8; + const filled = Math.round((v / 100) * width); + const full = "█".repeat(Math.max(0, Math.min(width, filled))); + const empty = "░".repeat(Math.max(0, width - filled)); + return theme.fg(colorForPercent(v), full) + theme.fg("dim", empty); + } + + function pickDataForProvider(provider: ProviderKey | null): UsageData | null { + if (!provider) return null; + return state[provider]; + } + + function updateStatus() { + const active = state.activeProvider; + const data = pickDataForProvider(active); + + if (data && !data.error) { + pi.events.emit("usage:update", { + session: data.session, + weekly: data.weekly, + sessionResetsIn: data.sessionResetsIn, + weeklyResetsIn: data.weeklyResetsIn, + }); + } + + if (!ctx?.hasUI) return; + + if (!active) { + ctx.ui.setStatus(STATUS_KEY, undefined); + return; + } + + const auth = readAuth(); + if (!canShowForProvider(active, auth, endpoints)) { + ctx.ui.setStatus(STATUS_KEY, undefined); + return; + } + + const theme = ctx.ui.theme; + const label = PROVIDER_LABELS[active]; + + if (!data) { + ctx.ui.setStatus(STATUS_KEY, theme.fg("dim", `${label} usage: loading…`)); + return; + } + + if (data.error) { + const cache = readUsageCache(); + const blockedUntil = active ? (cache?.rateLimitedUntil?.[active] ?? 0) : 0; + const backoffNote = blockedUntil > Date.now() + ? ` — retry in ${Math.ceil((blockedUntil - Date.now()) / 60000)}m` + : ""; + ctx.ui.setStatus(STATUS_KEY, theme.fg("warning", `${label} usage unavailable (${data.error}${backoffNote})`)); + return; + } + + const session = clampPercent(data.session); + const weekly = clampPercent(data.weekly); + + const sessionReset = data.sessionResetsIn ? theme.fg("dim", ` ⟳ ${data.sessionResetsIn}`) : ""; + const weeklyReset = data.weeklyResetsIn ? theme.fg("dim", ` ⟳ ${data.weeklyResetsIn}`) : ""; + + const status = + theme.fg("dim", `${label} `) + + theme.fg("muted", "S ") + + renderBar(theme, session) + + " " + + renderPercent(theme, session) + + sessionReset + + theme.fg("muted", " W ") + + renderBar(theme, weekly) + + " " + + renderPercent(theme, weekly) + + weeklyReset; + + ctx.ui.setStatus(STATUS_KEY, status); + } + + function updateProviderFrom(modelLike: any): boolean { + const previous = state.activeProvider; + state.activeProvider = detectProvider(modelLike); + + if (previous !== state.activeProvider) { + updateStatus(); + return true; + } + + return false; + } + + async function runPoll() { + const auth = readAuth(); + const active = state.activeProvider; + + if (!canShowForProvider(active, auth, endpoints) || !auth || !active) { + state.lastPoll = Date.now(); + updateStatus(); + return; + } + + // --- Shared disk cache check --- + // All pi sessions read and write the same cache file so that only one + // process hits the API per CACHE_TTL_MS window, no matter how many + // sessions are open at once. + const cache = readUsageCache(); + const now = Date.now(); + + // Respect cross-session rate-limit back-off written by any session. + const blockedUntil = cache?.rateLimitedUntil?.[active] ?? 0; + if (now < blockedUntil) { + // Use whatever data is in the cache so the bar still shows something. + if (cache?.data?.[active]) state[active] = cache.data[active]!; + state.lastPoll = now; + updateStatus(); + return; + } + + // If another session already polled recently, use their result. + if (cache && now - cache.timestamp < CACHE_TTL_MS && cache.data?.[active]) { + state[active] = cache.data[active]!; + state.lastPoll = now; + updateStatus(); + return; + } + + // --- Actually hit the API --- + // Skip independent token refresh — pi manages OAuth tokens and refreshes + // them in memory. A parallel refresh here would cause token rotation + // conflicts (Anthropic invalidates the old refresh token on use). + let result: UsageData; + + if (active === "codex") { + const access = auth["openai-codex"]?.access; + result = access + ? await fetchCodexUsage(access) + : { session: 0, weekly: 0, error: "missing access token (try /login again)" }; + } else if (active === "claude") { + const access = auth.anthropic?.access; + result = access + ? await fetchClaudeUsage(access) + : { session: 0, weekly: 0, error: "missing access token (try /login again)" }; + } else if (active === "zai") { + const token = auth.zai?.access || auth.zai?.key; + result = token + ? await fetchZaiUsage(token, { endpoints }) + : { session: 0, weekly: 0, error: "missing token (try /login again)" }; + } else if (active === "gemini") { + const creds = auth["google-gemini-cli"]; + result = creds?.access + ? await fetchGoogleUsage(creds.access, endpoints.gemini, creds.projectId, "gemini", { endpoints }) + : { session: 0, weekly: 0, error: "missing access token (try /login again)" }; + } else { + const creds = auth["google-antigravity"]; + result = creds?.access + ? await fetchGoogleUsage(creds.access, endpoints.antigravity, creds.projectId, "antigravity", { endpoints }) + : { session: 0, weekly: 0, error: "missing access token (try /login again)" }; + } + + state[active] = result; + + // Write result + rate-limit state to shared cache so other sessions + // (and our own next timer tick) don't need to re-hit the API. + const nextCache: import("./core").UsageCache = { + timestamp: now, + data: { ...(cache?.data ?? {}), [active]: result }, + rateLimitedUntil: { ...(cache?.rateLimitedUntil ?? {}) }, + }; + if (result.error === "HTTP 429") { + nextCache.rateLimitedUntil![active] = now + RATE_LIMITED_BACKOFF_MS; + } else { + delete nextCache.rateLimitedUntil![active]; + } + writeUsageCache(nextCache); + + state.lastPoll = now; + updateStatus(); + } + + async function poll() { + if (pollInFlight) { + pollQueued = true; + await pollInFlight; + return; + } + + do { + pollQueued = false; + pollInFlight = runPoll() + .catch(() => { + // Never crash extension event handlers on transient polling errors. + }) + .finally(() => { + pollInFlight = null; + }); + + await pollInFlight; + } while (pollQueued); + } + + pi.on("session_start", async (_event, _ctx) => { + ctx = _ctx; + updateProviderFrom(_ctx.model); + await poll(); + }); + + pi.on("session_shutdown", async (_event, _ctx) => { + if (_ctx?.hasUI) { + _ctx.ui.setStatus(STATUS_KEY, undefined); + } + }); + + // Refresh usage on every turn (like claude-pulse's UserPromptSubmit hook). + // The disk cache means the API is only hit at most once per CACHE_TTL_MS + // regardless of how many turns or sessions are active. + pi.on("turn_start", async (_event, _ctx) => { + ctx = _ctx; + updateProviderFrom(_ctx.model); + await poll(); + }); + + pi.on("model_select", async (event, _ctx) => { + ctx = _ctx; + const changed = updateProviderFrom(event.model ?? _ctx.model); + if (changed) await poll(); + }); + + pi.registerCommand("usage", { + description: "Show API usage for all subscriptions", + handler: async (_args, _ctx) => { + ctx = _ctx; + updateProviderFrom(_ctx.model); + + try { + if (_ctx?.hasUI) { + await _ctx.ui.custom((tui, theme, _keybindings, done) => { + const selector = new UsageSelectorComponent( + tui, + theme, + state.activeProvider, + () => fetchAllUsages({ endpoints }), + () => done(), + ); + return selector; + }); + } + } finally { + await poll(); + } + }, + }); + + // Re-poll immediately when the Claude account is switched via /switch-claude + pi.events.on("claude-account:switched", () => { + void poll(); + }); +} diff --git a/pi/.pi/agent/mcp-cache.json b/pi/.pi/agent/mcp-cache.json new file mode 100644 index 0000000..9b3a692 --- /dev/null +++ b/pi/.pi/agent/mcp-cache.json @@ -0,0 +1,140 @@ +{ + "version": 1, + "servers": { + "qmd": { + "configHash": "fd16eaf87d17a4ce5efee10dc65237dbbe1403353bbbfc4a7de196abe21ab5f9", + "tools": [ + { + "name": "query", + "description": "Search the knowledge base using a query document — one or more typed sub-queries combined for best recall.\n\n## Query Types\n\n**lex** — BM25 keyword search. Fast, exact, no LLM needed.\nFull lex syntax:\n- `term` — prefix match (\"perf\" matches \"performance\")\n- `\"exact phrase\"` — phrase must appear verbatim\n- `-term` or `-\"phrase\"` — exclude documents containing this\n\nGood lex examples:\n- `\"connection pool\" timeout -redis`\n- `\"machine learning\" -sports -athlete`\n- `handleError async typescript`\n\n**vec** — Semantic vector search. Write a natural language question. Finds documents by meaning, not exact words.\n- `how does the rate limiter handle burst traffic?`\n- `what is the tradeoff between consistency and availability?`\n\n**hyde** — Hypothetical document. Write 50-100 words that look like the answer. Often the most powerful for nuanced topics.\n- `The rate limiter uses a token bucket algorithm. When a client exceeds 100 req/min, subsequent requests return 429 until the window resets.`\n\n## Strategy\n\nCombine types for best results. First sub-query gets 2× weight — put your strongest signal first.\n\n| Goal | Approach |\n|------|----------|\n| Know exact term/name | `lex` only |\n| Concept search | `vec` only |\n| Best recall | `lex` + `vec` |\n| Complex/nuanced | `lex` + `vec` + `hyde` |\n| Unknown vocabulary | Use a standalone natural-language query (no typed lines) so the server can auto-expand it |\n\n## Examples\n\nSimple lookup:\n```json\n[{ \"type\": \"lex\", \"query\": \"CAP theorem\" }]\n```\n\nBest recall on a technical topic:\n```json\n[\n { \"type\": \"lex\", \"query\": \"\\\"connection pool\\\" timeout -redis\" },\n { \"type\": \"vec\", \"query\": \"why do database connections time out under load\" },\n { \"type\": \"hyde\", \"query\": \"Connection pool exhaustion occurs when all connections are in use and new requests must wait. This typically happens under high concurrency when queries run longer than expected.\" }\n]\n```\n\nIntent-aware lex (C++ performance, not sports):\n```json\n[\n { \"type\": \"lex\", \"query\": \"\\\"C++ performance\\\" optimization -sports -athlete\" },\n { \"type\": \"vec\", \"query\": \"how to optimize C++ program performance\" }\n]\n```", + "inputSchema": { + "type": "object", + "properties": { + "searches": { + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "lex", + "vec", + "hyde" + ], + "description": "lex = BM25 keywords (supports \"phrase\" and -negation); vec = semantic question; hyde = hypothetical answer passage" + }, + "query": { + "type": "string", + "description": "The query text. For lex: use keywords, \"quoted phrases\", and -negation. For vec: natural language question. For hyde: 50-100 word answer passage." + } + }, + "required": [ + "type", + "query" + ] + }, + "description": "Typed sub-queries to execute (lex/vec/hyde). First gets 2x weight." + }, + "limit": { + "default": 10, + "description": "Max results (default: 10)", + "type": "number" + }, + "minScore": { + "default": 0, + "description": "Min relevance 0-1 (default: 0)", + "type": "number" + }, + "collections": { + "description": "Filter to collections (OR match)", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "searches" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "get", + "description": "Retrieve the full content of a document by its file path or docid. Use paths or docids (#abc123) from search results. Suggests similar files if not found.", + "inputSchema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "description": "File path or docid from search results (e.g., 'pages/meeting.md', '#abc123', or 'pages/meeting.md:100' to start at line 100)" + }, + "fromLine": { + "description": "Start from this line number (1-indexed)", + "type": "number" + }, + "maxLines": { + "description": "Maximum number of lines to return", + "type": "number" + }, + "lineNumbers": { + "default": false, + "description": "Add line numbers to output (format: 'N: content')", + "type": "boolean" + } + }, + "required": [ + "file" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "multi_get", + "description": "Retrieve multiple documents by glob pattern (e.g., 'journals/2025-05*.md') or comma-separated list. Skips files larger than maxBytes.", + "inputSchema": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern or comma-separated list of file paths" + }, + "maxLines": { + "description": "Maximum lines per file", + "type": "number" + }, + "maxBytes": { + "default": 10240, + "description": "Skip files larger than this (default: 10240 = 10KB)", + "type": "number" + }, + "lineNumbers": { + "default": false, + "description": "Add line numbers to output (format: 'N: content')", + "type": "boolean" + } + }, + "required": [ + "pattern" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "status", + "description": "Show the status of the QMD index: collections, document counts, and health information.", + "inputSchema": { + "type": "object", + "properties": {}, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } + ], + "resources": [], + "cachedAt": 1772656106222 + } + } +} \ No newline at end of file diff --git a/pi/.pi/agent/mcp.json b/pi/.pi/agent/mcp.json new file mode 100644 index 0000000..b631d3b --- /dev/null +++ b/pi/.pi/agent/mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "qmd": { + "command": "qmd", + "args": [ + "mcp" + ], + "directTools": true + }, + "opty": { + "command": "opty", + "args": [ + "mcp" + ] + } + } +} diff --git a/pi/.pi/agent/models.json b/pi/.pi/agent/models.json new file mode 100644 index 0000000..99b87b7 --- /dev/null +++ b/pi/.pi/agent/models.json @@ -0,0 +1,41 @@ +{ + "providers": { + "llama-cpp": { + "baseUrl": "http://localhost:8080/v1", + "api": "openai-completions", + "apiKey": "sk-no-key", + "models": [ + { + "id": "unsloth/Qwen3.5-9B-GGUF:Q5_K_M", + "name": "Qwen 3.5 9B Q5_K_M (Local M1 Max - Unsloth)", + "reasoning": true, + "input": ["text"], + "contextWindow": 262144, + "maxTokens": 32768, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "compat": { + "supportsDeveloperRole": false, + "supportsReasoningEffort": false, + "maxTokensField": "max_tokens", + "thinkingFormat": "qwen" + } + }, + { + "id": "unsloth/Qwen3.5-4B-GGUF:Q5_K_M", + "name": "Qwen 3.5 4B Q5_K_M (Local M1 Max - Unsloth)", + "reasoning": true, + "input": ["text"], + "contextWindow": 262144, + "maxTokens": 32768, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "compat": { + "supportsDeveloperRole": false, + "supportsReasoningEffort": false, + "maxTokensField": "max_tokens", + "thinkingFormat": "qwen" + } + } + ] + } + } +} diff --git a/pi/.pi/agent/run-history.jsonl b/pi/.pi/agent/run-history.jsonl new file mode 100644 index 0000000..5cfe288 --- /dev/null +++ b/pi/.pi/agent/run-history.jsonl @@ -0,0 +1,8 @@ +{"agent":"scout","task":"I need to understand the editor and rendering architecture for implementing entity picking. Please find and summarize:\n\n1. How editor mode is toggled (look for key 'I' handling, editor mode state)\n2. ","ts":1772639557,"status":"ok","duration":90399} +{"agent":"scout","task":"I need to understand the editor and rendering architecture for implementing entity picking. Please find and summarize:\n\n1. How editor mode is toggled (look for key 'I' handling, editor mode flag)\n2. T","ts":1772654920,"status":"ok","duration":1217167} +{"agent":"scout","task":"I need to understand the codebase to plan entity picking in the editor. Find and summarize:\n\n1. How the editor mode works (toggled with 'I' key) - find the editor module/system, what it currently does","ts":1772655378,"status":"ok","duration":282743} +{"agent":"scout","task":"I need to understand the codebase to plan entity picking in the editor. Find and summarize:\n\n1. How editor mode works - look for editor-related code, the \"I\" key toggle, inspector UI\n2. How entities a","ts":1772655580,"status":"ok","duration":174603} +{"agent":"scout","task":"Explore the codebase structure. Find: 1) the main game loop and input handling, 2) the ECS system and component definitions, 3) any existing UI/rendering systems, 4) entity types (player, trees, etc),","ts":1772656483,"status":"ok","duration":451373} +{"agent":"scout","task":"I need to understand the rendering pipeline, scene loading, and transform usage for implementing entity picking with a visual selection indicator. Find and read:\n\n1. `src/render/mod.rs` - the full ren","ts":1772656502,"status":"ok","duration":82458} +{"agent":"scout","task":"Explore the codebase at /home/jonas/projects/snow_trail_sdl and give me a detailed summary of:\n1. src/loaders/mesh.rs - full content, especially the Mesh struct and all constructor functions\n2. src/pi","ts":1772658265,"status":"ok","duration":330549} +{"agent":"scout","task":"Find and summarize: 1) how the camera follow/update logic works, 2) how editor mode is tracked/detected. Look for camera systems, editor state, and any existing checks for editor mode in camera code.","ts":1772659168,"status":"ok","duration":5992} diff --git a/pi/.pi/agent/settings.json b/pi/.pi/agent/settings.json new file mode 100644 index 0000000..ab24d9e --- /dev/null +++ b/pi/.pi/agent/settings.json @@ -0,0 +1,13 @@ +{ + "lastChangelogVersion": "0.56.1", + "packages": [ + "npm:pi-subagents", + "npm:@aliou/pi-guardrails", + "npm:pi-mcp-adapter", + "npm:pi-ask-tool-extension", + "npm:pi-web-access" + ], + "defaultProvider": "anthropic", + "defaultModel": "claude-sonnet-4-6", + "hideThinkingBlock": true +} \ No newline at end of file diff --git a/pi/.pi/agent/skills/local-scout/SKILL.md b/pi/.pi/agent/skills/local-scout/SKILL.md new file mode 100644 index 0000000..a7d960f --- /dev/null +++ b/pi/.pi/agent/skills/local-scout/SKILL.md @@ -0,0 +1,29 @@ +--- +name: local-scout +description: "Delegates codebase exploration to the local scout subagent (runs on a fast local Qwen model with QMD + opty tools). Load this skill only when the user explicitly asks to use scout, use local scout, or scout a task. Do NOT load automatically for general exploration — only when scout is explicitly requested." +--- + +# Local Scout + +Delegate codebase exploration to the `scout` subagent, which runs on a fast local model (Qwen) augmented with semantic search via QMD and HDC-indexed context retrieval via opty. It is cheap, fast, and keeps the main context clean. + +## When to use + +- User says "use scout to find …", "scout: …", or "use local scout" +- You need to gather broad codebase context before planning +- The task is primarily "look around the codebase" rather than making precise edits + +## How to invoke + +```javascript +subagent({ agent: "scout", task: "Find and summarize the authentication flow" }) +``` + +The scout writes its findings to `context.md` and returns a summary. Use the summary or read `context.md` for the full structured output. + +## Tips + +- Be specific in the task description — the scout infers thoroughness from it +- For deep traces, prefix with "Thorough:" e.g. `"Thorough: trace all usages of X"` +- For quick lookups, prefix with "Quick:" e.g. `"Quick: where is the config loaded?"` +- Do your own reading only when you need precise line-level content to reference in your response diff --git a/pi/.pi/agent/usage-cache.json b/pi/.pi/agent/usage-cache.json new file mode 100644 index 0000000..1fc59b6 --- /dev/null +++ b/pi/.pi/agent/usage-cache.json @@ -0,0 +1,12 @@ +{ + "timestamp": 1772832937691, + "data": { + "claude": { + "session": 82, + "weekly": 9, + "sessionResetsIn": "3h 24m", + "weeklyResetsIn": "6d 22h" + } + }, + "rateLimitedUntil": {} +} \ No newline at end of file