feat: add 16 default extensions for Agent JAE
New extensions shipping with Agent JAE: - jae-rules: Per-project .jae/rules.md injection - cost-tracker: Token/cost tracking with budget limits - project-dna: Auto-analyze project fingerprint - teach-mode: Pedagogical explanation mode - bookmarks: File bookmark management - replay: Session recording and asciicast export - checkpoints: Git-based undo/time-travel - pr-review: Autonomous PR diff review - auto-docs: Auto-generate README and docs - screenshot-context: Vision analysis for screenshots - deploy: One-command deploy (Docker/Vercel/rsync/static) - pair-programming: Real-time file watching with feedback - dashboard: Terminal HUD with live stats - swarm: Multi-agent parallel task execution - skill-marketplace: Search/install/publish skills - widget-api-demo: TUI widget API demonstration
This commit is contained in:
parent
c42cd9a062
commit
a1b6e22c01
16 changed files with 1797 additions and 0 deletions
149
default-extensions/auto-docs.ts
Normal file
149
default-extensions/auto-docs.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { join, basename } from "node:path";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
async function analyzeAndGenerate(cwd: string, ctx: any): Promise<string> {
|
||||||
|
const sections: string[] = [];
|
||||||
|
|
||||||
|
// Project name
|
||||||
|
let projectName = basename(cwd);
|
||||||
|
let description = "";
|
||||||
|
const pkgPath = join(cwd, "package.json");
|
||||||
|
if (existsSync(pkgPath)) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||||
|
projectName = pkg.name || projectName;
|
||||||
|
description = pkg.description || "";
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push(`# ${projectName}\n`);
|
||||||
|
if (description) sections.push(`${description}\n`);
|
||||||
|
|
||||||
|
// Tech stack
|
||||||
|
const stackChecks = [
|
||||||
|
{ file: "package.json", label: "Node.js/JavaScript" },
|
||||||
|
{ file: "tsconfig.json", label: "TypeScript" },
|
||||||
|
{ file: "Cargo.toml", label: "Rust" },
|
||||||
|
{ file: "go.mod", label: "Go" },
|
||||||
|
{ file: "pyproject.toml", label: "Python" },
|
||||||
|
{ file: "requirements.txt", label: "Python" },
|
||||||
|
{ file: "Dockerfile", label: "Docker" },
|
||||||
|
{ file: "docker-compose.yml", label: "Docker Compose" },
|
||||||
|
{ file: "next.config.js", label: "Next.js" },
|
||||||
|
{ file: "next.config.mjs", label: "Next.js" },
|
||||||
|
{ file: "vite.config.ts", label: "Vite" },
|
||||||
|
{ file: "tailwind.config.js", label: "Tailwind CSS" },
|
||||||
|
{ file: "tailwind.config.ts", label: "Tailwind CSS" },
|
||||||
|
];
|
||||||
|
const stack = [...new Set(stackChecks.filter((c) => existsSync(join(cwd, c.file))).map((c) => c.label))];
|
||||||
|
if (stack.length > 0) {
|
||||||
|
sections.push(`## Tech Stack\n${stack.map((s) => `- ${s}`).join("\n")}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prerequisites
|
||||||
|
sections.push(`## Prerequisites\n`);
|
||||||
|
if (existsSync(join(cwd, "package.json"))) sections.push(`- Node.js (v18+ recommended)\n- npm or yarn\n`);
|
||||||
|
if (existsSync(join(cwd, "requirements.txt"))) sections.push(`- Python 3.8+\n- pip\n`);
|
||||||
|
if (existsSync(join(cwd, "Cargo.toml"))) sections.push(`- Rust / cargo\n`);
|
||||||
|
if (existsSync(join(cwd, "Dockerfile"))) sections.push(`- Docker\n`);
|
||||||
|
|
||||||
|
// Installation
|
||||||
|
sections.push(`## Installation\n\n\`\`\`bash\ngit clone <repo-url>\ncd ${basename(cwd)}\n`);
|
||||||
|
if (existsSync(join(cwd, "package.json"))) sections.push(`npm install\n`);
|
||||||
|
if (existsSync(join(cwd, "requirements.txt"))) sections.push(`pip install -r requirements.txt\n`);
|
||||||
|
sections.push(`\`\`\`\n`);
|
||||||
|
|
||||||
|
// Scripts
|
||||||
|
if (existsSync(pkgPath)) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||||
|
if (pkg.scripts && Object.keys(pkg.scripts).length > 0) {
|
||||||
|
sections.push(`## Available Scripts\n`);
|
||||||
|
for (const [name, cmd] of Object.entries(pkg.scripts).slice(0, 20) as [string, string][]) {
|
||||||
|
sections.push(`- \`npm run ${name}\` - ${cmd}`);
|
||||||
|
}
|
||||||
|
sections.push("");
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project structure
|
||||||
|
try {
|
||||||
|
const treeResult = await pi.exec(
|
||||||
|
"find",
|
||||||
|
[".", "-maxdepth", "2", "-type", "f",
|
||||||
|
"-not", "-path", "*/node_modules/*",
|
||||||
|
"-not", "-path", "*/.git/*",
|
||||||
|
"-not", "-path", "*/dist/*"],
|
||||||
|
{ cwd },
|
||||||
|
);
|
||||||
|
if (treeResult.code === 0 && treeResult.stdout.trim()) {
|
||||||
|
const files = treeResult.stdout.trim().split("\n").sort().slice(0, 40);
|
||||||
|
sections.push(`## Project Structure\n\n\`\`\`\n${files.join("\n")}\n\`\`\`\n`);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Env vars
|
||||||
|
const envExample = join(cwd, ".env.example");
|
||||||
|
if (existsSync(envExample)) {
|
||||||
|
const envContent = readFileSync(envExample, "utf-8").trim();
|
||||||
|
sections.push(`## Environment Variables\n\nCopy \`.env.example\` to \`.env\` and configure:\n\n\`\`\`\n${envContent}\n\`\`\`\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// License
|
||||||
|
const licensePath = join(cwd, "LICENSE");
|
||||||
|
if (existsSync(licensePath)) {
|
||||||
|
const license = readFileSync(licensePath, "utf-8");
|
||||||
|
const firstLine = license.split("\n")[0].trim();
|
||||||
|
sections.push(`## License\n\n${firstLine || "See LICENSE file."}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push(`\n---\n*Auto-generated by Agent JAE on ${new Date().toISOString().split("T")[0]}*\n`);
|
||||||
|
|
||||||
|
return sections.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "generate_docs",
|
||||||
|
label: "Generate Documentation",
|
||||||
|
description: "Auto-generate README and documentation for the current project",
|
||||||
|
parameters: Type.Object({
|
||||||
|
output: Type.Optional(Type.String({ description: "Output filename (default: README.md)" })),
|
||||||
|
}),
|
||||||
|
execute: async (args, ctx) => {
|
||||||
|
const filename = args.output || "README.md";
|
||||||
|
const content = await analyzeAndGenerate(ctx.cwd, ctx);
|
||||||
|
const outPath = join(ctx.cwd, filename);
|
||||||
|
writeFileSync(outPath, content, "utf-8");
|
||||||
|
return { output: `Documentation generated: ${outPath} (${content.length} chars)` };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("docs", {
|
||||||
|
description: "Auto-generate README & docs: /docs [filename]",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const filename = args.trim() || "README.md";
|
||||||
|
ctx.ui.notify("\u{1F4DD} Analyzing project and generating documentation...", "info");
|
||||||
|
|
||||||
|
const content = await analyzeAndGenerate(ctx.cwd, ctx);
|
||||||
|
const outPath = join(ctx.cwd, filename);
|
||||||
|
|
||||||
|
if (existsSync(outPath)) {
|
||||||
|
const confirmed = await ctx.ui.confirm(
|
||||||
|
"Overwrite?",
|
||||||
|
`${filename} already exists. Overwrite?`,
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
ctx.ui.notify("Cancelled.", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(outPath, content, "utf-8");
|
||||||
|
ctx.ui.notify(`\u{1F4DD} Documentation generated: ${outPath} (${content.length} chars)`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
132
default-extensions/bookmarks.ts
Normal file
132
default-extensions/bookmarks.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
interface Bookmark {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
line?: number;
|
||||||
|
note?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
let bookmarks: Bookmark[] = [];
|
||||||
|
|
||||||
|
function getBookmarksPath(cwd: string): string {
|
||||||
|
return join(cwd, ".jae", "bookmarks.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBookmarks(cwd: string): void {
|
||||||
|
const bPath = getBookmarksPath(cwd);
|
||||||
|
if (existsSync(bPath)) {
|
||||||
|
try {
|
||||||
|
bookmarks = JSON.parse(readFileSync(bPath, "utf-8"));
|
||||||
|
} catch {
|
||||||
|
bookmarks = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBookmarks(cwd: string): void {
|
||||||
|
const jaeDir = join(cwd, ".jae");
|
||||||
|
if (!existsSync(jaeDir)) mkdirSync(jaeDir, { recursive: true });
|
||||||
|
writeFileSync(getBookmarksPath(cwd), JSON.stringify(bookmarks, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
loadBookmarks(ctx.cwd);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("bookmark", {
|
||||||
|
description: "Manage bookmarks: /bookmark [add <name> <path>[:line]] | /bookmark list | /bookmark remove <name>",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
loadBookmarks(ctx.cwd);
|
||||||
|
const parts = args.trim().split(/\s+/);
|
||||||
|
const subcommand = parts[0] || "list";
|
||||||
|
|
||||||
|
if (subcommand === "list" || subcommand === "ls") {
|
||||||
|
if (bookmarks.length === 0) {
|
||||||
|
ctx.ui.notify("No bookmarks saved. Use /bookmark add <name> <path> to add one.", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = bookmarks
|
||||||
|
.map((b, i) => ` ${i + 1}. ${b.name} -> ${b.path}${b.line ? `:${b.line}` : ""}${b.note ? ` (${b.note})` : ""}`)
|
||||||
|
.join("\n");
|
||||||
|
ctx.ui.notify(`\u{1F516} Bookmarks:\n${list}`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === "add") {
|
||||||
|
const name = parts[1];
|
||||||
|
const pathArg = parts[2];
|
||||||
|
const note = parts.slice(3).join(" ") || undefined;
|
||||||
|
if (!name || !pathArg) {
|
||||||
|
ctx.ui.notify("Usage: /bookmark add <name> <path>[:line] [note]", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let filePath = pathArg;
|
||||||
|
let line: number | undefined;
|
||||||
|
if (pathArg.includes(":")) {
|
||||||
|
const colonIdx = pathArg.lastIndexOf(":");
|
||||||
|
const maybeNum = parseInt(pathArg.substring(colonIdx + 1), 10);
|
||||||
|
if (!isNaN(maybeNum)) {
|
||||||
|
filePath = pathArg.substring(0, colonIdx);
|
||||||
|
line = maybeNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove existing bookmark with same name
|
||||||
|
bookmarks = bookmarks.filter((b) => b.name !== name);
|
||||||
|
bookmarks.push({ name, path: filePath, line, note, timestamp: new Date().toISOString() });
|
||||||
|
saveBookmarks(ctx.cwd);
|
||||||
|
ctx.ui.notify(`\u{1F516} Bookmark '${name}' saved: ${filePath}${line ? `:${line}` : ""}`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === "remove" || subcommand === "rm" || subcommand === "delete") {
|
||||||
|
const name = parts[1];
|
||||||
|
if (!name) {
|
||||||
|
ctx.ui.notify("Usage: /bookmark remove <name>", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const before = bookmarks.length;
|
||||||
|
bookmarks = bookmarks.filter((b) => b.name !== name);
|
||||||
|
if (bookmarks.length < before) {
|
||||||
|
saveBookmarks(ctx.cwd);
|
||||||
|
ctx.ui.notify(`\u{1F516} Bookmark '${name}' removed.`, "info");
|
||||||
|
} else {
|
||||||
|
ctx.ui.notify(`Bookmark '${name}' not found.`, "warning");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify("Usage: /bookmark [add|list|remove] ...", "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("search", {
|
||||||
|
description: "Search bookmarks: /search <query>",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
loadBookmarks(ctx.cwd);
|
||||||
|
const query = args.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
ctx.ui.notify("Usage: /search <query>", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const results = bookmarks.filter(
|
||||||
|
(b) =>
|
||||||
|
b.name.toLowerCase().includes(query) ||
|
||||||
|
b.path.toLowerCase().includes(query) ||
|
||||||
|
(b.note && b.note.toLowerCase().includes(query)),
|
||||||
|
);
|
||||||
|
if (results.length === 0) {
|
||||||
|
ctx.ui.notify(`No bookmarks matching '${query}'.`, "info");
|
||||||
|
} else {
|
||||||
|
const list = results
|
||||||
|
.map((b) => ` - ${b.name} -> ${b.path}${b.line ? `:${b.line}` : ""}${b.note ? ` (${b.note})` : ""}`)
|
||||||
|
.join("\n");
|
||||||
|
ctx.ui.notify(`\u{1F50D} Search results for '${query}':\n${list}`, "info");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
126
default-extensions/checkpoints.ts
Normal file
126
default-extensions/checkpoints.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
let checkpointCount = 0;
|
||||||
|
|
||||||
|
async function createCheckpoint(ctx: any, label: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Check if in git repo
|
||||||
|
const statusResult = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd });
|
||||||
|
if (statusResult.code !== 0) return false;
|
||||||
|
|
||||||
|
// Only checkpoint if there are changes
|
||||||
|
if (!statusResult.stdout.trim()) return false;
|
||||||
|
|
||||||
|
// Stage all and create stash
|
||||||
|
await pi.exec("git", ["add", "-A"], { cwd: ctx.cwd });
|
||||||
|
checkpointCount++;
|
||||||
|
const tag = `jae-checkpoint-${checkpointCount}`;
|
||||||
|
const result = await pi.exec(
|
||||||
|
"git",
|
||||||
|
["stash", "push", "-m", `${tag}: ${label}`, "--include-untracked"],
|
||||||
|
{ cwd: ctx.cwd },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.code === 0 && !result.stdout.includes("No local changes")) {
|
||||||
|
// Immediately pop to restore working state - the stash entry remains in reflog
|
||||||
|
await pi.exec("git", ["stash", "pop"], { cwd: ctx.cwd });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-checkpoint on file mutations
|
||||||
|
pi.on("tool_execution_start", async (event, ctx) => {
|
||||||
|
const mutatingTools = ["write", "edit"];
|
||||||
|
if (mutatingTools.includes(event.toolName)) {
|
||||||
|
await createCheckpoint(ctx, `before ${event.toolName}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also checkpoint on bash commands that might modify files
|
||||||
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
|
if (event.toolName === "bash") {
|
||||||
|
const cmd = (event.input as any)?.command || "";
|
||||||
|
const dangerousPatterns = ["rm ", "mv ", "cp ", "sed ", "chmod ", "chown ", "truncate", "dd "];
|
||||||
|
if (dangerousPatterns.some((p) => cmd.includes(p))) {
|
||||||
|
await createCheckpoint(ctx, `before bash: ${cmd.substring(0, 50)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("undo", {
|
||||||
|
description: "Undo last file change by restoring from git stash checkpoint",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
try {
|
||||||
|
// List stashes to find jae checkpoints
|
||||||
|
const listResult = await pi.exec("git", ["stash", "list"], { cwd: ctx.cwd });
|
||||||
|
if (listResult.code !== 0) {
|
||||||
|
ctx.ui.notify("Not in a git repository.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stashes = listResult.stdout.trim().split("\n").filter(Boolean);
|
||||||
|
const jaeStashes = stashes.filter((s) => s.includes("jae-checkpoint-"));
|
||||||
|
|
||||||
|
if (jaeStashes.length === 0) {
|
||||||
|
ctx.ui.notify("No JAE checkpoints available to undo.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show what we're undoing
|
||||||
|
const latest = jaeStashes[0];
|
||||||
|
const confirmed = await ctx.ui.confirm(
|
||||||
|
"Undo Checkpoint",
|
||||||
|
`Restore to: ${latest}\n\nThis will discard current changes and restore the checkpoint.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
ctx.ui.notify("Undo cancelled.", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard reset and apply stash
|
||||||
|
const stashRef = latest.split(":")[0]; // e.g., "stash@{0}"
|
||||||
|
await pi.exec("git", ["checkout", "."], { cwd: ctx.cwd });
|
||||||
|
await pi.exec("git", ["clean", "-fd"], { cwd: ctx.cwd });
|
||||||
|
const applyResult = await pi.exec("git", ["stash", "apply", stashRef], { cwd: ctx.cwd });
|
||||||
|
|
||||||
|
if (applyResult.code === 0) {
|
||||||
|
ctx.ui.notify(`\u23EA Restored checkpoint: ${latest}`, "info");
|
||||||
|
} else {
|
||||||
|
ctx.ui.notify(`Failed to apply stash: ${applyResult.stderr}`, "error");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
ctx.ui.notify(`Undo error: ${err}`, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("checkpoints", {
|
||||||
|
description: "List available JAE checkpoints",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
const listResult = await pi.exec("git", ["stash", "list"], { cwd: ctx.cwd });
|
||||||
|
if (listResult.code !== 0) {
|
||||||
|
ctx.ui.notify("Not in a git repository.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stashes = listResult.stdout.trim().split("\n").filter(Boolean);
|
||||||
|
const jaeStashes = stashes.filter((s) => s.includes("jae-checkpoint-"));
|
||||||
|
|
||||||
|
if (jaeStashes.length === 0) {
|
||||||
|
ctx.ui.notify("No JAE checkpoints found.", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u{1F4CC} JAE Checkpoints (${jaeStashes.length}):\n${jaeStashes.map((s) => ` ${s}`).join("\n")}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
104
default-extensions/cost-tracker.ts
Normal file
104
default-extensions/cost-tracker.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import type { ExtensionAPI, ExtensionContext } from "@jaeswift/jae-coding-agent";
|
||||||
|
|
||||||
|
interface CostState {
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
cacheReadTokens: number;
|
||||||
|
cacheWriteTokens: number;
|
||||||
|
totalCost: number;
|
||||||
|
budgetLimit: number | null;
|
||||||
|
turns: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COST_PER_1K_INPUT = 0.003;
|
||||||
|
const COST_PER_1K_OUTPUT = 0.015;
|
||||||
|
const COST_PER_1K_CACHE_READ = 0.0003;
|
||||||
|
const COST_PER_1K_CACHE_WRITE = 0.00375;
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
const state: CostState = {
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
budgetLimit: null,
|
||||||
|
turns: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function recalcCost() {
|
||||||
|
state.totalCost =
|
||||||
|
(state.inputTokens / 1000) * COST_PER_1K_INPUT +
|
||||||
|
(state.outputTokens / 1000) * COST_PER_1K_OUTPUT +
|
||||||
|
(state.cacheReadTokens / 1000) * COST_PER_1K_CACHE_READ +
|
||||||
|
(state.cacheWriteTokens / 1000) * COST_PER_1K_CACHE_WRITE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(ctx: ExtensionContext) {
|
||||||
|
const cost = state.totalCost.toFixed(4);
|
||||||
|
const budget = state.budgetLimit ? ` / $${state.budgetLimit.toFixed(2)}` : "";
|
||||||
|
const tokens = ((state.inputTokens + state.outputTokens) / 1000).toFixed(1);
|
||||||
|
ctx.ui.setStatus("cost-tracker", `\u{1F4B0} $${cost}${budget} | ${tokens}k tok | ${state.turns} turns`);
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.on("turn_start", async (_event, ctx) => {
|
||||||
|
state.turns++;
|
||||||
|
updateStatus(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("agent_end", async (event, ctx) => {
|
||||||
|
for (const message of event.messages) {
|
||||||
|
if (message && typeof message === "object" && "usage" in message) {
|
||||||
|
const u = (message as any).usage;
|
||||||
|
state.inputTokens += u.input || 0;
|
||||||
|
state.outputTokens += u.output || 0;
|
||||||
|
state.cacheReadTokens += u.cacheRead || 0;
|
||||||
|
state.cacheWriteTokens += u.cacheWrite || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recalcCost();
|
||||||
|
updateStatus(ctx);
|
||||||
|
|
||||||
|
if (state.budgetLimit && state.totalCost >= state.budgetLimit) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u26A0\uFE0F Budget limit of $${state.budgetLimit} reached! Total: $${state.totalCost.toFixed(4)}`,
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("cost", {
|
||||||
|
description: "Show session cost breakdown",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u{1F4CA} Cost Report:\n` +
|
||||||
|
` Input: ${state.inputTokens.toLocaleString()} tokens ($${((state.inputTokens / 1000) * COST_PER_1K_INPUT).toFixed(4)})\n` +
|
||||||
|
` Output: ${state.outputTokens.toLocaleString()} tokens ($${((state.outputTokens / 1000) * COST_PER_1K_OUTPUT).toFixed(4)})\n` +
|
||||||
|
` Cache Read: ${state.cacheReadTokens.toLocaleString()} tokens\n` +
|
||||||
|
` Cache Write: ${state.cacheWriteTokens.toLocaleString()} tokens\n` +
|
||||||
|
` Total Cost: $${state.totalCost.toFixed(4)}\n` +
|
||||||
|
` Turns: ${state.turns}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("budget", {
|
||||||
|
description: "Set session budget limit: /budget <amount>",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const amount = parseFloat(args || "");
|
||||||
|
if (isNaN(amount) || amount <= 0) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
state.budgetLimit
|
||||||
|
? `Current budget: $${state.budgetLimit.toFixed(2)} | Spent: $${state.totalCost.toFixed(4)}`
|
||||||
|
: "No budget set. Usage: /budget 5.00",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
state.budgetLimit = amount;
|
||||||
|
ctx.ui.notify(`\u{1F4B0} Budget set to $${amount.toFixed(2)}`, "info");
|
||||||
|
updateStatus(ctx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
109
default-extensions/dashboard.ts
Normal file
109
default-extensions/dashboard.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
||||||
|
|
||||||
|
interface DashboardState {
|
||||||
|
gitBranch: string;
|
||||||
|
modelName: string;
|
||||||
|
sessionTokens: number;
|
||||||
|
filesModified: Set<string>;
|
||||||
|
toolCalls: number;
|
||||||
|
startTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
const state: DashboardState = {
|
||||||
|
gitBranch: "",
|
||||||
|
modelName: "",
|
||||||
|
sessionTokens: 0,
|
||||||
|
filesModified: new Set(),
|
||||||
|
toolCalls: 0,
|
||||||
|
startTime: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatUptime(): string {
|
||||||
|
const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
|
||||||
|
const mins = Math.floor(elapsed / 60);
|
||||||
|
const secs = elapsed % 60;
|
||||||
|
return mins > 0 ? `${mins}m${secs}s` : `${secs}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDashboard(ctx: any): void {
|
||||||
|
const tokens = (state.sessionTokens / 1000).toFixed(1);
|
||||||
|
const branch = state.gitBranch || "n/a";
|
||||||
|
const model = state.modelName || "unknown";
|
||||||
|
const files = state.filesModified.size;
|
||||||
|
const tools = state.toolCalls;
|
||||||
|
const uptime = formatUptime();
|
||||||
|
|
||||||
|
ctx.ui.setFooter(
|
||||||
|
"dashboard",
|
||||||
|
`\u{1F4CA} ${model} | \u{1F333} ${branch} | ${tokens}k tok | ${files} files | ${tools} tools | ${uptime}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
state.startTime = Date.now();
|
||||||
|
|
||||||
|
// Detect git branch
|
||||||
|
try {
|
||||||
|
const result = await pi.exec("git", ["branch", "--show-current"], { cwd: ctx.cwd });
|
||||||
|
if (result.code === 0 && result.stdout.trim()) {
|
||||||
|
state.gitBranch = result.stdout.trim();
|
||||||
|
}
|
||||||
|
} catch { /* not a git repo */ }
|
||||||
|
|
||||||
|
// Get model name
|
||||||
|
if (ctx.model) {
|
||||||
|
state.modelName = ctx.model.name || ctx.model.id || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDashboard(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("message_end", async (event, ctx) => {
|
||||||
|
if (event.message?.usage) {
|
||||||
|
const u = event.message.usage;
|
||||||
|
state.sessionTokens += (u.inputTokens || 0) + (u.outputTokens || 0);
|
||||||
|
}
|
||||||
|
if (ctx.model) {
|
||||||
|
state.modelName = ctx.model.name || ctx.model.id || state.modelName;
|
||||||
|
}
|
||||||
|
updateDashboard(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_execution_end", async (event, ctx) => {
|
||||||
|
state.toolCalls++;
|
||||||
|
|
||||||
|
// Track file modifications
|
||||||
|
if (["write", "edit", "write_file", "edit_file"].includes(event.toolName)) {
|
||||||
|
const filePath = (event.input as any)?.path || (event.input as any)?.file_path || "";
|
||||||
|
if (filePath) state.filesModified.add(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh git branch (might have changed)
|
||||||
|
try {
|
||||||
|
const result = await pi.exec("git", ["branch", "--show-current"], { cwd: ctx.cwd });
|
||||||
|
if (result.code === 0 && result.stdout.trim()) {
|
||||||
|
state.gitBranch = result.stdout.trim();
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
updateDashboard(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("dashboard", {
|
||||||
|
description: "Show session dashboard stats",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
const uptime = formatUptime();
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u{1F4CA} Session Dashboard:
|
||||||
|
Model: ${state.modelName || "unknown"}
|
||||||
|
Git Branch: ${state.gitBranch || "n/a"}
|
||||||
|
Session Tokens: ${state.sessionTokens.toLocaleString()}
|
||||||
|
Files Modified: ${state.filesModified.size} (${Array.from(state.filesModified).slice(0, 5).join(", ")}${state.filesModified.size > 5 ? "..." : ""})
|
||||||
|
Tool Calls: ${state.toolCalls}
|
||||||
|
Uptime: ${uptime}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
134
default-extensions/deploy.ts
Normal file
134
default-extensions/deploy.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { join, basename } from "node:path";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
type DeployTarget = "docker" | "vercel" | "rsync" | "static" | "unknown";
|
||||||
|
|
||||||
|
interface StackInfo {
|
||||||
|
type: DeployTarget;
|
||||||
|
runtime: string;
|
||||||
|
entrypoint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
function detectStack(cwd: string): StackInfo {
|
||||||
|
if (existsSync(join(cwd, "Dockerfile"))) {
|
||||||
|
return { type: "docker", runtime: "Docker", entrypoint: "Dockerfile" };
|
||||||
|
}
|
||||||
|
if (existsSync(join(cwd, "docker-compose.yml"))) {
|
||||||
|
return { type: "docker", runtime: "Docker Compose", entrypoint: "docker-compose.yml" };
|
||||||
|
}
|
||||||
|
if (existsSync(join(cwd, "vercel.json"))) {
|
||||||
|
return { type: "vercel", runtime: "Vercel" };
|
||||||
|
}
|
||||||
|
if (existsSync(join(cwd, "package.json"))) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
|
||||||
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
||||||
|
if (deps["next"]) return { type: "vercel", runtime: "Next.js" };
|
||||||
|
if (deps["vite"] || deps["react-scripts"]) return { type: "static", runtime: "SPA (Vite/CRA)" };
|
||||||
|
if (pkg.scripts?.start) return { type: "docker", runtime: "Node.js" };
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return { type: "docker", runtime: "Node.js" };
|
||||||
|
}
|
||||||
|
if (existsSync(join(cwd, "requirements.txt")) || existsSync(join(cwd, "pyproject.toml"))) {
|
||||||
|
return { type: "docker", runtime: "Python" };
|
||||||
|
}
|
||||||
|
if (existsSync(join(cwd, "index.html"))) {
|
||||||
|
return { type: "static", runtime: "Static HTML" };
|
||||||
|
}
|
||||||
|
return { type: "unknown", runtime: "Unknown" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "deploy",
|
||||||
|
label: "Deploy Project",
|
||||||
|
description: "Detect project stack and deploy. Supports Docker, Vercel, rsync, and static sites.",
|
||||||
|
parameters: Type.Object({
|
||||||
|
target: Type.Optional(Type.String({ description: "Deploy target: docker, vercel, rsync, static (auto-detected if omitted)" })),
|
||||||
|
host: Type.Optional(Type.String({ description: "Remote host for rsync deploy (user@host:/path)" })),
|
||||||
|
tag: Type.Optional(Type.String({ description: "Docker image tag" })),
|
||||||
|
}),
|
||||||
|
execute: async (args, ctx) => {
|
||||||
|
const stack = detectStack(ctx.cwd);
|
||||||
|
const deployType = (args.target as DeployTarget) || stack.type;
|
||||||
|
|
||||||
|
if (deployType === "unknown") {
|
||||||
|
return { error: "Could not detect project stack. Specify a target: docker, vercel, rsync, static" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: string[] = [`Detected: ${stack.runtime}`, `Deploy target: ${deployType}`];
|
||||||
|
|
||||||
|
if (deployType === "docker") {
|
||||||
|
const tag = args.tag || basename(ctx.cwd).toLowerCase();
|
||||||
|
const buildResult = await pi.exec("docker", ["build", "-t", tag, "."], { cwd: ctx.cwd });
|
||||||
|
if (buildResult.code !== 0) {
|
||||||
|
return { error: `Docker build failed: ${buildResult.stderr}` };
|
||||||
|
}
|
||||||
|
results.push(`Docker image built: ${tag}`);
|
||||||
|
results.push(`Run with: docker run -d -p 3000:3000 ${tag}`);
|
||||||
|
} else if (deployType === "vercel") {
|
||||||
|
const vercelResult = await pi.exec("npx", ["vercel", "--yes"], { cwd: ctx.cwd });
|
||||||
|
results.push(vercelResult.code === 0 ? `Vercel deployed: ${vercelResult.stdout.trim()}` : `Vercel error: ${vercelResult.stderr}`);
|
||||||
|
} else if (deployType === "rsync") {
|
||||||
|
if (!args.host) {
|
||||||
|
return { error: "rsync deploy requires --host argument (user@host:/path)" };
|
||||||
|
}
|
||||||
|
const rsyncResult = await pi.exec(
|
||||||
|
"rsync",
|
||||||
|
["-avz", "--exclude", "node_modules", "--exclude", ".git", "./", args.host],
|
||||||
|
{ cwd: ctx.cwd },
|
||||||
|
);
|
||||||
|
results.push(rsyncResult.code === 0 ? `Synced to ${args.host}` : `rsync error: ${rsyncResult.stderr}`);
|
||||||
|
} else if (deployType === "static") {
|
||||||
|
// Build if needed
|
||||||
|
const pkgPath = join(ctx.cwd, "package.json");
|
||||||
|
if (existsSync(pkgPath)) {
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||||
|
if (pkg.scripts?.build) {
|
||||||
|
const buildResult = await pi.exec("npm", ["run", "build"], { cwd: ctx.cwd });
|
||||||
|
results.push(buildResult.code === 0 ? "Build succeeded" : `Build error: ${buildResult.stderr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const distDir = existsSync(join(ctx.cwd, "dist")) ? "dist" : existsSync(join(ctx.cwd, "build")) ? "build" : ".";
|
||||||
|
results.push(`Static files in: ${distDir}/`);
|
||||||
|
results.push(`Serve locally: npx serve ${distDir}`);
|
||||||
|
if (args.host) {
|
||||||
|
const rsyncResult = await pi.exec("rsync", ["-avz", `${distDir}/`, args.host], { cwd: ctx.cwd });
|
||||||
|
results.push(rsyncResult.code === 0 ? `Deployed to ${args.host}` : `rsync error: ${rsyncResult.stderr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { output: results.join("\n") };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("deploy", {
|
||||||
|
description: "Deploy project: /deploy [docker|vercel|rsync|static] [--host user@host:/path] [--tag image-tag]",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const stack = detectStack(ctx.cwd);
|
||||||
|
const parts = args.trim().split(/\s+/);
|
||||||
|
const target = parts[0] || undefined;
|
||||||
|
|
||||||
|
let host: string | undefined;
|
||||||
|
let tag: string | undefined;
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
if (parts[i] === "--host" && parts[i + 1]) host = parts[i + 1];
|
||||||
|
if (parts[i] === "--tag" && parts[i + 1]) tag = parts[i + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u{1F680} Stack detected: ${stack.runtime} (${stack.type})\n\nUsage: /deploy [target] [options]\n Targets: docker, vercel, rsync, static\n Options: --host user@host:/path --tag image-name`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify(`\u{1F680} Deploying as ${target}...`, "info");
|
||||||
|
// Delegate to the tool
|
||||||
|
ctx.ui.notify(`Use the deploy tool for full deployment. Target: ${target}, Host: ${host || "none"}, Tag: ${tag || "auto"}`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
48
default-extensions/jae-rules.ts
Normal file
48
default-extensions/jae-rules.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
||||||
|
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.on("before_agent_start", async (_event, ctx) => {
|
||||||
|
const rulesPath = join(ctx.cwd, ".jae", "rules.md");
|
||||||
|
if (existsSync(rulesPath)) {
|
||||||
|
const rules = readFileSync(rulesPath, "utf-8").trim();
|
||||||
|
if (rules.length > 0) {
|
||||||
|
return { systemPrompt: `\n\n# Project Rules (.jae/rules.md)\n\n${rules}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("rules", {
|
||||||
|
description: "Show, create, or edit project rules (.jae/rules.md)",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const jaeDir = join(ctx.cwd, ".jae");
|
||||||
|
const rulesPath = join(jaeDir, "rules.md");
|
||||||
|
|
||||||
|
if (args.trim() === "init") {
|
||||||
|
if (!existsSync(jaeDir)) mkdirSync(jaeDir, { recursive: true });
|
||||||
|
if (!existsSync(rulesPath)) {
|
||||||
|
writeFileSync(
|
||||||
|
rulesPath,
|
||||||
|
"# Project Rules\n\nAdd your project-specific rules here.\nThese will be injected into JAE's system prompt.\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
ctx.ui.notify("Created .jae/rules.md - edit it to set project rules.", "info");
|
||||||
|
} else {
|
||||||
|
ctx.ui.notify(".jae/rules.md already exists.", "info");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(rulesPath)) {
|
||||||
|
const rules = readFileSync(rulesPath, "utf-8");
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u{1F4CB} Project Rules (${rules.length} chars):\n${rules.substring(0, 500)}${rules.length > 500 ? "..." : ""}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ctx.ui.notify("No .jae/rules.md found. Run /rules init to create one.", "warning");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
112
default-extensions/pair-programming.ts
Normal file
112
default-extensions/pair-programming.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
||||||
|
import { watch, type FSWatcher, readFileSync } from "node:fs";
|
||||||
|
import { join, relative, extname } from "node:path";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
let watcher: FSWatcher | null = null;
|
||||||
|
let pairMode = false;
|
||||||
|
let watchedFiles = new Set<string>();
|
||||||
|
let lastChangeTime = 0;
|
||||||
|
const DEBOUNCE_MS = 2000;
|
||||||
|
|
||||||
|
const CODE_EXTENSIONS = new Set([
|
||||||
|
".ts", ".tsx", ".js", ".jsx", ".py", ".rs", ".go",
|
||||||
|
".java", ".c", ".cpp", ".h", ".css", ".scss",
|
||||||
|
".html", ".vue", ".svelte", ".rb", ".php",
|
||||||
|
".json", ".yaml", ".yml", ".toml", ".md",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const IGNORE_PATTERNS = [
|
||||||
|
"node_modules", ".git", "dist", "build", ".jae",
|
||||||
|
".next", ".cache", "__pycache__", "target",
|
||||||
|
];
|
||||||
|
|
||||||
|
function shouldWatch(filepath: string): boolean {
|
||||||
|
if (IGNORE_PATTERNS.some((p) => filepath.includes(p))) return false;
|
||||||
|
return CODE_EXTENSIONS.has(extname(filepath));
|
||||||
|
}
|
||||||
|
|
||||||
|
function startWatching(cwd: string, ctx: any): void {
|
||||||
|
if (watcher) {
|
||||||
|
watcher.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
watcher = watch(cwd, { recursive: true }, (eventType, filename) => {
|
||||||
|
if (!filename || !pairMode) return;
|
||||||
|
const filepath = join(cwd, filename);
|
||||||
|
if (!shouldWatch(filepath)) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastChangeTime < DEBOUNCE_MS) return;
|
||||||
|
lastChangeTime = now;
|
||||||
|
|
||||||
|
const relPath = relative(cwd, filepath);
|
||||||
|
watchedFiles.add(relPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(filepath, "utf-8");
|
||||||
|
const lines = content.split("\n").length;
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u{1F4DD} File changed: ${relPath} (${lines} lines, ${eventType})\nJAE is watching in pair mode.`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
ctx.ui.notify(`\u{1F4DD} File ${eventType}: ${relPath}`, "info");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u{1F91D} Pair programming mode started!\nWatching: ${cwd}\nJAE will observe your changes and provide feedback.`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.ui.notify(`Failed to start file watcher: ${err}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopWatching(ctx: any): void {
|
||||||
|
if (watcher) {
|
||||||
|
watcher.close();
|
||||||
|
watcher = null;
|
||||||
|
}
|
||||||
|
const filesCount = watchedFiles.size;
|
||||||
|
watchedFiles.clear();
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u{1F91D} Pair programming mode stopped. ${filesCount} files were modified during session.`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.on("session_end", async (_event, _ctx) => {
|
||||||
|
if (watcher) {
|
||||||
|
watcher.close();
|
||||||
|
watcher = null;
|
||||||
|
pairMode = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("before_agent_start", async (_event, _ctx) => {
|
||||||
|
if (pairMode && watchedFiles.size > 0) {
|
||||||
|
const filesList = Array.from(watchedFiles).slice(0, 10).join(", ");
|
||||||
|
return {
|
||||||
|
systemPrompt: `\n\n# Pair Programming Mode Active\nRecently modified files: ${filesList}\nProvide constructive feedback on changes. Suggest improvements, catch bugs, and explain patterns.\n`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("pair", {
|
||||||
|
description: "Toggle pair programming mode - JAE watches and comments on your file changes",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
pairMode = !pairMode;
|
||||||
|
|
||||||
|
if (pairMode) {
|
||||||
|
startWatching(ctx.cwd, ctx);
|
||||||
|
ctx.ui.setFooter("pair", "\u{1F91D} Pair Mode");
|
||||||
|
} else {
|
||||||
|
stopWatching(ctx);
|
||||||
|
ctx.ui.setFooter("pair", undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
112
default-extensions/pr-review.ts
Normal file
112
default-extensions/pr-review.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
async function reviewDiff(diff: string, ctx: any): Promise<string> {
|
||||||
|
if (!diff.trim()) return "No diff content to review.";
|
||||||
|
|
||||||
|
const lines = diff.split("\n");
|
||||||
|
const stats = {
|
||||||
|
filesChanged: 0,
|
||||||
|
additions: 0,
|
||||||
|
deletions: 0,
|
||||||
|
files: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("+++ b/")) {
|
||||||
|
stats.filesChanged++;
|
||||||
|
stats.files.push(line.replace("+++ b/", ""));
|
||||||
|
} else if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||||
|
stats.additions++;
|
||||||
|
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
||||||
|
stats.deletions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
`## PR Review Summary`,
|
||||||
|
``,
|
||||||
|
`**Files changed:** ${stats.filesChanged}`,
|
||||||
|
`**Additions:** +${stats.additions}`,
|
||||||
|
`**Deletions:** -${stats.deletions}`,
|
||||||
|
``,
|
||||||
|
`### Files:`,
|
||||||
|
...stats.files.map((f) => `- ${f}`),
|
||||||
|
``,
|
||||||
|
`### Diff Preview (first 200 lines):`,
|
||||||
|
"```diff",
|
||||||
|
lines.slice(0, 200).join("\n"),
|
||||||
|
"```",
|
||||||
|
``,
|
||||||
|
`> Full review requires LLM analysis. Send this diff as context to JAE for detailed review.`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "pr_review",
|
||||||
|
label: "PR Review",
|
||||||
|
description: "Review a pull request or git diff. Provide a branch name, PR URL, or commit range.",
|
||||||
|
parameters: Type.Object({
|
||||||
|
target: Type.String({ description: "Branch name, PR URL, or commit range (e.g., 'main..feature', 'origin/main')" }),
|
||||||
|
}),
|
||||||
|
execute: async (args, ctx) => {
|
||||||
|
const { target } = args;
|
||||||
|
let diff = "";
|
||||||
|
|
||||||
|
// Try as branch comparison
|
||||||
|
const diffResult = await pi.exec(
|
||||||
|
"git",
|
||||||
|
["diff", target],
|
||||||
|
{ cwd: ctx.cwd },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (diffResult.code === 0 && diffResult.stdout.trim()) {
|
||||||
|
diff = diffResult.stdout;
|
||||||
|
} else {
|
||||||
|
// Try as range
|
||||||
|
const rangeResult = await pi.exec(
|
||||||
|
"git",
|
||||||
|
["diff", `${target}`],
|
||||||
|
{ cwd: ctx.cwd },
|
||||||
|
);
|
||||||
|
if (rangeResult.code === 0) {
|
||||||
|
diff = rangeResult.stdout;
|
||||||
|
} else {
|
||||||
|
return { error: `Could not get diff for: ${target}. Error: ${diffResult.stderr || rangeResult.stderr}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const review = await reviewDiff(diff, ctx);
|
||||||
|
return { output: review };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("pr-review", {
|
||||||
|
description: "Review a PR or branch diff: /pr-review <branch|range>",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const target = args.trim();
|
||||||
|
if (!target) {
|
||||||
|
ctx.ui.notify("Usage: /pr-review <branch|commit-range>\nExamples:\n /pr-review main..feature\n /pr-review origin/main", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify(`\u{1F50D} Fetching diff for: ${target}...`, "info");
|
||||||
|
|
||||||
|
const diffResult = await pi.exec("git", ["diff", target], { cwd: ctx.cwd });
|
||||||
|
|
||||||
|
if (diffResult.code !== 0) {
|
||||||
|
ctx.ui.notify(`Failed to get diff: ${diffResult.stderr}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!diffResult.stdout.trim()) {
|
||||||
|
ctx.ui.notify("No differences found.", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const review = await reviewDiff(diffResult.stdout, ctx);
|
||||||
|
ctx.ui.notify(review, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
139
default-extensions/project-dna.ts
Normal file
139
default-extensions/project-dna.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
||||||
|
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.on("before_agent_start", async (_event, ctx) => {
|
||||||
|
const dnaPath = join(ctx.cwd, ".jae", "project-dna.md");
|
||||||
|
if (existsSync(dnaPath)) {
|
||||||
|
const dna = readFileSync(dnaPath, "utf-8").trim();
|
||||||
|
if (dna.length > 0) {
|
||||||
|
return { systemPrompt: `\n\n# Project DNA (auto-generated fingerprint)\n\n${dna}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
const dnaPath = join(ctx.cwd, ".jae", "project-dna.md");
|
||||||
|
if (!existsSync(dnaPath)) {
|
||||||
|
ctx.ui.notify("\u{1F9EC} No project DNA found. Run /dna to auto-analyze your project.", "info");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("dna", {
|
||||||
|
description: "Auto-analyze project and generate .jae/project-dna.md fingerprint",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const cwd = ctx.cwd;
|
||||||
|
const jaeDir = join(cwd, ".jae");
|
||||||
|
const dnaPath = join(jaeDir, "project-dna.md");
|
||||||
|
|
||||||
|
if (args.trim() === "show" && existsSync(dnaPath)) {
|
||||||
|
const dna = readFileSync(dnaPath, "utf-8");
|
||||||
|
ctx.ui.notify(`\u{1F9EC} Project DNA:\n${dna.substring(0, 1500)}`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify("\u{1F50D} Analyzing project...", "info");
|
||||||
|
|
||||||
|
const sections: string[] = ["# Project DNA\n"];
|
||||||
|
|
||||||
|
// Tech stack detection
|
||||||
|
const checks = [
|
||||||
|
{ file: "package.json", label: "Node.js" },
|
||||||
|
{ file: "Cargo.toml", label: "Rust" },
|
||||||
|
{ file: "go.mod", label: "Go" },
|
||||||
|
{ file: "pyproject.toml", label: "Python (pyproject)" },
|
||||||
|
{ file: "requirements.txt", label: "Python (pip)" },
|
||||||
|
{ file: "Gemfile", label: "Ruby" },
|
||||||
|
{ file: "pom.xml", label: "Java (Maven)" },
|
||||||
|
{ file: "build.gradle", label: "Java/Kotlin (Gradle)" },
|
||||||
|
{ file: "docker-compose.yml", label: "Docker Compose" },
|
||||||
|
{ file: "Dockerfile", label: "Docker" },
|
||||||
|
{ file: ".github/workflows", label: "GitHub Actions" },
|
||||||
|
{ file: "tsconfig.json", label: "TypeScript" },
|
||||||
|
{ file: ".eslintrc.json", label: "ESLint" },
|
||||||
|
{ file: "vitest.config.ts", label: "Vitest" },
|
||||||
|
{ file: "jest.config.js", label: "Jest" },
|
||||||
|
{ file: "tailwind.config.js", label: "Tailwind CSS" },
|
||||||
|
{ file: "next.config.js", label: "Next.js" },
|
||||||
|
{ file: "vite.config.ts", label: "Vite" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const detected: string[] = [];
|
||||||
|
for (const c of checks) {
|
||||||
|
if (existsSync(join(cwd, c.file))) detected.push(c.label);
|
||||||
|
}
|
||||||
|
sections.push(`## Tech Stack\n${detected.length > 0 ? detected.map((d) => `- ${d}`).join("\n") : "- Unknown"}\n`);
|
||||||
|
|
||||||
|
// Package.json details
|
||||||
|
const pkgPath = join(cwd, "package.json");
|
||||||
|
if (existsSync(pkgPath)) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||||
|
sections.push(
|
||||||
|
`## Package Info\n- Name: ${pkg.name || "unknown"}\n- Version: ${pkg.version || "unknown"}\n- Description: ${pkg.description || "none"}\n`,
|
||||||
|
);
|
||||||
|
if (pkg.dependencies) {
|
||||||
|
const deps = Object.keys(pkg.dependencies).slice(0, 20);
|
||||||
|
sections.push(`## Dependencies (top 20)\n${deps.map((d) => `- ${d}`).join("\n")}\n`);
|
||||||
|
}
|
||||||
|
if (pkg.scripts) {
|
||||||
|
const scripts = Object.entries(pkg.scripts).slice(0, 15) as [string, string][];
|
||||||
|
sections.push(`## Scripts\n${scripts.map(([k, v]) => `- \`${k}\`: ${v}`).join("\n")}\n`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore parse errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Git info
|
||||||
|
try {
|
||||||
|
const gitResult = await pi.exec("git", ["log", "--oneline", "-10"], { cwd });
|
||||||
|
if (gitResult.code === 0 && gitResult.stdout.trim()) {
|
||||||
|
sections.push(`## Recent Git History\n\`\`\`\n${gitResult.stdout.trim()}\n\`\`\`\n`);
|
||||||
|
}
|
||||||
|
const branchResult = await pi.exec("git", ["branch", "--show-current"], { cwd });
|
||||||
|
if (branchResult.code === 0 && branchResult.stdout.trim()) {
|
||||||
|
sections.push(`## Current Branch: ${branchResult.stdout.trim()}\n`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not a git repo */
|
||||||
|
}
|
||||||
|
|
||||||
|
// File stats
|
||||||
|
try {
|
||||||
|
const findResult = await pi.exec(
|
||||||
|
"find",
|
||||||
|
[".", "-maxdepth", "3", "-type", "f", "-not", "-path", "*/node_modules/*", "-not", "-path", "*/.git/*", "-not", "-path", "*/dist/*", "-not", "-path", "*/.jae/*"],
|
||||||
|
{ cwd },
|
||||||
|
);
|
||||||
|
if (findResult.code === 0) {
|
||||||
|
const files = findResult.stdout.trim().split("\n").filter(Boolean);
|
||||||
|
sections.push(`## File Count: ${files.length}\n`);
|
||||||
|
const extCounts: Record<string, number> = {};
|
||||||
|
for (const f of files) {
|
||||||
|
const ext = f.includes(".") ? (f.split(".").pop() || "none") : "(no ext)";
|
||||||
|
extCounts[ext] = (extCounts[ext] || 0) + 1;
|
||||||
|
}
|
||||||
|
const sorted = Object.entries(extCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 15);
|
||||||
|
sections.push(`## File Types\n${sorted.map(([ext, count]) => `- .${ext}: ${count}`).join("\n")}\n`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push(`\n---\nGenerated: ${new Date().toISOString()}\n`);
|
||||||
|
|
||||||
|
const dnaContent = sections.join("\n");
|
||||||
|
if (!existsSync(jaeDir)) mkdirSync(jaeDir, { recursive: true });
|
||||||
|
writeFileSync(dnaPath, dnaContent, "utf-8");
|
||||||
|
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u{1F9EC} Project DNA generated (${dnaContent.length} chars). Saved to .jae/project-dna.md`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
127
default-extensions/replay.ts
Normal file
127
default-extensions/replay.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
interface ReplayEvent {
|
||||||
|
time: number;
|
||||||
|
type: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
let recording = false;
|
||||||
|
let events: ReplayEvent[] = [];
|
||||||
|
let startTime = 0;
|
||||||
|
|
||||||
|
pi.on("message_end", async (event, ctx) => {
|
||||||
|
if (!recording) return;
|
||||||
|
const msg = event.message;
|
||||||
|
if (!msg) return;
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
if ("content" in msg && typeof msg.content === "string") {
|
||||||
|
text = msg.content;
|
||||||
|
} else if ("content" in msg && Array.isArray(msg.content)) {
|
||||||
|
text = (msg.content as any[])
|
||||||
|
.filter((b: any) => b.type === "text")
|
||||||
|
.map((b: any) => b.text)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
events.push({
|
||||||
|
time: (Date.now() - startTime) / 1000,
|
||||||
|
type: (msg as any).role || "unknown",
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_execution_end", async (event, _ctx) => {
|
||||||
|
if (!recording) return;
|
||||||
|
const resultText =
|
||||||
|
typeof event.result === "string"
|
||||||
|
? event.result
|
||||||
|
: JSON.stringify(event.result).substring(0, 500);
|
||||||
|
events.push({
|
||||||
|
time: (Date.now() - startTime) / 1000,
|
||||||
|
type: `tool:${event.toolName}`,
|
||||||
|
text: resultText,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("replay", {
|
||||||
|
description: "Session replay: /replay start | /replay stop | /replay export [file.cast]",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const parts = args.trim().split(/\s+/);
|
||||||
|
const sub = parts[0] || "status";
|
||||||
|
|
||||||
|
if (sub === "start") {
|
||||||
|
if (recording) {
|
||||||
|
ctx.ui.notify("Already recording.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recording = true;
|
||||||
|
events = [];
|
||||||
|
startTime = Date.now();
|
||||||
|
ctx.ui.setStatus("replay", "\u{1F534} REC");
|
||||||
|
ctx.ui.notify("\u{1F3AC} Replay recording started.", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "stop") {
|
||||||
|
if (!recording) {
|
||||||
|
ctx.ui.notify("Not recording.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recording = false;
|
||||||
|
ctx.ui.setStatus("replay", undefined);
|
||||||
|
ctx.ui.notify(`\u{1F3AC} Recording stopped. ${events.length} events captured. Use /replay export to save.`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "export") {
|
||||||
|
if (events.length === 0) {
|
||||||
|
ctx.ui.notify("No events recorded. Start a recording first.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filename = parts[1] || `jae-replay-${Date.now()}.cast`;
|
||||||
|
const outPath = join(ctx.cwd, filename);
|
||||||
|
|
||||||
|
// asciicast v2 format
|
||||||
|
const header = JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
width: 120,
|
||||||
|
height: 40,
|
||||||
|
timestamp: Math.floor(startTime / 1000),
|
||||||
|
title: "JAE Session Replay",
|
||||||
|
env: { TERM: "xterm-256color" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = [header];
|
||||||
|
for (const ev of events) {
|
||||||
|
const prefix = ev.type === "assistant" ? "\x1b[36m" : ev.type.startsWith("tool:") ? "\x1b[33m" : "\x1b[37m";
|
||||||
|
const label = ev.type === "assistant" ? "JAE" : ev.type === "user" ? "YOU" : ev.type;
|
||||||
|
const displayText = `${prefix}[${label}]\x1b[0m ${ev.text}\r\n`;
|
||||||
|
lines.push(JSON.stringify([ev.time, "o", displayText]));
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(outPath, lines.join("\n") + "\n", "utf-8");
|
||||||
|
ctx.ui.notify(`\u{1F3AC} Exported ${events.length} events to ${outPath}\nPlay with: asciinema play ${filename}`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "status") {
|
||||||
|
ctx.ui.notify(
|
||||||
|
recording
|
||||||
|
? `\u{1F534} Recording in progress: ${events.length} events (${((Date.now() - startTime) / 1000).toFixed(0)}s)`
|
||||||
|
: `\u{1F3AC} Not recording. ${events.length} events in buffer.`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify("Usage: /replay start | stop | export [file.cast] | status", "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
47
default-extensions/screenshot-context.ts
Normal file
47
default-extensions/screenshot-context.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.on("before_agent_start", async (_event, _ctx) => {
|
||||||
|
return {
|
||||||
|
systemPrompt: `
|
||||||
|
# Screenshot & Image Analysis
|
||||||
|
|
||||||
|
When the user pastes or attaches an image (screenshot, diagram, mockup, error screenshot):
|
||||||
|
1. Analyze the visual content thoroughly
|
||||||
|
2. If it's a UI screenshot: describe layout, identify components, suggest improvements
|
||||||
|
3. If it's an error screenshot: read the error message, diagnose the issue, suggest fixes
|
||||||
|
4. If it's a design mockup: break down into implementable components
|
||||||
|
5. If it's a diagram: interpret the architecture/flow and relate to the codebase
|
||||||
|
6. If it's code in an image: transcribe it accurately and analyze
|
||||||
|
Always acknowledge what you see in the image before providing analysis.
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("input", async (event, ctx) => {
|
||||||
|
// Check if input contains image attachments
|
||||||
|
const input = event as any;
|
||||||
|
if (input.images && input.images.length > 0) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u{1F4F7} ${input.images.length} image(s) detected. JAE will analyze with vision capabilities.`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("screenshot", {
|
||||||
|
description: "Tips for using screenshot context with JAE",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u{1F4F7} Screenshot Context Tips:
|
||||||
|
|
||||||
|
- Paste screenshots directly into the chat
|
||||||
|
- JAE will automatically analyze images with vision
|
||||||
|
- Supported: UI screenshots, error messages, diagrams, mockups
|
||||||
|
- For best results, crop to the relevant area
|
||||||
|
- You can paste multiple images in one message`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
189
default-extensions/skill-marketplace.ts
Normal file
189
default-extensions/skill-marketplace.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
||||||
|
import { existsSync, writeFileSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
const SKILLS_API = "https://skills.jaeswift.xyz/api/skills";
|
||||||
|
|
||||||
|
interface SkillInfo {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
async function fetchJSON(url: string): Promise<any> {
|
||||||
|
const result = await pi.exec("curl", ["-s", "-f", url]);
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(`HTTP request failed: ${result.stderr}`);
|
||||||
|
}
|
||||||
|
return JSON.parse(result.stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSkillsDir(ctx: any): string {
|
||||||
|
const dir = join(ctx.agentDir, "skills");
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listLocalSkills(ctx: any): string[] {
|
||||||
|
const dir = getSkillsDir(ctx);
|
||||||
|
try {
|
||||||
|
return readdirSync(dir).filter((f) => f.endsWith(".md") || existsSync(join(dir, f, "SKILL.md")));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /search-skills command
|
||||||
|
pi.registerCommand("search-skills", {
|
||||||
|
description: "Search the JAE skill marketplace: /search-skills <query>",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const query = args.trim();
|
||||||
|
if (!query) {
|
||||||
|
ctx.ui.notify("Usage: /search-skills <query>", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify(`\u{1F50D} Searching skills for: ${query}...`, "info");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const skills = await fetchJSON(`${SKILLS_API}/search?q=${encodeURIComponent(query)}`);
|
||||||
|
if (!skills || skills.length === 0) {
|
||||||
|
ctx.ui.notify(`No skills found matching '${query}'.`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = skills
|
||||||
|
.map((s: SkillInfo) => ` - ${s.name} (v${s.version}) by ${s.author}\n ${s.description}`)
|
||||||
|
.join("\n");
|
||||||
|
ctx.ui.notify(`\u{1F3EA} Skills matching '${query}':\n${list}`, "info");
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.ui.notify(`Failed to search skills: ${err.message}\nAPI: ${SKILLS_API}`, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// /install-skill command
|
||||||
|
pi.registerCommand("install-skill", {
|
||||||
|
description: "Install a skill from the marketplace: /install-skill <name>",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const name = args.trim();
|
||||||
|
if (!name) {
|
||||||
|
ctx.ui.notify("Usage: /install-skill <name>", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify(`\u{1F4E6} Installing skill: ${name}...`, "info");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const skill = await fetchJSON(`${SKILLS_API}/${encodeURIComponent(name)}`);
|
||||||
|
if (!skill || !skill.content) {
|
||||||
|
ctx.ui.notify(`Skill '${name}' not found or has no content.`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillsDir = getSkillsDir(ctx);
|
||||||
|
const skillDir = join(skillsDir, name);
|
||||||
|
if (!existsSync(skillDir)) mkdirSync(skillDir, { recursive: true });
|
||||||
|
|
||||||
|
writeFileSync(join(skillDir, "SKILL.md"), skill.content, "utf-8");
|
||||||
|
|
||||||
|
// Write any additional files
|
||||||
|
if (skill.files && typeof skill.files === "object") {
|
||||||
|
for (const [filename, content] of Object.entries(skill.files)) {
|
||||||
|
writeFileSync(join(skillDir, filename), content as string, "utf-8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u2705 Skill '${name}' installed to ${skillDir}\nRestart JAE to activate.`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.ui.notify(`Failed to install skill: ${err.message}`, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// /publish-skill command
|
||||||
|
pi.registerCommand("publish-skill", {
|
||||||
|
description: "Publish a local skill to the marketplace: /publish-skill <name>",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const name = args.trim();
|
||||||
|
if (!name) {
|
||||||
|
ctx.ui.notify("Usage: /publish-skill <skill-dir-name>", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillsDir = getSkillsDir(ctx);
|
||||||
|
const skillDir = join(skillsDir, name);
|
||||||
|
const skillMd = join(skillDir, "SKILL.md");
|
||||||
|
|
||||||
|
if (!existsSync(skillMd)) {
|
||||||
|
ctx.ui.notify(`Skill '${name}' not found locally at ${skillDir}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = readFileSync(skillMd, "utf-8");
|
||||||
|
|
||||||
|
// Collect extra files
|
||||||
|
const files: Record<string, string> = {};
|
||||||
|
try {
|
||||||
|
for (const f of readdirSync(skillDir)) {
|
||||||
|
if (f !== "SKILL.md") {
|
||||||
|
files[f] = readFileSync(join(skillDir, f), "utf-8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
ctx.ui.notify(`\u{1F4E4} Publishing skill: ${name}...`, "info");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.stringify({ name, content, files });
|
||||||
|
const result = await pi.exec(
|
||||||
|
"curl",
|
||||||
|
["-s", "-f", "-X", "POST", "-H", "Content-Type: application/json", "-d", payload, SKILLS_API],
|
||||||
|
);
|
||||||
|
if (result.code === 0) {
|
||||||
|
ctx.ui.notify(`\u2705 Skill '${name}' published to marketplace!`, "info");
|
||||||
|
} else {
|
||||||
|
ctx.ui.notify(`Publish failed: ${result.stderr}`, "error");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.ui.notify(`Publish error: ${err.message}`, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// /skills command - list local skills
|
||||||
|
pi.registerCommand("skills", {
|
||||||
|
description: "List locally installed skills",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
const local = listLocalSkills(ctx);
|
||||||
|
if (local.length === 0) {
|
||||||
|
ctx.ui.notify("No skills installed. Use /search-skills and /install-skill to get started.", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.ui.notify(`\u{1F9E0} Installed Skills (${local.length}):\n${local.map((s) => ` - ${s}`).join("\n")}`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// LLM tool for skill search
|
||||||
|
pi.registerTool({
|
||||||
|
name: "search_skills",
|
||||||
|
label: "Search Skills",
|
||||||
|
description: "Search the JAE skill marketplace for installable skills",
|
||||||
|
parameters: Type.Object({
|
||||||
|
query: Type.String({ description: "Search query for skills" }),
|
||||||
|
}),
|
||||||
|
execute: async (args, _ctx) => {
|
||||||
|
try {
|
||||||
|
const skills = await fetchJSON(`${SKILLS_API}/search?q=${encodeURIComponent(args.query)}`);
|
||||||
|
return { output: JSON.stringify(skills, null, 2) };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { error: `Skill search failed: ${err.message}` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
147
default-extensions/swarm.ts
Normal file
147
default-extensions/swarm.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
||||||
|
import { execSync, spawn } from "node:child_process";
|
||||||
|
import { writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
interface SwarmTask {
|
||||||
|
id: number;
|
||||||
|
description: string;
|
||||||
|
status: "pending" | "running" | "done" | "error";
|
||||||
|
result: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
let taskCounter = 0;
|
||||||
|
|
||||||
|
async function runSubtask(
|
||||||
|
description: string,
|
||||||
|
cwd: string,
|
||||||
|
): Promise<{ success: boolean; output: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timeout = 120_000; // 2 minutes max per subtask
|
||||||
|
let output = "";
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const child = spawn(
|
||||||
|
"jae",
|
||||||
|
["-p", description],
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
timeout,
|
||||||
|
env: { ...process.env },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
child.stdout?.on("data", (data: Buffer) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr?.on("data", (data: Buffer) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
resolve({ success: code === 0, output: output.trim() || "(no output)" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
resolve({ success: false, output: `Process error: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
try { child.kill("SIGTERM"); } catch {}
|
||||||
|
resolve({ success: false, output: output.trim() + "\n(timed out)" });
|
||||||
|
}
|
||||||
|
}, timeout);
|
||||||
|
} catch (err: any) {
|
||||||
|
resolve({ success: false, output: `Spawn error: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "swarm_task",
|
||||||
|
label: "Swarm Task",
|
||||||
|
description:
|
||||||
|
"Split a complex task into subtasks and run them in parallel using multiple JAE instances. Provide the main task and a list of subtask descriptions.",
|
||||||
|
parameters: Type.Object({
|
||||||
|
task: Type.String({ description: "Main task description" }),
|
||||||
|
subtasks: Type.Array(Type.String(), {
|
||||||
|
description: "List of subtask descriptions to run in parallel",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
execute: async (args, ctx) => {
|
||||||
|
const { task, subtasks } = args;
|
||||||
|
if (!subtasks || subtasks.length === 0) {
|
||||||
|
return { error: "No subtasks provided" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks: SwarmTask[] = subtasks.map((desc: string) => ({
|
||||||
|
id: ++taskCounter,
|
||||||
|
description: desc,
|
||||||
|
status: "pending" as const,
|
||||||
|
result: "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u{1F41D} Swarm: Launching ${tasks.length} subtasks for: ${task}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run all in parallel
|
||||||
|
const promises = tasks.map(async (t) => {
|
||||||
|
t.status = "running";
|
||||||
|
const res = await runSubtask(t.description, ctx.cwd);
|
||||||
|
t.status = res.success ? "done" : "error";
|
||||||
|
t.result = res.output;
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
const summary = results
|
||||||
|
.map(
|
||||||
|
(t) =>
|
||||||
|
`### Subtask #${t.id}: ${t.status === "done" ? "\u2705" : "\u274C"} ${t.description}\n${t.result.substring(0, 500)}`,
|
||||||
|
)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
const successCount = results.filter((t) => t.status === "done").length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
output: `# Swarm Results\n\nTask: ${task}\nCompleted: ${successCount}/${tasks.length}\n\n${summary}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("swarm", {
|
||||||
|
description:
|
||||||
|
"Run parallel subtasks: /swarm <task description> (JAE will decompose into subtasks)",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const task = args.trim();
|
||||||
|
if (!task) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
"Usage: /swarm <task description>\n\nJAE will decompose the task into parallel subtasks and run them simultaneously.",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u{1F41D} Swarm mode: Submit this task to JAE and it will use the swarm_task tool to parallelize it.\n\nTask: ${task}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
38
default-extensions/teach-mode.ts
Normal file
38
default-extensions/teach-mode.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
||||||
|
|
||||||
|
const TEACH_PROMPT = `
|
||||||
|
# Teach Mode Active
|
||||||
|
|
||||||
|
You are in TEACH MODE. For every action you take:
|
||||||
|
1. Explain WHY you are making this decision (not just what)
|
||||||
|
2. Describe alternative approaches you considered and why you rejected them
|
||||||
|
3. Point out patterns, best practices, and potential pitfalls
|
||||||
|
4. When writing code, add educational comments explaining non-obvious logic
|
||||||
|
5. After completing a task, provide a brief summary of what was learned
|
||||||
|
6. Use analogies and examples to clarify complex concepts
|
||||||
|
7. Highlight any trade-offs being made (performance vs readability, etc.)
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
let teachModeOn = false;
|
||||||
|
|
||||||
|
pi.on("before_agent_start", async (_event, _ctx) => {
|
||||||
|
if (teachModeOn) {
|
||||||
|
return { systemPrompt: TEACH_PROMPT };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("teach", {
|
||||||
|
description: "Toggle teach mode - JAE explains every decision in detail",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
teachModeOn = !teachModeOn;
|
||||||
|
ctx.ui.setStatus("teach-mode", teachModeOn ? "\u{1F393} Teach Mode" : undefined);
|
||||||
|
ctx.ui.notify(
|
||||||
|
teachModeOn
|
||||||
|
? "\u{1F393} Teach Mode ON - JAE will explain every decision in detail."
|
||||||
|
: "\u{1F393} Teach Mode OFF - Back to normal operation.",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
84
default-extensions/widget-api-demo.ts
Normal file
84
default-extensions/widget-api-demo.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
interface WidgetState {
|
||||||
|
activeTool: string;
|
||||||
|
lastToolDuration: number;
|
||||||
|
errorsCount: number;
|
||||||
|
warningsCount: number;
|
||||||
|
lastActivity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: WidgetState = {
|
||||||
|
activeTool: "none",
|
||||||
|
lastToolDuration: 0,
|
||||||
|
errorsCount: 0,
|
||||||
|
warningsCount: 0,
|
||||||
|
lastActivity: "idle",
|
||||||
|
};
|
||||||
|
|
||||||
|
let toolStartTime = 0;
|
||||||
|
|
||||||
|
function renderWidget(): string {
|
||||||
|
const lines = [
|
||||||
|
`\u{250C}\u{2500} JAE Status \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2510}`,
|
||||||
|
`\u{2502} Activity: ${state.lastActivity.padEnd(12)} \u{2502}`,
|
||||||
|
`\u{2502} Tool: ${state.activeTool.padEnd(12)} \u{2502}`,
|
||||||
|
`\u{2502} Last: ${(state.lastToolDuration + "ms").padEnd(12)} \u{2502}`,
|
||||||
|
`\u{2502} Errors: ${String(state.errorsCount).padEnd(12)} \u{2502}`,
|
||||||
|
`\u{2514}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2518}`,
|
||||||
|
];
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWidget(ctx: any): void {
|
||||||
|
ctx.ui.setFooter("widget-demo", `\u{1F4DF} ${state.lastActivity} | Tool: ${state.activeTool} | Errs: ${state.errorsCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.on("tool_execution_start", async (event, ctx) => {
|
||||||
|
toolStartTime = Date.now();
|
||||||
|
state.activeTool = event.toolName || "unknown";
|
||||||
|
state.lastActivity = "running";
|
||||||
|
updateWidget(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_execution_end", async (event, ctx) => {
|
||||||
|
state.lastToolDuration = Date.now() - toolStartTime;
|
||||||
|
state.activeTool = "none";
|
||||||
|
state.lastActivity = "idle";
|
||||||
|
|
||||||
|
// Check for errors in result
|
||||||
|
const result = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
|
||||||
|
if (result.toLowerCase().includes("error")) {
|
||||||
|
state.errorsCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWidget(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("message_end", async (event, ctx) => {
|
||||||
|
state.lastActivity = "thinking";
|
||||||
|
updateWidget(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("turn_start", async (_event, ctx) => {
|
||||||
|
state.lastActivity = "processing";
|
||||||
|
updateWidget(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("turn_end", async (_event, ctx) => {
|
||||||
|
state.lastActivity = "idle";
|
||||||
|
updateWidget(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("widget", {
|
||||||
|
description: "Show the JAE status widget demo",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
const widget = renderWidget();
|
||||||
|
ctx.ui.notify(
|
||||||
|
`\u{1F4DF} Widget API Demo:\n\n${widget}\n\nThis demonstrates how extensions can render live TUI widgets.\nThe footer bar shows real-time status updates.\n\nWidget State:\n Active Tool: ${state.activeTool}\n Last Duration: ${state.lastToolDuration}ms\n Errors: ${state.errorsCount}\n Activity: ${state.lastActivity}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue