diff --git a/.DS_Store b/.DS_Store index 54c80b0..dadc490 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/README.md b/README.md index 02dd875..23f5354 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # weave -**Graph-native memory CLI for AI agents.** Chat with AI that actually remembers — across sessions, across agents. +**Testing-native memory CLI for AI agents.** Run multi-step test workflows with persistent memory across sessions and specialized QA agents. + +> CLI binary name: `weave-test` (replace any older `weave` examples with `weave-test`). ``` ◈ weave v0.1.0 @@ -9,14 +11,15 @@ ## What is Weave? -Weave is a terminal CLI agent (like Claude Code) with a twist: **persistent, graph-structured memory**. Every conversation is remembered. Memories are connected by semantic similarity, temporal sequence, and shared entities. Your agents build knowledge over time — they never cold-start. +Weave is a terminal CLI agent (like Claude Code) focused on **software testing and quality improvement**. It combines **persistent graph memory** with **multi-agent QA roles** so each run gets smarter over time. -Built on the MemWeave memory architecture: +Built on the MemWeave memory architecture plus a test orchestration layer: - **Multi-layer memory graph** — semantic, temporal, causal, and entity edges - **Tiered memory** — working → short-term → long-term → archival with automatic promotion and decay -- **Multi-agent** — spawn multiple agents with different personas that share a memory fabric +- **Multi-agent** — testing personas (orchestrator, edge-case hunter, report analyst) share a memory fabric - **Hybrid retrieval** — vector similarity + graph traversal for smarter recall - **Local-first** — works with just hash-based embeddings + SQLite, no cloud required for memory +- **Testing pipeline** — command discovery (`lint`, `typecheck`, `test`, `integration`, `e2e`, `build`) with clear run reports ## Install @@ -43,8 +46,11 @@ weave init # Set your API key (or use Codex auth — see below) weave config set apiKey sk-your-openai-key -# Start chatting (memories persist automatically) -weave chat +# Initialize testing agents +weave test init + +# Run the testing pipeline on current project +weave test run # Or use Anthropic weave config set provider anthropic @@ -65,6 +71,83 @@ weave chat ## Commands +### Testing (Primary) + +```bash +weave test init # create test-focused agents +weave test run # discover and run tests in current dir +weave test plan # preview discovered + autonomous plan +weave test run --dir ../my-app # run against another project +weave test run --workspace release # keep separate test memory per workspace +weave test run --provider anthropic # use another model provider +weave test run --model gpt-4o-mini # choose specific model for insights +weave test run --max-auto 5 # add up to 5 autonomous expansions +weave test run --no-autonomous # run only discovered commands + +weave-test automation create --name "nightly smoke" --dir . --every 1d +weave-test automation remind "in 45 minutes" --name "rerun tests" --dir . +weave-test automation list +weave-test automation run +weave-test automation daemon # keep scheduler running locally +``` + +The testing workflow: +- discovers test commands from project scripts/runtime, +- optionally proposes additional safe commands using autonomous planning (`--max-auto > 0`), +- runs them as a multi-step pipeline, +- analyzes failures and edge-case gaps, +- persists run intelligence to memory for future sessions. + +### Automations + +`weave-test` now supports durable test automations: + +- `automation create` for recurring schedules via `--every` or `--cron` +- `automation remind` for one-time reminders/check-backs +- `automation loop` as a Claude-style recurring shortcut +- `automation daemon` to keep due automations running locally + +Examples: + +```bash +weave-test automation create --name "daily regression" --dir . --every 1d +weave-test automation create --name "weekday smoke" --dir . --cron "0 9 * * 1-5" +weave-test automation remind "in 2 hours" --name "rerun failed checks" --dir . +weave-test automation loop 30m --name "poll health" --dir . --target testPlan +weave-test automation list +weave-test automation pause +weave-test automation resume +weave-test automation run +weave-test automation daemon --poll-ms 10000 +``` + +### GitHub App Writes + +`weave-test` can now write to GitHub through a GitHub App installation identity instead of your local `git push`. + +Configure the app: + +```bash +weave-test github app init \ + --app-id 123456 \ + --private-key-path /path/to/github-app.pem \ + --owner your-org \ + --repo your-repo + +weave-test github app status +weave-test github repo connect --owner your-org --repo your-repo --save-defaults +``` + +Create a branch, commit local files to GitHub, and open a PR: + +```bash +weave-test github branch create --branch weavetest/demo --base main +weave-test github commit --branch weavetest/demo --message "Update test flow" --dir . src/index.ts README.md +weave-test github pr create --title "Update test flow" --head weavetest/demo --base main +``` + +`weave-test github push` is also available as a commit-and-update-ref alias if you prefer that wording. + ### Chat ```bash diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..87bd726 --- /dev/null +++ b/action.yml @@ -0,0 +1,41 @@ +name: "Weave Bot" +description: "AI-powered PR reviews, issue triage, and @weave mentions — powered by memweave" +author: "jayavibhavnk" + +inputs: + trigger_phrase: + description: "Phrase that triggers Weave in comments (e.g. @weave)" + required: false + default: "@weave" + model: + description: "LLM model to use (e.g. gpt-4o, claude-sonnet-4-20250514)" + required: false + provider: + description: "LLM provider (openai, anthropic)" + required: false + default: "openai" + +runs: + using: "composite" + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install memweave + shell: bash + run: npm install -g memweave@latest + + - name: Run Weave Action + shell: bash + run: weave-test action run + env: + GITHUB_TOKEN: ${{ github.token }} + WEAVE_TRIGGER_PHRASE: ${{ inputs.trigger_phrase }} + WEAVE_MODEL: ${{ inputs.model }} + WEAVE_PROVIDER: ${{ inputs.provider }} + +branding: + icon: "cpu" + color: "purple" diff --git a/package-lock.json b/package-lock.json index 4e46360..3c2b59b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "memweave", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "memweave", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/package.json b/package.json index 51247b9..591c920 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Graph-native memory CLI for AI agents — persistent, multi-agent, cross-session context", "type": "module", "bin": { - "weave": "./dist/index.js" + "weave-test": "./dist/index.js" }, "main": "./dist/index.js", "files": [ diff --git a/src/config.ts b/src/config.ts index 68b3364..7b1e25b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,35 +28,48 @@ export function getDefaultConfig(): WeaveConfig { embeddingDim: 256, defaultAgent: "assistant", workspacePath: path.join(WORKSPACES_DIR, "default.db"), + githubApiBaseUrl: "https://api.github.com", }; } export function loadConfig(): WeaveConfig { ensureConfigDir(); const defaults = getDefaultConfig(); + let config = defaults; if (fs.existsSync(CONFIG_FILE)) { try { const raw = fs.readFileSync(CONFIG_FILE, "utf-8"); const saved = JSON.parse(raw); - return { ...defaults, ...saved }; + config = { ...defaults, ...saved }; } catch { - return defaults; + config = defaults; } } - // Check environment variables const envKey = process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY; if (envKey) { - defaults.apiKey = envKey; + config.apiKey = envKey; if (process.env.ANTHROPIC_API_KEY && !process.env.OPENAI_API_KEY) { - defaults.provider = "anthropic"; - defaults.model = "claude-sonnet-4-20250514"; + config.provider = "anthropic"; + config.model = "claude-sonnet-4-20250514"; } } - return defaults; + if (process.env.GITHUB_APP_ID) config.githubAppId = process.env.GITHUB_APP_ID; + if (process.env.GITHUB_APP_PRIVATE_KEY) + config.githubAppPrivateKey = process.env.GITHUB_APP_PRIVATE_KEY; + if (process.env.GITHUB_APP_PRIVATE_KEY_PATH) + config.githubAppPrivateKeyPath = process.env.GITHUB_APP_PRIVATE_KEY_PATH; + if (process.env.GITHUB_OWNER) config.githubOwner = process.env.GITHUB_OWNER; + if (process.env.GITHUB_REPO) config.githubRepo = process.env.GITHUB_REPO; + if (process.env.GITHUB_API_BASE_URL) + config.githubApiBaseUrl = process.env.GITHUB_API_BASE_URL; + if (process.env.GITHUB_TOKEN) + config.githubToken = process.env.GITHUB_TOKEN; + + return config; } export function saveConfig(config: Partial): void { @@ -64,7 +77,6 @@ export function saveConfig(config: Partial): void { const existing = loadConfig(); const merged = { ...existing, ...config }; - // Don't persist workspacePath if it's the default const toSave: Record = {}; const defaults = getDefaultConfig(); for (const [key, value] of Object.entries(merged)) { @@ -96,6 +108,13 @@ export function setConfigValue(key: string, value: string): void { throw new Error(`Invalid provider: ${value}. Use one of: ${VALID_PROVIDERS.join(", ")}`); } update[key] = p; + } else if (key === "githubApiBaseUrl") { + try { + new URL(value); + } catch { + throw new Error("githubApiBaseUrl must be a valid URL"); + } + update[key] = value.replace(/\/$/, ""); } else { update[key] = value; } @@ -122,7 +141,6 @@ export function listWorkspaces(): string[] { /** * Try to read OpenAI API key from Codex's auth.json (e.g. after `codex login --api-key`). - * See: https://developers.openai.com/codex/auth/ */ export function getCodexAuthApiKey(): string | undefined { try { @@ -171,3 +189,7 @@ export function getProviderBaseURL( ? "http://localhost:11434/v1" : "http://localhost:1234/v1"; } + +export function getGithubApiBaseUrl(config?: Partial): string { + return config?.githubApiBaseUrl || process.env.GITHUB_API_BASE_URL || "https://api.github.com"; +} diff --git a/src/core/agent.ts b/src/core/agent.ts index a962b4b..7c70f60 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -167,11 +167,10 @@ export class AgentMemory { return this.graph.consolidate(); } - buildSystemPrompt(): string { + buildSystemPrompt(opts?: { skillPrompt?: string; optimizerPrompt?: string }): string { const p = this.persona; const parts: string[] = []; - // Identity (Cursor-style: one clear line) if (p.systemPrompt) { parts.push(p.systemPrompt); } else { @@ -188,6 +187,18 @@ export class AgentMemory { parts.push(weaveContext); } + // Inject optimizer-generated adaptive prompt sections + if (opts?.optimizerPrompt) { + parts.push(""); + parts.push(opts.optimizerPrompt); + } + + // Inject matched skills (progressive disclosure) + if (opts?.skillPrompt) { + parts.push(""); + parts.push(opts.skillPrompt); + } + parts.push(""); parts.push("## Your Persistent Memory"); @@ -203,6 +214,7 @@ export class AgentMemory { "- You have persistent memory. Important information is automatically saved between sessions. Reference recalled context when relevant; don't repeat stored facts—build on them.\n" + "- **Tools**: Use tools when the user asks you to examine, edit, or run something. Prefer `read_file` before `edit_file` or `write_file`. For large files use `start_line`/`end_line`. Paths are relative to the current working directory.\n" + "- **Formatting**: Use backticks for file names, commands, and symbol names. When citing code, use the form `path:startLine-endLine` (e.g. `src/app.ts:12-15`).\n" + + "- You can create reusable skills with `create_skill` when you discover a repeating pattern.\n" + "- Be concise and helpful. If unsure, say so." ); diff --git a/src/core/graph.ts b/src/core/graph.ts index 4b98fd7..5f05dbb 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -1,6 +1,7 @@ import { MemoryNode, MemoryEdge, + MemoryType, EdgeType, RetrievalResult, MemoryTier, @@ -100,6 +101,7 @@ export class MemoryGraph { this.linkSemantic(node, 5, 0.15); this.linkTemporal(node); this.linkEntities(node); + this.linkCausal(node); } private linkSemantic(node: MemoryNode, k: number, threshold: number): void { @@ -167,6 +169,72 @@ export class MemoryGraph { } } + /** + * Link outcome nodes to the most recent non-outcome nodes from the same + * agent, representing the causal chain: user input -> agent action -> outcome. + * Also links insight nodes back to the outcomes they were derived from. + */ + private linkCausal(node: MemoryNode): void { + if ( + node.memoryType !== MemoryType.OUTCOME && + node.memoryType !== MemoryType.INSIGHT + ) + return; + + // For outcomes: link back to the most recent non-outcome node + // (the episodic memory that represents the triggering interaction) + if (node.memoryType === MemoryType.OUTCOME) { + let closest: MemoryNode | null = null; + let closestDiff = Infinity; + + for (const [id, other] of this.nodes) { + if (id === node.id) continue; + if (other.agentId !== node.agentId) continue; + if (other.memoryType === MemoryType.OUTCOME) continue; + const diff = node.createdAt - other.createdAt; + if (diff > 0 && diff < closestDiff) { + closestDiff = diff; + closest = other; + } + } + + if (closest && closestDiff < 60_000) { + this.addEdge({ + sourceId: closest.id, + targetId: node.id, + edgeType: EdgeType.CAUSAL, + weight: Math.max(0.5, 1.0 - closestDiff / 60_000), + createdAt: Date.now(), + }); + } + } + + // For insights: link to recent outcome nodes they were derived from + if (node.memoryType === MemoryType.INSIGHT && node.embedding.length > 0) { + const outcomes = Array.from(this.nodes.values()).filter( + (n) => n.memoryType === MemoryType.OUTCOME && n.embedding.length > 0 + ); + const scored = outcomes + .map((o) => ({ + node: o, + sim: cosineSimilarity(node.embedding, o.embedding), + })) + .filter((s) => s.sim > 0.2) + .sort((a, b) => b.sim - a.sim) + .slice(0, 5); + + for (const { node: outcome, sim } of scored) { + this.addEdge({ + sourceId: outcome.id, + targetId: node.id, + edgeType: EdgeType.CAUSAL, + weight: sim, + createdAt: Date.now(), + }); + } + } + } + async retrieve( query: string, k = 5, diff --git a/src/core/sessions.ts b/src/core/sessions.ts new file mode 100644 index 0000000..208cee7 --- /dev/null +++ b/src/core/sessions.ts @@ -0,0 +1,155 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +const SESSIONS_DIR = path.join(os.homedir(), ".weave", "sessions"); + +function ensureSessionsDir(): void { + if (!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true }); +} + +export interface SessionRecord { + id: string; + workspace: string; + agentName: string; + model: string; + createdAt: number; + updatedAt: number; + messageCount: number; + transcript: object[]; +} + +export interface SessionMeta { + id: string; + workspace: string; + agentName: string; + model: string; + createdAt: number; + updatedAt: number; + messageCount: number; + filename: string; +} + +function keyPrefix(workspace: string, agentName: string): string { + return `${workspace}__${agentName}__`; +} + +function sessionFiles(workspace: string, agentName: string): string[] { + ensureSessionsDir(); + const prefix = keyPrefix(workspace, agentName); + try { + return fs + .readdirSync(SESSIONS_DIR) + .filter((f) => f.startsWith(prefix) && f.endsWith(".json")) + .sort() + .reverse(); // newest first + } catch { + return []; + } +} + +export function saveSession( + workspace: string, + agentName: string, + model: string, + transcript: object[] +): void { + ensureSessionsDir(); + if (transcript.length === 0) return; + const now = Date.now(); + const files = sessionFiles(workspace, agentName); + + // If a session already exists for today, update it in place + if (files.length > 0) { + const latestPath = path.join(SESSIONS_DIR, files[0]); + try { + const existing: SessionRecord = JSON.parse(fs.readFileSync(latestPath, "utf-8")); + const sameDay = + new Date(existing.createdAt).toDateString() === new Date(now).toDateString(); + if (sameDay) { + const updated: SessionRecord = { + ...existing, + model, + updatedAt: now, + messageCount: transcript.length, + transcript, + }; + fs.writeFileSync(latestPath, JSON.stringify(updated, null, 2)); + pruneOldSessions(workspace, agentName); + return; + } + } catch {} + } + + // Create a new session file + const id = now.toString(36); + const filename = `${keyPrefix(workspace, agentName)}${now}.json`; + const record: SessionRecord = { + id, + workspace, + agentName, + model, + createdAt: now, + updatedAt: now, + messageCount: transcript.length, + transcript, + }; + fs.writeFileSync(path.join(SESSIONS_DIR, filename), JSON.stringify(record, null, 2)); + pruneOldSessions(workspace, agentName); +} + +function pruneOldSessions(workspace: string, agentName: string): void { + const files = sessionFiles(workspace, agentName); + if (files.length > 10) { + for (const f of files.slice(10)) { + try { + fs.unlinkSync(path.join(SESSIONS_DIR, f)); + } catch {} + } + } +} + +export function loadLastSession(workspace: string, agentName: string): SessionRecord | null { + const files = sessionFiles(workspace, agentName); + if (files.length === 0) return null; + try { + return JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, files[0]), "utf-8")); + } catch { + return null; + } +} + +export function listSessionMeta(workspace: string, agentName: string): SessionMeta[] { + const files = sessionFiles(workspace, agentName); + const result: SessionMeta[] = []; + for (const f of files) { + try { + const raw = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), "utf-8")); + result.push({ + id: raw.id, + workspace: raw.workspace, + agentName: raw.agentName, + model: raw.model, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + messageCount: raw.messageCount, + filename: f, + }); + } catch {} + } + return result; +} + +export function loadSessionByIndex( + workspace: string, + agentName: string, + index: number +): SessionRecord | null { + const files = sessionFiles(workspace, agentName); + if (index < 0 || index >= files.length) return null; + try { + return JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, files[index]), "utf-8")); + } catch { + return null; + } +} diff --git a/src/core/storage.ts b/src/core/storage.ts index 8f5cf87..41d1102 100644 --- a/src/core/storage.ts +++ b/src/core/storage.ts @@ -4,6 +4,10 @@ import type { AgentPersona, AgentState, } from "./types.js"; +import type { + AutomationRecord, + AutomationRunRecord, +} from "../testing/automation-types.js"; export class Storage { private db: any; @@ -59,6 +63,36 @@ export class Storage { CREATE INDEX IF NOT EXISTS idx_nodes_tier ON nodes(tier); CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id); CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id); + + CREATE TABLE IF NOT EXISTS automations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + enabled INTEGER NOT NULL, + trigger_json TEXT NOT NULL, + target_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_run_at INTEGER, + next_run_at INTEGER, + failure_count INTEGER NOT NULL, + max_failures INTEGER NOT NULL, + last_status TEXT, + last_summary TEXT + ); + + CREATE TABLE IF NOT EXISTS automation_runs ( + id TEXT PRIMARY KEY, + automation_id TEXT NOT NULL, + started_at INTEGER NOT NULL, + ended_at INTEGER, + status TEXT NOT NULL, + summary TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_automations_enabled_next + ON automations(enabled, next_run_at); + CREATE INDEX IF NOT EXISTS idx_automation_runs_automation + ON automation_runs(automation_id, started_at); `); } @@ -185,6 +219,80 @@ export class Storage { this.db.prepare("UPDATE agents SET last_active = ? WHERE id = ?").run(Date.now(), id); } + saveAutomation(record: AutomationRecord): void { + this.db + .prepare( + `INSERT OR REPLACE INTO automations + (id, name, enabled, trigger_json, target_json, created_at, updated_at, + last_run_at, next_run_at, failure_count, max_failures, last_status, last_summary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + record.id, + record.name, + record.enabled ? 1 : 0, + JSON.stringify(record.trigger), + JSON.stringify(record.target), + record.createdAt, + record.updatedAt, + record.lastRunAt ?? null, + record.nextRunAt ?? null, + record.failureCount, + record.maxFailures, + record.lastStatus ?? null, + record.lastSummary ?? null + ); + } + + loadAutomations(): AutomationRecord[] { + const rows = this.db + .prepare("SELECT * FROM automations ORDER BY created_at ASC") + .all() as Record[]; + return rows.map(rowToAutomation); + } + + loadAutomation(id: string): AutomationRecord | null { + const row = this.db + .prepare("SELECT * FROM automations WHERE id = ?") + .get(id) as Record | undefined; + return row ? rowToAutomation(row) : null; + } + + deleteAutomation(id: string): void { + this.db.prepare("DELETE FROM automation_runs WHERE automation_id = ?").run(id); + this.db.prepare("DELETE FROM automations WHERE id = ?").run(id); + } + + saveAutomationRun(run: AutomationRunRecord): void { + this.db + .prepare( + `INSERT OR REPLACE INTO automation_runs + (id, automation_id, started_at, ended_at, status, summary) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .run( + run.id, + run.automationId, + run.startedAt, + run.endedAt ?? null, + run.status, + run.summary + ); + } + + loadAutomationRuns(automationId?: string): AutomationRunRecord[] { + const rows = automationId + ? (this.db + .prepare( + "SELECT * FROM automation_runs WHERE automation_id = ? ORDER BY started_at DESC" + ) + .all(automationId) as Record[]) + : (this.db + .prepare("SELECT * FROM automation_runs ORDER BY started_at DESC") + .all() as Record[]); + return rows.map(rowToAutomationRun); + } + saveAll(nodes: MemoryNode[], edges: MemoryEdge[]): void { const txn = this.db.transaction(() => { for (const node of nodes) this.saveNode(node); @@ -226,3 +334,47 @@ function rowToEdge(row: Record): MemoryEdge { createdAt: row.created_at as number, }; } + +function rowToAutomation(row: Record): AutomationRecord { + return { + id: row.id as string, + name: row.name as string, + enabled: Boolean(row.enabled), + trigger: JSON.parse(row.trigger_json as string), + target: JSON.parse(row.target_json as string), + createdAt: row.created_at as number, + updatedAt: row.updated_at as number, + lastRunAt: + row.last_run_at === null || row.last_run_at === undefined + ? undefined + : (row.last_run_at as number), + nextRunAt: + row.next_run_at === null || row.next_run_at === undefined + ? undefined + : (row.next_run_at as number), + failureCount: row.failure_count as number, + maxFailures: row.max_failures as number, + lastStatus: + row.last_status === null || row.last_status === undefined + ? undefined + : (row.last_status as AutomationRecord["lastStatus"]), + lastSummary: + row.last_summary === null || row.last_summary === undefined + ? undefined + : (row.last_summary as string), + }; +} + +function rowToAutomationRun(row: Record): AutomationRunRecord { + return { + id: row.id as string, + automationId: row.automation_id as string, + startedAt: row.started_at as number, + endedAt: + row.ended_at === null || row.ended_at === undefined + ? undefined + : (row.ended_at as number), + status: row.status as AutomationRunRecord["status"], + summary: row.summary as string, + }; +} diff --git a/src/core/types.ts b/src/core/types.ts index acc639c..5f3a3d8 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -17,6 +17,9 @@ export enum MemoryType { PROCEDURAL = "procedural", PREFERENCE = "preference", ENTITY = "entity", + OUTCOME = "outcome", + SKILL = "skill", + INSIGHT = "insight", } export enum EdgeType { @@ -95,6 +98,13 @@ export interface WeaveConfig { embeddingDim: number; defaultAgent: string; workspacePath: string; + githubAppId?: string; + githubAppPrivateKeyPath?: string; + githubAppPrivateKey?: string; + githubOwner?: string; + githubRepo?: string; + githubApiBaseUrl?: string; + githubToken?: string; } export interface ChatMessage { @@ -107,6 +117,18 @@ export interface ChatMessage { toolResults?: { callId: string; output: string }[]; } +export interface TaskOutcome { + taskId: string; + query: string; + skillUsed?: string; + toolsUsed: string[]; + success: boolean; + errorType?: string; + duration: number; + userFeedback?: "positive" | "negative" | "neutral"; + iterations: number; +} + export function createMemoryNode( content: string, agentId: string, diff --git a/src/evolution/optimizer.ts b/src/evolution/optimizer.ts new file mode 100644 index 0000000..2941b7d --- /dev/null +++ b/src/evolution/optimizer.ts @@ -0,0 +1,202 @@ +import { MemoryType } from "../core/types.js"; +import type { MemoryNode } from "../core/types.js"; +import type { MemoryFabric } from "../core/fabric.js"; + +/** + * Dynamically constructs adaptive system prompt sections + * based on accumulated insights and outcome history. + * + * Unlike AdalFlow's heavy TextGrad approach, this leverages + * the existing memory graph to surface the right meta-knowledge + * at the right time — zero extra API calls. + */ +export class PromptOptimizer { + private fabric: MemoryFabric; + + constructor(fabric: MemoryFabric) { + this.fabric = fabric; + } + + /** + * Build an adaptive prompt section from insights, outcomes, and + * learned patterns stored in the graph. This is injected into + * the system prompt alongside skills. + */ + async buildAdaptivePrompt( + agentId: string, + userInput: string + ): Promise { + const parts: string[] = []; + + // 1. Inject high-value insights + const insightSection = this.buildInsightSection(agentId); + if (insightSection) parts.push(insightSection); + + // 2. Inject learned anti-patterns + const antiPatterns = this.buildAntiPatternSection(agentId); + if (antiPatterns) parts.push(antiPatterns); + + // 3. Inject relevant past outcomes for similar queries + const relevantOutcomes = await this.buildRelevantOutcomeSection( + agentId, + userInput + ); + if (relevantOutcomes) parts.push(relevantOutcomes); + + if (parts.length === 0) return ""; + + return "## Learned Behavior\n" + parts.join("\n\n"); + } + + /** + * Surface high-confidence insights that should influence every interaction. + */ + private buildInsightSection(agentId: string): string | null { + const graph = this.fabric.getGraph(); + const insights = graph + .getAgentNodes(agentId) + .filter( + (n) => + n.memoryType === MemoryType.INSIGHT && + (n.metadata.insightCategory === "pattern" || + n.metadata.insightCategory === "preference" || + n.metadata.insightCategory === "optimization") + ) + .sort((a, b) => b.importance - a.importance) + .slice(0, 5); + + // Also include global insights from other agents + const globalInsights = graph + .getAllNodes() + .filter( + (n) => + n.memoryType === MemoryType.INSIGHT && + n.agentId !== agentId && + n.scope === "global" && + n.importance >= 0.7 + ) + .sort((a, b) => b.importance - a.importance) + .slice(0, 3); + + const all = [...insights, ...globalInsights]; + if (all.length === 0) return null; + + const lines = all.map((n) => `- ${n.content}`); + return "### What Works\n" + lines.join("\n"); + } + + /** + * Surface anti-patterns the agent should avoid. + */ + private buildAntiPatternSection(agentId: string): string | null { + const graph = this.fabric.getGraph(); + const antiPatterns = graph + .getAgentNodes(agentId) + .filter( + (n) => + n.memoryType === MemoryType.INSIGHT && + n.metadata.insightCategory === "anti-pattern" + ) + .sort((a, b) => b.importance - a.importance) + .slice(0, 3); + + if (antiPatterns.length === 0) return null; + + const lines = antiPatterns.map((n) => `- ${n.content}`); + return "### Avoid\n" + lines.join("\n"); + } + + /** + * Find past outcomes for similar queries to inform the current approach. + * Uses the graph's vector search to find semantically similar past tasks. + */ + private async buildRelevantOutcomeSection( + agentId: string, + userInput: string + ): Promise { + const graph = this.fabric.getGraph(); + + // Use hybrid retrieval to find related outcomes + const results = await graph.retrieve(userInput, 5, { + agentFilter: agentId, + strategy: "hybrid", + }); + + const outcomeResults = results.filter( + (r) => + r.node.memoryType === MemoryType.OUTCOME && r.score > 0.3 + ); + + if (outcomeResults.length === 0) return null; + + const successes = outcomeResults.filter( + (r) => r.node.metadata.success === true + ); + const failures = outcomeResults.filter( + (r) => r.node.metadata.success === false + ); + + const parts: string[] = ["### Similar Past Tasks"]; + + if (successes.length > 0) { + const toolSets = successes + .map((r) => r.node.metadata.toolsUsed as string[] | undefined) + .filter((t): t is string[] => !!t && t.length > 0); + + if (toolSets.length > 0) { + const toolFreq = new Map(); + for (const tools of toolSets) { + for (const t of tools) { + toolFreq.set(t, (toolFreq.get(t) || 0) + 1); + } + } + const topTools = Array.from(toolFreq.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([t]) => t); + + parts.push( + `Previously successful with similar tasks using: ${topTools.join(", ")}` + ); + } + } + + if (failures.length > 0) { + const errorTypes = failures + .map((r) => r.node.metadata.errorType as string | undefined) + .filter(Boolean); + + if (errorTypes.length > 0) { + const uniqueErrors = [...new Set(errorTypes)]; + parts.push( + `Watch out: similar tasks previously failed with: ${uniqueErrors.join(", ")}` + ); + } + } + + return parts.length > 1 ? parts.join("\n") : null; + } + + /** + * Recommend the best retrieval strategy based on past performance. + */ + recommendStrategy( + agentId: string + ): "vector" | "graph" | "hybrid" { + const graph = this.fabric.getGraph(); + const outcomes = graph + .getAgentNodes(agentId) + .filter((n) => n.memoryType === MemoryType.OUTCOME); + + if (outcomes.length < 10) return "hybrid"; + + // If the agent has lots of interconnected nodes, graph search adds value + const nodeCount = graph.getAgentNodes(agentId).length; + const edgeCount = graph.edgeCount; + const density = nodeCount > 0 ? edgeCount / nodeCount : 0; + + if (density > 3) return "hybrid"; + if (density > 1) return "graph"; + return "vector"; + } +} diff --git a/src/evolution/reflector.ts b/src/evolution/reflector.ts new file mode 100644 index 0000000..ef77817 --- /dev/null +++ b/src/evolution/reflector.ts @@ -0,0 +1,239 @@ +import { + MemoryType, + MemoryTier, + MemoryScope, + createMemoryNode, +} from "../core/types.js"; +import type { MemoryFabric } from "../core/fabric.js"; +import type { LLMProvider } from "../llm/provider.js"; +import { OutcomeTracker } from "./tracker.js"; +import { SkillRegistry } from "../skills/registry.js"; +import type { + ReflectionResult, + ReflectionInsight, + SuggestedSkill, +} from "./types.js"; + +const REFLECTION_PROMPT = `You are analyzing the outcome history of an AI coding agent to find patterns that improve future performance. + +Given the following task outcomes (most recent first), identify: + +1. **Patterns**: Approaches or tool sequences that consistently led to success +2. **Anti-patterns**: Approaches that consistently failed or took too many iterations +3. **Preferences**: User preferences inferred from the interaction patterns +4. **Optimizations**: Specific improvements the agent should adopt + +Also suggest any reusable "skills" — if you see a repeated task pattern that could be captured as a template for the agent to follow. + +Respond in JSON format: +{ + "insights": [ + { + "category": "pattern" | "anti-pattern" | "preference" | "optimization", + "summary": "concise description", + "confidence": 0.0-1.0 + } + ], + "suggestedSkills": [ + { + "name": "kebab-case-name", + "trigger": "regex pattern1|pattern2", + "body": "markdown instructions for the agent", + "reason": "why this skill would help" + } + ], + "promptAdjustments": [ + "specific instruction to add to the system prompt" + ] +} + +OUTCOMES: +`; + +export class Reflector { + private fabric: MemoryFabric; + private tracker: OutcomeTracker; + private skillRegistry: SkillRegistry; + + constructor( + fabric: MemoryFabric, + tracker: OutcomeTracker, + skillRegistry: SkillRegistry + ) { + this.fabric = fabric; + this.tracker = tracker; + this.skillRegistry = skillRegistry; + } + + /** + * Analyze recent outcomes and produce actionable insights. + * Requires an LLM provider for the analysis step. + */ + async reflect( + provider: LLMProvider, + agentId: string, + model?: string + ): Promise { + const outcomes = this.tracker.getRecentOutcomes(agentId, 30); + + if (outcomes.length < 3) { + return { insights: [], suggestedSkills: [], promptAdjustments: [] }; + } + + const outcomesText = outcomes + .map((n) => n.content) + .join("\n"); + + const summary = this.tracker.summarize(agentId); + const statsText = [ + `Total tasks: ${summary.total}`, + `Success rate: ${(summary.successRate * 100).toFixed(0)}%`, + `Avg duration: ${(summary.avgDuration / 1000).toFixed(1)}s`, + `Most used tools: ${summary.commonTools.slice(0, 5).map((t) => `${t.name}(${t.count})`).join(", ")}`, + summary.commonErrors.length > 0 + ? `Common errors: ${summary.commonErrors.map((e) => `${e.type}(${e.count})`).join(", ")}` + : "", + summary.skillUsage.length > 0 + ? `Skills used: ${summary.skillUsage.map((s) => `${s.name}(${s.count}, ${(s.successRate * 100).toFixed(0)}%)`).join(", ")}` + : "", + ] + .filter(Boolean) + .join("\n"); + + const prompt = `${REFLECTION_PROMPT}${outcomesText}\n\nAGGREGATE STATS:\n${statsText}`; + + try { + const response = await provider.chat( + [ + { role: "system", content: "You are a meta-analysis assistant. Respond only with valid JSON." }, + { role: "user", content: prompt }, + ], + model + ); + + const result = this.parseReflectionResponse(response); + + // Persist insights as high-importance memory nodes + await this.persistInsights(result.insights, agentId); + + // Auto-create suggested skills + for (const skill of result.suggestedSkills) { + const existing = this.skillRegistry.getSkill(skill.name); + if (!existing) { + this.skillRegistry.createSkill( + { + name: skill.name, + trigger: skill.trigger, + description: skill.reason, + }, + skill.body, + "global" + ); + } + } + + return result; + } catch { + // Reflection is best-effort; fall back to stats-only insights + return this.buildStatsInsights(summary, agentId); + } + } + + private parseReflectionResponse(raw: string): ReflectionResult { + // Extract JSON from potential markdown code blocks + const jsonMatch = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/) || [null, raw]; + const cleaned = (jsonMatch[1] || raw).trim(); + + try { + const parsed = JSON.parse(cleaned); + return { + insights: Array.isArray(parsed.insights) + ? parsed.insights.filter( + (i: any) => i.category && i.summary && typeof i.confidence === "number" + ) + : [], + suggestedSkills: Array.isArray(parsed.suggestedSkills) + ? parsed.suggestedSkills.filter( + (s: any) => s.name && s.trigger && s.body + ) + : [], + promptAdjustments: Array.isArray(parsed.promptAdjustments) + ? parsed.promptAdjustments.filter((a: any) => typeof a === "string") + : [], + }; + } catch { + return { insights: [], suggestedSkills: [], promptAdjustments: [] }; + } + } + + private async persistInsights( + insights: ReflectionInsight[], + agentId: string + ): Promise { + const graph = this.fabric.getGraph(); + + for (const insight of insights) { + const content = `[INSIGHT/${insight.category}] ${insight.summary}`; + const node = createMemoryNode(content, agentId, { + memoryType: MemoryType.INSIGHT, + tier: MemoryTier.LONG_TERM, + scope: MemoryScope.GLOBAL, + importance: 0.7 + insight.confidence * 0.3, + decayRate: 0.00005, + metadata: { + insightCategory: insight.category, + confidence: insight.confidence, + generatedAt: Date.now(), + }, + }); + + await graph.addNode(node); + } + } + + /** + * Fallback: generate basic insights purely from aggregate stats + * when LLM reflection is unavailable. + */ + private async buildStatsInsights( + summary: ReturnType, + agentId: string + ): Promise { + const insights: ReflectionInsight[] = []; + + if (summary.successRate < 0.5 && summary.total >= 5) { + insights.push({ + category: "anti-pattern", + summary: `Low success rate (${(summary.successRate * 100).toFixed(0)}%) across ${summary.total} tasks. Consider breaking complex tasks into smaller steps.`, + confidence: 0.7, + basedOnOutcomes: summary.total, + }); + } + + if (summary.successRate > 0.85 && summary.total >= 10) { + insights.push({ + category: "pattern", + summary: `High success rate (${(summary.successRate * 100).toFixed(0)}%) maintained across ${summary.total} tasks. Current approach is working well.`, + confidence: 0.8, + basedOnOutcomes: summary.total, + }); + } + + for (const err of summary.commonErrors) { + if (err.count >= 3) { + insights.push({ + category: "anti-pattern", + summary: `Recurring error "${err.type}" occurred ${err.count} times. Investigate root cause.`, + confidence: 0.6, + basedOnOutcomes: err.count, + }); + } + } + + if (insights.length > 0) { + await this.persistInsights(insights, agentId); + } + + return { insights, suggestedSkills: [], promptAdjustments: [] }; + } +} diff --git a/src/evolution/tracker.ts b/src/evolution/tracker.ts new file mode 100644 index 0000000..412abe0 --- /dev/null +++ b/src/evolution/tracker.ts @@ -0,0 +1,173 @@ +import { + type TaskOutcome, + type MemoryNode, + MemoryType, + MemoryTier, + MemoryScope, + createMemoryNode, +} from "../core/types.js"; +import type { MemoryFabric } from "../core/fabric.js"; +import type { OutcomeSummary } from "./types.js"; + +export class OutcomeTracker { + private fabric: MemoryFabric; + + constructor(fabric: MemoryFabric) { + this.fabric = fabric; + } + + /** + * Record a task outcome as a memory node in the graph. + * The outcome is serialized into the node content and metadata, + * allowing it to participate in semantic linking with related memories. + */ + async record(outcome: TaskOutcome, agentId: string): Promise { + const content = this.formatOutcomeContent(outcome); + + const node = createMemoryNode(content, agentId, { + memoryType: MemoryType.OUTCOME, + tier: MemoryTier.SHORT_TERM, + scope: MemoryScope.PRIVATE, + importance: outcome.success ? 0.4 : 0.6, + decayRate: 0.0002, + metadata: { + outcomeType: "task", + taskId: outcome.taskId, + success: outcome.success, + errorType: outcome.errorType, + toolsUsed: outcome.toolsUsed, + skillUsed: outcome.skillUsed, + duration: outcome.duration, + iterations: outcome.iterations, + userFeedback: outcome.userFeedback, + query: outcome.query, + }, + }); + + const graph = this.fabric.getGraph(); + await graph.addNode(node); + return node.id; + } + + /** + * Retrieve recent outcomes for an agent from the graph. + */ + getRecentOutcomes(agentId: string, limit = 50): MemoryNode[] { + const graph = this.fabric.getGraph(); + return graph + .getAgentNodes(agentId) + .filter((n) => n.memoryType === MemoryType.OUTCOME) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit); + } + + /** + * Get all outcomes across all agents. + */ + getAllOutcomes(limit = 100): MemoryNode[] { + const graph = this.fabric.getGraph(); + return graph + .getAllNodes() + .filter((n) => n.memoryType === MemoryType.OUTCOME) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit); + } + + /** + * Compute aggregate statistics from recorded outcomes. + */ + summarize(agentId?: string, limit = 100): OutcomeSummary { + const outcomes = agentId + ? this.getRecentOutcomes(agentId, limit) + : this.getAllOutcomes(limit); + + const total = outcomes.length; + const successes = outcomes.filter( + (n) => (n.metadata.success as boolean) === true + ).length; + const failures = total - successes; + const successRate = total > 0 ? successes / total : 0; + + const durations = outcomes + .map((n) => n.metadata.duration as number) + .filter((d) => typeof d === "number"); + const avgDuration = + durations.length > 0 + ? durations.reduce((a, b) => a + b, 0) / durations.length + : 0; + + // Count tool usage + const toolCounts = new Map(); + for (const n of outcomes) { + const tools = n.metadata.toolsUsed as string[] | undefined; + if (tools) { + for (const t of tools) { + toolCounts.set(t, (toolCounts.get(t) || 0) + 1); + } + } + } + const commonTools = Array.from(toolCounts.entries()) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + // Count errors + const errorCounts = new Map(); + for (const n of outcomes) { + const errType = n.metadata.errorType as string | undefined; + if (errType) { + errorCounts.set(errType, (errorCounts.get(errType) || 0) + 1); + } + } + const commonErrors = Array.from(errorCounts.entries()) + .map(([type, count]) => ({ type, count })) + .sort((a, b) => b.count - a.count); + + // Skill usage with success rates + const skillStats = new Map(); + for (const n of outcomes) { + const skill = n.metadata.skillUsed as string | undefined; + if (skill) { + const s = skillStats.get(skill) || { total: 0, successes: 0 }; + s.total++; + if (n.metadata.success) s.successes++; + skillStats.set(skill, s); + } + } + const skillUsage = Array.from(skillStats.entries()) + .map(([name, s]) => ({ + name, + count: s.total, + successRate: s.total > 0 ? s.successes / s.total : 0, + })) + .sort((a, b) => b.count - a.count); + + return { + total, + successes, + failures, + successRate, + avgDuration, + commonTools, + commonErrors, + skillUsage, + }; + } + + private formatOutcomeContent(outcome: TaskOutcome): string { + const status = outcome.success ? "SUCCESS" : "FAILURE"; + const tools = + outcome.toolsUsed.length > 0 + ? ` using ${outcome.toolsUsed.join(", ")}` + : ""; + const skill = outcome.skillUsed + ? ` (skill: ${outcome.skillUsed})` + : ""; + const error = outcome.errorType + ? ` Error: ${outcome.errorType}` + : ""; + const duration = `${(outcome.duration / 1000).toFixed(1)}s`; + + return `[${status}] Task: "${outcome.query}"${tools}${skill} in ${duration}, ${outcome.iterations} iterations.${error}`; + } +} diff --git a/src/evolution/types.ts b/src/evolution/types.ts new file mode 100644 index 0000000..c2b0b57 --- /dev/null +++ b/src/evolution/types.ts @@ -0,0 +1,32 @@ +import type { TaskOutcome } from "../core/types.js"; + +export interface ReflectionInsight { + category: "pattern" | "anti-pattern" | "preference" | "optimization"; + summary: string; + confidence: number; + basedOnOutcomes: number; +} + +export interface ReflectionResult { + insights: ReflectionInsight[]; + suggestedSkills: SuggestedSkill[]; + promptAdjustments: string[]; +} + +export interface SuggestedSkill { + name: string; + trigger: string; + body: string; + reason: string; +} + +export interface OutcomeSummary { + total: number; + successes: number; + failures: number; + successRate: number; + avgDuration: number; + commonTools: { name: string; count: number }[]; + commonErrors: { type: string; count: number }[]; + skillUsage: { name: string; count: number; successRate: number }[]; +} diff --git a/src/github/action-entry.ts b/src/github/action-entry.ts new file mode 100644 index 0000000..82fc206 --- /dev/null +++ b/src/github/action-entry.ts @@ -0,0 +1,149 @@ +import * as fs from "node:fs"; +import type { WeaveConfig } from "../core/types.js"; +import { loadConfig } from "../config.js"; +import { createProvider } from "../llm/provider.js"; +import { resolveApiKey, getProviderBaseURL } from "../config.js"; +import { reviewPullRequest } from "./reviewer.js"; +import { parseMentionFromEvent, handleMention } from "./responder.js"; +import { parseIssueFromEvent, triageIssue } from "./issue-handler.js"; +import { generateReleaseNotes } from "./release-notes.js"; +import { createGithubClientFromToken } from "./client.js"; + +export interface ActionRunResult { + event: string; + handler: string; + success: boolean; + details?: string; + error?: string; +} + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Required environment variable ${name} is not set.`); + return value; +} + +function readEventPayload(eventPath: string): any { + const raw = fs.readFileSync(eventPath, "utf-8"); + return JSON.parse(raw); +} + +export async function runAction( + configOverride?: Partial, + triggerPhrase = "@weave" +): Promise { + const eventName = requireEnv("GITHUB_EVENT_NAME"); + const eventPath = requireEnv("GITHUB_EVENT_PATH"); + const token = requireEnv("GITHUB_TOKEN"); + + const config = { ...loadConfig(), ...configOverride }; + const apiKey = resolveApiKey(config); + if (!apiKey) { + throw new Error("No LLM API key found. Set OPENAI_API_KEY or ANTHROPIC_API_KEY."); + } + const provider = createProvider( + config.provider, + apiKey, + config.model, + getProviderBaseURL(config.provider, config.baseURL) + ); + + const payload = readEventPayload(eventPath); + const repoFullName: string = payload.repository?.full_name || ""; + const [owner, repo] = repoFullName.split("/"); + + console.log(`[weave-action] Event: ${eventName}, Repo: ${repoFullName}`); + + // ── Pull Request ───────────────────────────────────────── + if (eventName === "pull_request") { + const prNumber = payload.pull_request?.number; + if (!prNumber) return { event: eventName, handler: "none", success: false, error: "No PR number in payload" }; + + const action = payload.action; + if (!["opened", "synchronize", "reopened"].includes(action)) { + return { event: eventName, handler: "skip", success: true, details: `PR action "${action}" not handled` }; + } + + console.log(`[weave-action] Reviewing PR #${prNumber}`); + const result = await reviewPullRequest(config, provider, owner, repo, prNumber, token); + return { + event: eventName, + handler: "reviewPullRequest", + success: result.submitted, + details: `Verdict: ${result.review.verdict}, ${result.review.comments.length} inline comments`, + }; + } + + // ── Issue Comment (@weave mention) ─────────────────────── + if (eventName === "issue_comment" || eventName === "pull_request_review_comment") { + const mention = parseMentionFromEvent(payload, triggerPhrase); + if (!mention) { + return { event: eventName, handler: "skip", success: true, details: `No "${triggerPhrase}" mention found` }; + } + + console.log(`[weave-action] Responding to mention in #${mention.issueNumber}`); + const result = await handleMention(config, provider, mention, token, triggerPhrase); + return { + event: eventName, + handler: "handleMention", + success: result.posted, + details: `Reply posted: ${result.posted}`, + }; + } + + // ── New Issue ──────────────────────────────────────────── + if (eventName === "issues" && payload.action === "opened") { + const parsed = parseIssueFromEvent(payload); + if (!parsed) return { event: eventName, handler: "none", success: false, error: "Could not parse issue from payload" }; + + console.log(`[weave-action] Triaging issue #${parsed.issue.number}`); + const result = await triageIssue(config, provider, parsed.owner, parsed.repo, parsed.issue, token); + return { + event: eventName, + handler: "triageIssue", + success: result.applied, + details: `Labels: [${result.labels.join(", ")}], Priority: ${result.priority}`, + }; + } + + // ── Release / workflow_dispatch (release notes) ────────── + if (eventName === "release" || eventName === "workflow_dispatch") { + const inputs = payload.inputs || {}; + const from = inputs.from || inputs.base_tag; + const to = inputs.to || inputs.head_tag || "HEAD"; + + if (!from) { + if (eventName === "workflow_dispatch") { + return { event: eventName, handler: "none", success: false, error: "Provide 'from' (base tag) in workflow inputs" }; + } + return { event: eventName, handler: "skip", success: true, details: "Release event without tag range" }; + } + + console.log(`[weave-action] Generating release notes: ${from} → ${to}`); + const result = await generateReleaseNotes( + config, + provider, + { + owner, + repo, + from, + to, + tagName: inputs.tag_name, + releaseName: inputs.release_name, + publish: inputs.publish === "true", + }, + token + ); + + return { + event: eventName, + handler: "generateReleaseNotes", + success: true, + details: result.published + ? `Release published: ${result.releaseUrl}` + : `Release notes generated (${result.markdown.length} chars)`, + }; + } + + return { event: eventName, handler: "none", success: true, details: `Event "${eventName}" not handled by Weave` }; +} diff --git a/src/github/app-auth.ts b/src/github/app-auth.ts new file mode 100644 index 0000000..bf02c03 --- /dev/null +++ b/src/github/app-auth.ts @@ -0,0 +1,64 @@ +import * as fs from "node:fs"; +import * as crypto from "node:crypto"; +import type { WeaveConfig } from "../core/types.js"; +import { getGithubApiBaseUrl } from "../config.js"; +import type { GithubAppConfig } from "./types.js"; + +function base64url(input: Buffer | string): string { + const buffer = typeof input === "string" ? Buffer.from(input) : input; + return buffer + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +export function normalizePrivateKey(value: string): string { + return value.includes("\\n") ? value.replace(/\\n/g, "\n") : value; +} + +export function loadGithubAppConfig(config: WeaveConfig): GithubAppConfig | null { + const appId = config.githubAppId || process.env.GITHUB_APP_ID; + const inlineKey = config.githubAppPrivateKey || process.env.GITHUB_APP_PRIVATE_KEY; + const keyPath = config.githubAppPrivateKeyPath || process.env.GITHUB_APP_PRIVATE_KEY_PATH; + + let privateKey = inlineKey ? normalizePrivateKey(inlineKey) : ""; + if (!privateKey && keyPath && fs.existsSync(keyPath)) { + privateKey = fs.readFileSync(keyPath, "utf-8"); + } + + if (!appId || !privateKey) return null; + + return { + appId, + privateKey, + apiBaseUrl: getGithubApiBaseUrl(config), + owner: config.githubOwner || process.env.GITHUB_OWNER, + repo: config.githubRepo || process.env.GITHUB_REPO, + }; +} + +export function createGithubAppJwt( + appId: string, + privateKey: string, + nowSeconds = Math.floor(Date.now() / 1000) +): string { + const header = { + alg: "RS256", + typ: "JWT", + }; + const payload = { + iat: nowSeconds - 60, + exp: nowSeconds + 9 * 60, + iss: appId, + }; + + const encodedHeader = base64url(JSON.stringify(header)); + const encodedPayload = base64url(JSON.stringify(payload)); + const signingInput = `${encodedHeader}.${encodedPayload}`; + const signer = crypto.createSign("RSA-SHA256"); + signer.update(signingInput); + signer.end(); + const signature = signer.sign(privateKey); + return `${signingInput}.${base64url(signature)}`; +} diff --git a/src/github/cli.ts b/src/github/cli.ts new file mode 100644 index 0000000..9f00e04 --- /dev/null +++ b/src/github/cli.ts @@ -0,0 +1,73 @@ +import { table, t } from "../ui/theme.js"; +import type { GithubBranchResult, GithubCommitFlowResult, BotPushResult } from "./write-flow.js"; +import type { GithubPullRequestResult } from "./types.js"; + +export function renderGithubStatus(status: { + appSlug: string; + owner?: string; + repo?: string; +}): string { + return [ + "", + ` ${t.brandBold("GitHub App Status")}`, + ` ${t.muted("─".repeat(40))}`, + ` ${t.label("App")} ${status.appSlug}`, + ` ${t.label("Owner")} ${status.owner || "-"}`, + ` ${t.label("Repo")} ${status.repo || "-"}`, + "", + ].join("\n"); +} + +export function renderGithubRepos(rows: { full_name: string; default_branch?: string }[]): string { + return table( + ["Repository", "Default Branch"], + rows.map((row) => [row.full_name, row.default_branch || "-"]) + ); +} + +export function renderBranchCreated(result: GithubBranchResult): string { + return [ + "", + ` ${t.brandBold("Branch Created")}`, + ` ${t.muted("─".repeat(40))}`, + ` ${t.label("Repo")} ${result.owner}/${result.repo}`, + ` ${t.label("Branch")} ${result.branch}`, + ` ${t.label("SHA")} ${result.sha}`, + "", + ].join("\n"); +} + +export function renderCommitResult(result: GithubCommitFlowResult): string { + return [ + "", + ` ${t.brandBold("Commit Pushed")}`, + ` ${t.muted("─".repeat(40))}`, + ` ${t.label("Repo")} ${result.owner}/${result.repo}`, + ` ${t.label("Branch")} ${result.branch}`, + ` ${t.label("Commit")} ${result.commitSha}`, + ` ${t.label("Files")} ${result.changedFiles.join(", ")}`, + "", + ].join("\n"); +} + +export function renderBotPushResult(result: BotPushResult): string { + return [ + "", + ` ${t.brandBold("Pushed as bot")}`, + ` ${t.muted("─".repeat(40))}`, + ` ${t.label("Branch")} ${result.branch}`, + ` ${t.label("Author")} ${result.username} <${result.email}>`, + "", + ].join("\n"); +} + +export function renderPullRequestResult(result: GithubPullRequestResult): string { + return [ + "", + ` ${t.brandBold("Pull Request Created")}`, + ` ${t.muted("─".repeat(40))}`, + ` ${t.label("Number")} ${String(result.number)}`, + ` ${t.label("URL")} ${result.html_url}`, + "", + ].join("\n"); +} diff --git a/src/github/client.ts b/src/github/client.ts new file mode 100644 index 0000000..20bee39 --- /dev/null +++ b/src/github/client.ts @@ -0,0 +1,318 @@ +import type { + GithubAppConfig, + GithubBlobResult, + GithubComment, + GithubCommitResult, + GithubCompareResult, + GithubFileInput, + GithubInstallation, + GithubInstallationToken, + GithubIssue, + GithubPRFile, + GithubPullRequest, + GithubPullRequestResult, + GithubRelease, + GithubRepoRef, + GithubReviewComment, + GithubTag, + GithubTreeEntryInput, + GithubTreeResult, +} from "./types.js"; + +export class GithubClient { + constructor( + private readonly baseUrl: string, + private readonly fetchImpl: typeof fetch = fetch + ) {} + + private async request( + path: string, + init: RequestInit, + token: string, + accept = "application/vnd.github+json" + ): Promise { + const response = await this.fetchImpl(`${this.baseUrl}${path}`, { + ...init, + headers: { + Accept: accept, + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + ...(init.headers || {}), + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`GitHub API ${response.status}: ${text}`); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; + } + + getApp(): Promise<{ slug: string; id: number; name?: string }> { + throw new Error("Use getAppWithJwt instead"); + } + + getAppWithJwt(jwt: string): Promise<{ slug: string; id: number; name?: string }> { + return this.request("/app", { method: "GET" }, jwt); + } + + getRepoInstallation(owner: string, repo: string, jwt: string): Promise { + return this.request(`/repos/${owner}/${repo}/installation`, { method: "GET" }, jwt); + } + + createInstallationToken( + installationId: number, + jwt: string + ): Promise { + return this.request( + `/app/installations/${installationId}/access_tokens`, + { method: "POST", body: JSON.stringify({ permissions: { contents: "write", pull_requests: "write", issues: "write", metadata: "read" } }) }, + jwt + ); + } + + listInstallationRepos(token: string): Promise<{ repositories: { full_name: string; default_branch?: string }[] }> { + return this.request("/installation/repositories", { method: "GET" }, token); + } + + getRepo(owner: string, repo: string, token: string): Promise<{ default_branch: string; permissions?: Record }> { + return this.request(`/repos/${owner}/${repo}`, { method: "GET" }, token); + } + + getBranch(owner: string, repo: string, branch: string, token: string): Promise<{ commit: { sha: string } }> { + return this.request(`/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`, { method: "GET" }, token); + } + + getRef(owner: string, repo: string, ref: string, token: string): Promise<{ object: { sha: string } }> { + return this.request(`/repos/${owner}/${repo}/git/ref/${ref}`, { method: "GET" }, token); + } + + getCommit(owner: string, repo: string, sha: string, token: string): Promise<{ sha: string; tree: { sha: string } }> { + return this.request(`/repos/${owner}/${repo}/git/commits/${sha}`, { method: "GET" }, token); + } + + createRef(owner: string, repo: string, ref: string, sha: string, token: string): Promise { + return this.request( + `/repos/${owner}/${repo}/git/refs`, + { method: "POST", body: JSON.stringify({ ref, sha }) }, + token + ); + } + + createBlob(owner: string, repo: string, content: string, token: string): Promise { + return this.request( + `/repos/${owner}/${repo}/git/blobs`, + { method: "POST", body: JSON.stringify({ content, encoding: "utf-8" }) }, + token + ); + } + + createTree( + owner: string, + repo: string, + baseTree: string, + tree: GithubTreeEntryInput[], + token: string + ): Promise { + return this.request( + `/repos/${owner}/${repo}/git/trees`, + { method: "POST", body: JSON.stringify({ base_tree: baseTree, tree }) }, + token + ); + } + + createCommit( + owner: string, + repo: string, + message: string, + tree: string, + parents: string[], + token: string + ): Promise { + return this.request( + `/repos/${owner}/${repo}/git/commits`, + { method: "POST", body: JSON.stringify({ message, tree, parents }) }, + token + ); + } + + updateRef( + owner: string, + repo: string, + ref: string, + sha: string, + token: string + ): Promise { + return this.request( + `/repos/${owner}/${repo}/git/refs/${ref}`, + { method: "PATCH", body: JSON.stringify({ sha, force: false }) }, + token + ); + } + + createPullRequest( + owner: string, + repo: string, + title: string, + body: string, + head: string, + base: string, + token: string + ): Promise { + return this.request( + `/repos/${owner}/${repo}/pulls`, + { method: "POST", body: JSON.stringify({ title, body, head, base }) }, + token + ); + } + + // ── PR read / review APIs ──────────────────────────────── + + getPullRequest(owner: string, repo: string, number: number, token: string): Promise { + return this.request(`/repos/${owner}/${repo}/pulls/${number}`, { method: "GET" }, token); + } + + async getPullRequestDiff(owner: string, repo: string, number: number, token: string): Promise { + const response = await this.fetchImpl(`${this.baseUrl}/repos/${owner}/${repo}/pulls/${number}`, { + method: "GET", + headers: { + Accept: "application/vnd.github.diff", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (!response.ok) throw new Error(`GitHub API ${response.status}: ${await response.text()}`); + return response.text(); + } + + getPullRequestFiles(owner: string, repo: string, number: number, token: string): Promise { + return this.request(`/repos/${owner}/${repo}/pulls/${number}/files?per_page=100`, { method: "GET" }, token); + } + + listPullRequestCommits( + owner: string, repo: string, number: number, token: string + ): Promise<{ sha: string; commit: { message: string; author: { name: string; date: string } } }[]> { + return this.request(`/repos/${owner}/${repo}/pulls/${number}/commits?per_page=100`, { method: "GET" }, token); + } + + createPullRequestReview( + owner: string, + repo: string, + number: number, + body: string, + event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT", + comments: GithubReviewComment[], + token: string + ): Promise<{ id: number; html_url?: string }> { + const payload: Record = { body, event }; + if (comments.length > 0) { + payload.comments = comments.map((c) => ({ + path: c.path, + line: c.line, + side: c.side || "RIGHT", + body: c.body, + })); + } + return this.request( + `/repos/${owner}/${repo}/pulls/${number}/reviews`, + { method: "POST", body: JSON.stringify(payload) }, + token + ); + } + + // ── Issue / comment APIs ───────────────────────────────── + + getIssue(owner: string, repo: string, number: number, token: string): Promise { + return this.request(`/repos/${owner}/${repo}/issues/${number}`, { method: "GET" }, token); + } + + createIssueComment(owner: string, repo: string, number: number, body: string, token: string): Promise { + return this.request( + `/repos/${owner}/${repo}/issues/${number}/comments`, + { method: "POST", body: JSON.stringify({ body }) }, + token + ); + } + + getIssueComments(owner: string, repo: string, number: number, token: string): Promise { + return this.request(`/repos/${owner}/${repo}/issues/${number}/comments?per_page=100`, { method: "GET" }, token); + } + + addLabels(owner: string, repo: string, number: number, labels: string[], token: string): Promise { + return this.request( + `/repos/${owner}/${repo}/issues/${number}/labels`, + { method: "POST", body: JSON.stringify({ labels }) }, + token + ); + } + + setAssignees(owner: string, repo: string, number: number, assignees: string[], token: string): Promise { + return this.request( + `/repos/${owner}/${repo}/issues/${number}/assignees`, + { method: "POST", body: JSON.stringify({ assignees }) }, + token + ); + } + + // ── Release / tag / compare APIs ───────────────────────── + + listTags(owner: string, repo: string, token: string): Promise { + return this.request(`/repos/${owner}/${repo}/tags?per_page=100`, { method: "GET" }, token); + } + + compareCommits(owner: string, repo: string, base: string, head: string, token: string): Promise { + return this.request( + `/repos/${owner}/${repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`, + { method: "GET" }, + token + ); + } + + createRelease( + owner: string, repo: string, tagName: string, name: string, body: string, token: string + ): Promise { + return this.request( + `/repos/${owner}/${repo}/releases`, + { method: "POST", body: JSON.stringify({ tag_name: tagName, name, body }) }, + token + ); + } + + listMergedPulls(owner: string, repo: string, token: string, perPage = 30): Promise { + return this.request( + `/repos/${owner}/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=${perPage}`, + { method: "GET" }, + token + ); + } +} + +export function filesToTreeEntries( + files: GithubFileInput[], + blobShas: string[] +): GithubTreeEntryInput[] { + return files.map((file, index) => ({ + path: file.repoPath, + mode: "100644", + type: "blob", + sha: blobShas[index], + })); +} + +export function createGithubClient(config: GithubAppConfig, fetchImpl?: typeof fetch): GithubClient { + return new GithubClient(config.apiBaseUrl.replace(/\/$/, ""), fetchImpl); +} + +export function createGithubClientFromToken( + baseUrl = "https://api.github.com", + fetchImpl?: typeof fetch +): GithubClient { + return new GithubClient(baseUrl.replace(/\/$/, ""), fetchImpl); +} + diff --git a/src/github/issue-handler.ts b/src/github/issue-handler.ts new file mode 100644 index 0000000..aaf6e13 --- /dev/null +++ b/src/github/issue-handler.ts @@ -0,0 +1,123 @@ +import type { WeaveConfig, ChatMessage } from "../core/types.js"; +import type { LLMProvider } from "../llm/provider.js"; +import type { GithubIssue } from "./types.js"; +import { createGithubClientFromToken } from "./client.js"; +import { getGithubApiBaseUrl } from "../config.js"; + +export interface TriageResult { + labels: string[]; + priority: "low" | "medium" | "high" | "critical"; + response: string; + assignees: string[]; + applied: boolean; +} + +function parseTriageResponse(text: string): Omit { + try { + const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); + const raw = jsonMatch ? jsonMatch[1].trim() : text.trim(); + const parsed = JSON.parse(raw); + return { + labels: Array.isArray(parsed.labels) ? parsed.labels.map(String) : [], + priority: (["low", "medium", "high", "critical"] as const).includes(parsed.priority) + ? parsed.priority + : "medium", + response: String(parsed.response || ""), + assignees: Array.isArray(parsed.assignees) ? parsed.assignees.map(String) : [], + }; + } catch { + return { + labels: [], + priority: "medium", + response: text.slice(0, 2000), + assignees: [], + }; + } +} + +export function parseIssueFromEvent(payload: any): { + owner: string; + repo: string; + issue: GithubIssue; +} | null { + const issue = payload.issue; + const repo = payload.repository; + if (!issue || !repo) return null; + const [owner, repoName] = repo.full_name.split("/"); + return { + owner, + repo: repoName, + issue: { + number: issue.number, + title: issue.title || "", + body: issue.body || "", + state: issue.state || "open", + user: { login: issue.user?.login || "unknown" }, + labels: Array.isArray(issue.labels) ? issue.labels.map((l: any) => ({ name: l.name || l })) : [], + assignees: Array.isArray(issue.assignees) ? issue.assignees.map((a: any) => ({ login: a.login || a })) : [], + html_url: issue.html_url || "", + }, + }; +} + +export async function triageIssue( + config: WeaveConfig, + provider: LLMProvider, + owner: string, + repo: string, + issue: GithubIssue, + token: string, + model?: string +): Promise { + const client = createGithubClientFromToken(getGithubApiBaseUrl(config)); + + const messages: ChatMessage[] = [ + { + role: "system", + content: `You are an issue triage assistant for the ${owner}/${repo} repository. Analyze the issue and respond with a JSON block (wrapped in \`\`\`json ... \`\`\`) containing: +{ + "labels": ["label1", "label2"], + "priority": "low" | "medium" | "high" | "critical", + "response": "a helpful initial response to the issue author", + "assignees": [] +} + +Use common labels like: "bug", "enhancement", "question", "documentation", "good first issue", "help wanted", "duplicate", "invalid", "wontfix". +Keep the response friendly and ask clarifying questions if the issue is vague. +For priority: "critical" = security/data loss, "high" = broken feature, "medium" = normal, "low" = cosmetic/nice-to-have. +Leave assignees empty unless the issue clearly belongs to a specific area.`, + }, + { + role: "user", + content: `## Issue #${issue.number}: ${issue.title} + +**Author:** ${issue.user.login} +**Existing labels:** ${issue.labels.map((l) => l.name).join(", ") || "none"} + +### Body +${issue.body || "(no description)"}`, + }, + ]; + + const response = await provider.chat(messages, model); + const triage = parseTriageResponse(response); + + let applied = false; + try { + if (triage.labels.length > 0) { + await client.addLabels(owner, repo, issue.number, triage.labels, token); + } + if (triage.response) { + const body = `## Weave Triage\n\n**Priority:** ${triage.priority}\n\n${triage.response}`; + await client.createIssueComment(owner, repo, issue.number, body, token); + } + if (triage.assignees.length > 0) { + await client.setAssignees(owner, repo, issue.number, triage.assignees, token); + } + applied = true; + } catch { + /* could not apply triage actions */ + } + + return { ...triage, applied }; +} diff --git a/src/github/release-notes.ts b/src/github/release-notes.ts new file mode 100644 index 0000000..a73043d --- /dev/null +++ b/src/github/release-notes.ts @@ -0,0 +1,104 @@ +import type { WeaveConfig, ChatMessage } from "../core/types.js"; +import type { LLMProvider } from "../llm/provider.js"; +import { createGithubClientFromToken } from "./client.js"; +import { getGithubApiBaseUrl } from "../config.js"; + +export interface ReleaseNotesOptions { + owner: string; + repo: string; + from: string; + to: string; + tagName?: string; + releaseName?: string; + publish?: boolean; +} + +export interface ReleaseNotesResult { + markdown: string; + published: boolean; + releaseUrl?: string; +} + +export async function generateReleaseNotes( + config: WeaveConfig, + provider: LLMProvider, + options: ReleaseNotesOptions, + token: string, + model?: string +): Promise { + const client = createGithubClientFromToken(getGithubApiBaseUrl(config)); + + const comparison = await client.compareCommits( + options.owner, options.repo, options.from, options.to, token + ); + + const commitSummary = comparison.commits + .map((c) => `- ${c.commit.message.split("\n")[0]} (${c.sha.slice(0, 7)} by ${c.commit.author.name})`) + .join("\n"); + + let mergedPRs = ""; + try { + const pulls = await client.listMergedPulls(options.owner, options.repo, token, 100); + const relevantPRs = pulls.filter((p) => p.merged && p.merge_commit_sha && comparison.commits.some((c) => c.sha === p.merge_commit_sha)); + if (relevantPRs.length > 0) { + mergedPRs = relevantPRs + .map((p) => `- PR #${p.number}: ${p.title} by @${p.user.login}`) + .join("\n"); + } + } catch { + /* PR list unavailable */ + } + + const messages: ChatMessage[] = [ + { + role: "system", + content: `You are a release notes generator. Given a list of commits and merged PRs, produce clean, categorized release notes in markdown. +Categories to use (omit empty ones): +- 🚀 Features +- 🐛 Bug Fixes +- ⚠️ Breaking Changes +- 📚 Documentation +- 🔧 Maintenance +- 🏗️ Infrastructure + +Start with a brief overview sentence, then list changes by category. Use bullet points. +Keep it concise -- one line per change. Reference PR numbers when available.`, + }, + { + role: "user", + content: [ + `## Release: ${options.from} → ${options.to}`, + `**Total commits:** ${comparison.total_commits}`, + "", + "### Commits", + commitSummary || "(no commits)", + "", + mergedPRs ? `### Merged Pull Requests\n${mergedPRs}` : "", + ].filter(Boolean).join("\n"), + }, + ]; + + const markdown = await provider.chat(messages, model); + + let published = false; + let releaseUrl: string | undefined; + + if (options.publish && options.tagName) { + try { + const release = await client.createRelease( + options.owner, + options.repo, + options.tagName, + options.releaseName || options.tagName, + markdown, + token + ); + published = true; + releaseUrl = release.html_url; + } catch { + /* could not create release */ + } + } + + return { markdown, published, releaseUrl }; +} diff --git a/src/github/responder.ts b/src/github/responder.ts new file mode 100644 index 0000000..c7a805e --- /dev/null +++ b/src/github/responder.ts @@ -0,0 +1,130 @@ +import type { WeaveConfig, ChatMessage } from "../core/types.js"; +import type { LLMProvider } from "../llm/provider.js"; +import type { GithubComment } from "./types.js"; +import { createGithubClientFromToken } from "./client.js"; +import { getGithubApiBaseUrl } from "../config.js"; + +export interface MentionEvent { + owner: string; + repo: string; + issueNumber: number; + commentId: number; + commentBody: string; + commentAuthor: string; + isPullRequest: boolean; +} + +export interface MentionResult { + reply: string; + posted: boolean; +} + +function stripTrigger(body: string, trigger = "@weave"): string { + return body.replace(new RegExp(trigger.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), "").trim(); +} + +export function parseMentionFromEvent( + payload: any, + trigger = "@weave" +): MentionEvent | null { + const comment = payload.comment; + if (!comment?.body) return null; + if (!comment.body.toLowerCase().includes(trigger.toLowerCase())) return null; + + const repo = payload.repository; + if (!repo) return null; + + const [owner, repoName] = repo.full_name.split("/"); + const issueNumber = payload.issue?.number ?? payload.pull_request?.number; + if (!issueNumber) return null; + + return { + owner, + repo: repoName, + issueNumber, + commentId: comment.id, + commentBody: comment.body, + commentAuthor: comment.user?.login ?? "unknown", + isPullRequest: !!payload.issue?.pull_request || !!payload.pull_request, + }; +} + +export async function handleMention( + config: WeaveConfig, + provider: LLMProvider, + event: MentionEvent, + token: string, + trigger = "@weave", + model?: string +): Promise { + const client = createGithubClientFromToken(getGithubApiBaseUrl(config)); + const query = stripTrigger(event.commentBody, trigger); + + let prDiffContext = ""; + if (event.isPullRequest) { + try { + const files = await client.getPullRequestFiles(event.owner, event.repo, event.issueNumber, token); + const diffParts: string[] = []; + for (const f of files.slice(0, 20)) { + if (!f.patch) continue; + const patchLines = f.patch.split("\n"); + const truncated = patchLines.length > 200 + ? patchLines.slice(0, 200).join("\n") + "\n... (truncated)" + : f.patch; + diffParts.push(`### ${f.filename}\n\`\`\`diff\n${truncated}\n\`\`\``); + } + prDiffContext = diffParts.join("\n\n"); + } catch { + /* diff not available */ + } + } + + let conversationHistory = ""; + try { + const comments: GithubComment[] = await client.getIssueComments( + event.owner, event.repo, event.issueNumber, token + ); + const recent = comments.slice(-10); + conversationHistory = recent + .map((c) => `**${c.user.login}** (${c.created_at}):\n${c.body}`) + .join("\n\n---\n\n"); + } catch { + /* ignore */ + } + + const messages: ChatMessage[] = [ + { + role: "system", + content: `You are Weave, an AI assistant embedded in a GitHub repository. You help with code reviews, debugging, explanations, and suggestions. +When responding, be concise, helpful, and reference specific code when possible. +If asked to make code changes and a PR diff is available, suggest specific edits. +Format your response in GitHub-flavored markdown.`, + }, + { + role: "user", + content: [ + `Repository: ${event.owner}/${event.repo}`, + `Issue/PR #${event.issueNumber}${event.isPullRequest ? " (Pull Request)" : " (Issue)"}`, + conversationHistory ? `\n## Recent conversation\n${conversationHistory}` : "", + prDiffContext ? `\n## PR Diff\n${prDiffContext}` : "", + `\n## Request from @${event.commentAuthor}\n${query}`, + ].filter(Boolean).join("\n"), + }, + ]; + + const reply = await provider.chat(messages, model); + + let posted = false; + try { + await client.createIssueComment( + event.owner, event.repo, event.issueNumber, + reply, + token + ); + posted = true; + } catch { + /* could not post */ + } + + return { reply, posted }; +} diff --git a/src/github/reviewer.ts b/src/github/reviewer.ts new file mode 100644 index 0000000..1976f9b --- /dev/null +++ b/src/github/reviewer.ts @@ -0,0 +1,145 @@ +import type { WeaveConfig, ChatMessage } from "../core/types.js"; +import type { LLMProvider } from "../llm/provider.js"; +import type { GithubPullRequest, GithubPRFile, GithubReviewComment } from "./types.js"; +import { createGithubClientFromToken } from "./client.js"; +import { getGithubApiBaseUrl } from "../config.js"; + +const MAX_PATCH_LINES = 500; + +interface ReviewResult { + verdict: "approve" | "request_changes" | "comment"; + summary: string; + comments: GithubReviewComment[]; +} + +function truncatePatch(patch: string): string { + const lines = patch.split("\n"); + if (lines.length <= MAX_PATCH_LINES) return patch; + return lines.slice(0, MAX_PATCH_LINES).join("\n") + "\n... (truncated, patch too large)"; +} + +function buildDiffContext(files: GithubPRFile[]): string { + const parts: string[] = []; + for (const f of files) { + if (!f.patch) { + parts.push(`### ${f.filename} (binary or too large, skipped)`); + continue; + } + parts.push(`### ${f.filename} (${f.status}, +${f.additions}/-${f.deletions})`); + parts.push("```diff"); + parts.push(truncatePatch(f.patch)); + parts.push("```"); + } + return parts.join("\n"); +} + +function parseReviewResponse(text: string): ReviewResult { + try { + const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); + const raw = jsonMatch ? jsonMatch[1].trim() : text.trim(); + const parsed = JSON.parse(raw); + const verdict = (["approve", "request_changes", "comment"] as const).includes(parsed.verdict) + ? parsed.verdict + : "comment"; + const comments: GithubReviewComment[] = Array.isArray(parsed.comments) + ? parsed.comments.map((c: any) => ({ + path: String(c.path || c.file || ""), + line: typeof c.line === "number" ? c.line : undefined, + body: String(c.body || c.comment || ""), + })).filter((c: GithubReviewComment) => c.path && c.body) + : []; + return { verdict, summary: String(parsed.summary || ""), comments }; + } catch { + return { verdict: "comment", summary: text.slice(0, 2000), comments: [] }; + } +} + +const REVIEW_EVENT_MAP = { + approve: "APPROVE", + request_changes: "REQUEST_CHANGES", + comment: "COMMENT", +} as const; + +export async function reviewPullRequest( + config: WeaveConfig, + provider: LLMProvider, + owner: string, + repo: string, + prNumber: number, + token: string, + model?: string +): Promise<{ pr: GithubPullRequest; review: ReviewResult; submitted: boolean }> { + const client = createGithubClientFromToken(getGithubApiBaseUrl(config)); + const [pr, files] = await Promise.all([ + client.getPullRequest(owner, repo, prNumber, token), + client.getPullRequestFiles(owner, repo, prNumber, token), + ]); + + const diffContext = buildDiffContext(files); + + const messages: ChatMessage[] = [ + { + role: "system", + content: `You are a senior code reviewer. Analyze the pull request diff and produce a thorough review. +Respond with a JSON block (wrapped in \`\`\`json ... \`\`\`) containing: +{ + "verdict": "approve" | "request_changes" | "comment", + "summary": "overall review summary", + "comments": [ + { "path": "filename", "line": , "body": "inline comment" } + ] +} +Only use "request_changes" for actual bugs or serious issues. Use "comment" for suggestions. +Keep comments concise and actionable. If the code looks good, use "approve".`, + }, + { + role: "user", + content: `## PR #${pr.number}: ${pr.title} + +**Author:** ${pr.user.login} +**Base:** ${pr.base.ref} ← **Head:** ${pr.head.ref} +**Changes:** ${pr.changed_files} files, +${pr.additions}/-${pr.deletions} + +### Description +${pr.body || "(no description)"} + +### Diff +${diffContext}`, + }, + ]; + + const response = await provider.chat(messages, model); + const review = parseReviewResponse(response); + + let submitted = false; + try { + await client.createPullRequestReview( + owner, + repo, + prNumber, + `## Weave Review\n\n${review.summary}`, + REVIEW_EVENT_MAP[review.verdict], + review.comments, + token + ); + submitted = true; + } catch (err) { + const safeComments = review.comments.filter((c) => !c.line); + if (safeComments.length < review.comments.length) { + try { + await client.createPullRequestReview( + owner, repo, prNumber, + `## Weave Review\n\n${review.summary}\n\n${review.comments.map((c) => `- **${c.path}**: ${c.body}`).join("\n")}`, + REVIEW_EVENT_MAP[review.verdict], + [], + token + ); + submitted = true; + } catch { + /* fall through to return submitted=false */ + } + } + } + + return { pr, review, submitted }; +} diff --git a/src/github/types.ts b/src/github/types.ts new file mode 100644 index 0000000..4ed54b2 --- /dev/null +++ b/src/github/types.ts @@ -0,0 +1,139 @@ +export interface GithubAppConfig { + appId: string; + privateKey: string; + apiBaseUrl: string; + owner?: string; + repo?: string; +} + +export interface GithubInstallation { + id: number; + account: { + login: string; + }; + repositories_url?: string; +} + +export interface GithubInstallationToken { + token: string; + expires_at: string; +} + +export interface GithubRepoRef { + ref: string; + sha: string; +} + +export interface GithubBlobResult { + sha: string; +} + +export interface GithubTreeEntryInput { + path: string; + mode: "100644" | "100755" | "040000" | "120000"; + type: "blob" | "tree" | "commit"; + sha: string | null; +} + +export interface GithubTreeResult { + sha: string; +} + +export interface GithubCommitResult { + sha: string; + html_url?: string; +} + +export interface GithubPullRequestResult { + number: number; + html_url: string; +} + +export interface GithubFileInput { + repoPath: string; + content: string; +} + +// ── PR / Issue / Review types ───────────────────────────── + +export interface GithubPullRequest { + number: number; + title: string; + body: string; + state: string; + user: { login: string }; + head: { ref: string; sha: string }; + base: { ref: string; sha: string }; + html_url: string; + diff_url: string; + changed_files: number; + additions: number; + deletions: number; + merged?: boolean; + merge_commit_sha?: string; +} + +export interface GithubPRFile { + filename: string; + status: string; + additions: number; + deletions: number; + patch?: string; +} + +export interface GithubComment { + id: number; + body: string; + user: { login: string }; + created_at: string; + html_url?: string; +} + +export interface GithubReviewComment { + path: string; + line?: number; + side?: "LEFT" | "RIGHT"; + body: string; +} + +export interface GithubReviewInput { + body: string; + event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; + comments?: GithubReviewComment[]; +} + +export interface GithubIssue { + number: number; + title: string; + body: string; + state: string; + user: { login: string }; + labels: { name: string }[]; + assignees: { login: string }[]; + html_url: string; + pull_request?: { url: string }; +} + +export interface GithubRelease { + id: number; + tag_name: string; + name: string; + body: string; + html_url: string; + draft: boolean; + prerelease: boolean; +} + +export interface GithubTag { + name: string; + commit: { sha: string }; +} + +export interface GithubCompareResult { + total_commits: number; + commits: { + sha: string; + commit: { message: string; author: { name: string; date: string } }; + }[]; + files?: GithubPRFile[]; +} diff --git a/src/github/write-flow.ts b/src/github/write-flow.ts new file mode 100644 index 0000000..643f358 --- /dev/null +++ b/src/github/write-flow.ts @@ -0,0 +1,352 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { execSync } from "node:child_process"; +import type { WeaveConfig } from "../core/types.js"; +import { loadGithubAppConfig, createGithubAppJwt } from "./app-auth.js"; +import { createGithubClient, filesToTreeEntries } from "./client.js"; +import type { + GithubAppConfig, + GithubFileInput, + GithubPullRequestResult, +} from "./types.js"; + +export interface GithubRepoSelection { + owner: string; + repo: string; +} + +export interface GithubBranchResult { + owner: string; + repo: string; + branch: string; + sha: string; +} + +export interface GithubCommitFlowResult { + owner: string; + repo: string; + branch: string; + commitSha: string; + changedFiles: string[]; +} + +export interface BotPushResult { + branch: string; + username: string; + email: string; +} + +// ── GitHub App auth helpers (for API-based branch/PR creation) ── + +function requireAppConfig(config: WeaveConfig): GithubAppConfig { + const appConfig = loadGithubAppConfig(config); + if (!appConfig) { + throw new Error( + "GitHub App is not configured. Set githubAppId and githubAppPrivateKeyPath (or githubAppPrivateKey)." + ); + } + return appConfig; +} + +export function resolveOwnerRepo( + config: WeaveConfig, + owner?: string, + repo?: string +): GithubRepoSelection { + const appConfig = loadGithubAppConfig(config); + const resolvedOwner = owner || appConfig?.owner || config.githubOwner; + const resolvedRepo = repo || appConfig?.repo || config.githubRepo; + if (!resolvedOwner || !resolvedRepo) { + throw new Error("Provide --owner and --repo or configure githubOwner/githubRepo."); + } + return { owner: resolvedOwner, repo: resolvedRepo }; +} + +export async function getInstallationTokenForRepo( + config: WeaveConfig, + owner: string, + repo: string, + fetchImpl?: typeof fetch +): Promise<{ token: string; client: ReturnType }> { + const appConfig = requireAppConfig(config); + const client = createGithubClient(appConfig, fetchImpl); + const jwt = createGithubAppJwt(appConfig.appId, appConfig.privateKey); + const installation = await client.getRepoInstallation(owner, repo, jwt); + const token = await client.createInstallationToken(installation.id, jwt); + return { token: token.token, client }; +} + +export async function getGithubAppStatus( + config: WeaveConfig, + fetchImpl?: typeof fetch +): Promise<{ + appSlug: string; + owner?: string; + repo?: string; +}> { + const appConfig = requireAppConfig(config); + const client = createGithubClient(appConfig, fetchImpl); + const jwt = createGithubAppJwt(appConfig.appId, appConfig.privateKey); + const app = await client.getAppWithJwt(jwt); + return { + appSlug: app.slug, + owner: appConfig.owner, + repo: appConfig.repo, + }; +} + +export async function listConnectedRepos( + config: WeaveConfig, + owner?: string, + repo?: string, + fetchImpl?: typeof fetch +): Promise<{ full_name: string; default_branch?: string }[]> { + const target = resolveOwnerRepo(config, owner, repo); + const { token, client } = await getInstallationTokenForRepo(config, target.owner, target.repo, fetchImpl); + const result = await client.listInstallationRepos(token); + return result.repositories; +} + +export async function getGithubBranchInfo( + config: WeaveConfig, + input: { + owner?: string; + repo?: string; + branch: string; + }, + fetchImpl?: typeof fetch +): Promise<{ owner: string; repo: string; branch: string; sha: string }> { + const target = resolveOwnerRepo(config, input.owner, input.repo); + const { token, client } = await getInstallationTokenForRepo(config, target.owner, target.repo, fetchImpl); + const branch = await client.getBranch(target.owner, target.repo, input.branch, token); + return { + owner: target.owner, + repo: target.repo, + branch: input.branch, + sha: branch.commit.sha, + }; +} + +// ── Branch creation (via GitHub App API) ── + +async function getRefIfExists( + config: WeaveConfig, + owner: string, + repo: string, + ref: string, + fetchImpl?: typeof fetch +): Promise<{ sha: string } | null> { + try { + const { token, client } = await getInstallationTokenForRepo(config, owner, repo, fetchImpl); + const result = await client.getRef(owner, repo, ref, token); + return { sha: result.object.sha }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes("GitHub API 404")) return null; + throw err; + } +} + +async function assertBranchPathIsCreatable( + config: WeaveConfig, + owner: string, + repo: string, + branch: string, + fetchImpl?: typeof fetch +): Promise { + const existingBranch = await getRefIfExists(config, owner, repo, `heads/${branch}`, fetchImpl); + if (existingBranch) { + throw new Error(`Branch "${branch}" already exists.`); + } + + const parts = branch.split("/"); + if (parts.length <= 1) return; + + const prefixes: string[] = []; + for (let i = 0; i < parts.length - 1; i++) { + prefixes.push(parts.slice(0, i + 1).join("/")); + } + + for (const prefix of prefixes) { + const conflicting = await getRefIfExists(config, owner, repo, `heads/${prefix}`, fetchImpl); + if (conflicting) { + throw new Error( + `Cannot create branch "${branch}" because branch "${prefix}" already exists. GitHub cannot create nested branch refs under an existing branch name. Use a different branch name like "${branch.replace(/\//g, "-")}".` + ); + } + } +} + +export async function createGithubBranch( + config: WeaveConfig, + input: { + owner?: string; + repo?: string; + branch: string; + baseBranch?: string; + }, + fetchImpl?: typeof fetch +): Promise { + const target = resolveOwnerRepo(config, input.owner, input.repo); + await assertBranchPathIsCreatable(config, target.owner, target.repo, input.branch, fetchImpl); + const { token, client } = await getInstallationTokenForRepo(config, target.owner, target.repo, fetchImpl); + const repoInfo = await client.getRepo(target.owner, target.repo, token); + const baseBranch = input.baseBranch || repoInfo.default_branch; + const base = await client.getBranch(target.owner, target.repo, baseBranch, token); + const created = await client.createRef( + target.owner, + target.repo, + `refs/heads/${input.branch}`, + base.commit.sha, + token + ); + return { + owner: target.owner, + repo: target.repo, + branch: input.branch, + sha: created.sha, + }; +} + +// ── File commit via GitHub App API ── + +export function loadLocalFilesForCommit( + dir: string, + filePaths: string[] +): GithubFileInput[] { + if (filePaths.length === 0) { + throw new Error("Provide at least one file path to commit."); + } + return filePaths.map((filePath) => { + const fullPath = path.resolve(dir, filePath); + if (!fs.existsSync(fullPath)) { + throw new Error(`File not found: ${filePath}`); + } + if (fs.statSync(fullPath).isDirectory()) { + throw new Error(`Expected file but found directory: ${filePath}`); + } + return { + repoPath: filePath.replace(/\\/g, "/"), + content: fs.readFileSync(fullPath, "utf-8"), + }; + }); +} + +export async function createGithubCommitFromFiles( + config: WeaveConfig, + input: { + owner?: string; + repo?: string; + branch: string; + message: string; + dir: string; + filePaths: string[]; + }, + fetchImpl?: typeof fetch +): Promise { + const target = resolveOwnerRepo(config, input.owner, input.repo); + const files = loadLocalFilesForCommit(input.dir, input.filePaths); + const { token, client } = await getInstallationTokenForRepo(config, target.owner, target.repo, fetchImpl); + + const ref = await client.getRef(target.owner, target.repo, `heads/${input.branch}`, token); + const parentSha = ref.object.sha; + const parentCommit = await client.getCommit(target.owner, target.repo, parentSha, token); + const blobs = await Promise.all( + files.map((file) => client.createBlob(target.owner, target.repo, file.content, token)) + ); + const tree = await client.createTree( + target.owner, + target.repo, + parentCommit.tree.sha, + filesToTreeEntries(files, blobs.map((blob) => blob.sha)), + token + ); + const commit = await client.createCommit( + target.owner, + target.repo, + input.message, + tree.sha, + [parentSha], + token + ); + await client.updateRef(target.owner, target.repo, `heads/${input.branch}`, commit.sha, token); + + return { + owner: target.owner, + repo: target.repo, + branch: input.branch, + commitSha: commit.sha, + changedFiles: files.map((file) => file.repoPath), + }; +} + +// ── Bot push (the simple way -- like Cursor / Claude Code) ── + +/** + * Commit as the bot identity and push using the user's existing git credentials. + * GitHub maps the commit email to the bot's GitHub account, so it shows up + * as a contributor. No bot PAT or GitHub API needed. + */ +export function gitCommitAndPushAsBot( + input: { + branch: string; + message: string; + dir: string; + botUsername?: string; + } +): BotPushResult { + const username = input.botUsername || process.env.WEAVE_TEST_GITHUB_BOT_USERNAME || "weave-cli"; + const email = `${username}@users.noreply.github.com`; + const gitOpts = { cwd: input.dir, encoding: "utf-8" as BufferEncoding }; + + execSync(`git add -A`, gitOpts); + + try { + execSync( + `git -c user.name="${username}" -c user.email="${email}" commit -m "${input.message.replace(/"/g, '\\"')}"`, + gitOpts + ); + } catch (err: unknown) { + const e = err as { stdout?: string; stderr?: string }; + if (e.stdout?.includes("nothing to commit") || e.stderr?.includes("nothing to commit")) { + throw new Error("Nothing to commit -- working tree is clean."); + } + throw err; + } + + execSync(`git push origin HEAD:refs/heads/${input.branch}`, gitOpts); + + return { + branch: input.branch, + username, + email, + }; +} + +// ── PR creation (via GitHub App API) ── + +export async function createGithubPullRequest( + config: WeaveConfig, + input: { + owner?: string; + repo?: string; + title: string; + body?: string; + head: string; + base?: string; + }, + fetchImpl?: typeof fetch +): Promise { + const target = resolveOwnerRepo(config, input.owner, input.repo); + const { token, client } = await getInstallationTokenForRepo(config, target.owner, target.repo, fetchImpl); + const repoInfo = await client.getRepo(target.owner, target.repo, token); + return client.createPullRequest( + target.owner, + target.repo, + input.title, + input.body || "", + input.head, + input.base || repoInfo.default_branch, + token + ); +} diff --git a/src/index.ts b/src/index.ts index d16b720..b3cff60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,10 @@ #!/usr/bin/env node +import * as path from "node:path"; import { Command } from "commander"; import { loadConfig, + saveConfig, setConfigValue, getConfigValue, resolveApiKey, @@ -11,16 +13,176 @@ import { ensureConfigDir, } from "./config.js"; import { t, icons, banner, table, successLine, errorLine } from "./ui/theme.js"; +import type { ChatMessage } from "./core/types.js"; const VERSION = "0.4.0"; const program = new Command(); program - .name("weave") - .description("Graph-native memory CLI for AI agents") + .name("weave-test") + .description("Testing-native multi-agent CLI with persistent memory") .version(VERSION, "-v, --version"); +// ── weave update ─────────────────────────────────────────── +program + .command("update") + .description("Check for updates and upgrade to the latest version") + .action(async () => { + const { execSync } = await import("node:child_process"); + console.log(`\n ${t.brandBold("Weave Update")}`); + console.log(` ${t.muted("─".repeat(40))}`); + console.log(` ${t.dim("Current version:")} ${t.accent(VERSION)}`); + try { + const latest = execSync("npm view memweave version", { + encoding: "utf-8", + timeout: 10000, + }).trim(); + console.log(` ${t.dim("Latest version:")} ${t.accent(latest)}`); + if (latest === VERSION) { + console.log(`\n ${t.success(icons.check)} Already up to date!\n`); + return; + } + console.log(`\n ${t.dim("Installing memweave@latest...")}\n`); + execSync("npm install -g memweave@latest", { + encoding: "utf-8", + stdio: "inherit", + timeout: 60000, + }); + console.log(`\n ${t.success(icons.check)} Updated to ${latest}!\n`); + } catch (err) { + console.log(errorLine(err instanceof Error ? err.message : String(err))); + console.log(`\n ${t.dim("Try manually:")} npm install -g memweave@latest\n`); + process.exit(1); + } + }); + +// ── weave run (headless) ─────────────────────────────────── +program + .command("run") + .description("Run a single prompt headlessly and exit") + .argument("", "Prompt to send to the agent") + .option("-a, --agent ", "Agent to use", "assistant") + .option("-m, --model ", "Model to use") + .option("-p, --provider ", "LLM provider (openai|anthropic|ollama|lmstudio)") + .option("-w, --workspace ", "Workspace to use", "default") + .option( + "--output-format ", + "Output format: text | json | stream-json", + "text" + ) + .action(async (prompt, options) => { + const config = loadConfig(); + const providerName = options.provider || config.provider; + const model = options.model || config.model; + const apiKey = resolveApiKey({ ...config, provider: providerName }); + const isLocal = providerName === "ollama" || providerName === "lmstudio"; + + if (!apiKey && !isLocal) { + console.error("No API key found. Set one with: weave-test config set apiKey "); + process.exit(1); + } + + const workspacePath = getWorkspacePath(options.workspace); + const { MemoryFabric } = await import("./core/fabric.js"); + const { createProvider } = await import("./llm/provider.js"); + const { builtinTools } = await import("./tools/definitions.js"); + const { executeTool } = await import("./tools/executor.js"); + + const fabric = await MemoryFabric.create({ + ...config, + provider: providerName, + model, + apiKey, + workspacePath, + }); + + const agent = fabric.getOrCreateAgent(options.agent, { + name: options.agent, + role: "AI Assistant", + model, + provider: providerName, + }); + + const baseURL = isLocal && config.baseURL ? config.baseURL : undefined; + const llm = createProvider(providerName, apiKey!, model, baseURL); + + // Build initial messages with recalled context + const recalled = await agent.recall(prompt, 5, "hybrid"); + let systemPrompt = agent.buildSystemPrompt(); + if (recalled.length > 0) { + systemPrompt += + "\n\n## Recalled Context\n" + + recalled + .map((r, i) => `[Memory ${i + 1}, relevance=${r.score.toFixed(2)}] ${r.node.content}`) + .join("\n"); + } + + const messages: ChatMessage[] = [ + { role: "system", content: systemPrompt }, + { role: "user", content: prompt }, + ]; + + const format = options.outputFormat || "text"; + + try { + // Agentic loop — tools auto-approved (headless = yolo) + let fullText = ""; + let iterations = 0; + const MAX_ITER = 10; + + while (iterations++ < MAX_ITER) { + const response = await llm.chatWithTools(messages, builtinTools, model); + + if (response.toolCalls.length > 0) { + const toolResults: { callId: string; output: string }[] = []; + for (const call of response.toolCalls) { + const result = await executeTool(call.name, call.args); + toolResults.push({ callId: call.id, output: result.output }); + } + const toolMsgs = llm.buildToolResultMessages(response.toolCalls, toolResults); + messages.push(...toolMsgs); + continue; + } + + if (response.text) { + fullText = response.text; + } else { + for await (const token of llm.stream(messages, model)) { + fullText += token; + if (format === "stream-json") { + process.stdout.write(JSON.stringify({ type: "delta", text: token }) + "\n"); + } + } + } + break; + } + + agent.addChatMessage({ role: "user", content: prompt }); + agent.addChatMessage({ role: "assistant", content: fullText }); + fabric.autoSave(); + fabric.close(); + + if (format === "json") { + console.log( + JSON.stringify({ prompt, response: fullText, agent: options.agent, model, provider: providerName }) + ); + } else if (format === "stream-json") { + console.log(JSON.stringify({ type: "done", text: fullText })); + } else { + console.log(fullText); + } + } catch (err) { + fabric.close(); + if (format === "json") { + console.log(JSON.stringify({ error: err instanceof Error ? err.message : String(err) })); + } else { + console.error(err instanceof Error ? err.message : String(err)); + } + process.exit(1); + } + }); + // ── weave chat ───────────────────────────────────────────── program .command("chat") @@ -29,6 +191,10 @@ program .option("-m, --model ", "Model to use") .option("-p, --provider ", "LLM provider (openai|anthropic|ollama|lmstudio)") .option("-w, --workspace ", "Workspace to use", "default") + .option("--new", "Start a fresh transcript (memories persist)") + .option("--continue", "Restore the last session transcript exactly") + .option("--resume", "Pick a past session from an interactive list") + .option("--yolo", "Auto-approve all tool actions (no prompts)") .action(async (options) => { const config = loadConfig(); const provider = options.provider || config.provider; @@ -40,18 +206,19 @@ program console.log(""); console.log(errorLine("No API key found. Set one with:")); console.log( - `\n ${t.accent("weave config set apiKey")} ${t.dim("")}` + `\n ${t.accent("weave-test config set apiKey")} ${t.dim("")}` ); console.log( ` ${t.dim("or set")} ${t.accent("OPENAI_API_KEY")} ${t.dim("/")} ${t.accent("ANTHROPIC_API_KEY")} ${t.dim("env var")}` ); console.log( - ` ${t.dim("For OpenAI only: run")} ${t.accent("codex login --api-key ")} ${t.dim("then weave will use ~/.codex/auth.json")}\n` + ` ${t.dim("For OpenAI only: run")} ${t.accent("codex login --api-key ")} ${t.dim("then weave-test will use ~/.codex/auth.json")}\n` ); process.exit(1); } - const workspacePath = getWorkspacePath(options.workspace); + const workspaceName: string = options.workspace; + const workspacePath = getWorkspacePath(workspaceName); const { MemoryFabric } = await import("./core/fabric.js"); const { createProvider } = await import("./llm/provider.js"); @@ -74,6 +241,58 @@ program const baseURL = isLocal && config.baseURL ? config.baseURL : undefined; const llm = createProvider(provider, apiKey!, model, baseURL); + // ── Session restore logic ────────────────────────────── + let initialTranscript: object[] | undefined; + + if (options.resume) { + const { listSessionMeta, loadSessionByIndex } = await import("./core/sessions.js"); + const sessions = listSessionMeta(workspaceName, options.agent); + + if (sessions.length === 0) { + console.log(`\n ${t.dim("No saved sessions for this agent.")}\n`); + } else { + console.log(`\n ${t.brandBold("Saved Sessions")} ${t.dim(`(${workspaceName} · ${options.agent})`)}`); + console.log(` ${t.muted("─".repeat(50))}`); + sessions.forEach((s, i) => { + const date = new Date(s.createdAt).toLocaleString(); + console.log( + ` ${t.accent(String(i + 1).padStart(2))}. ${date} ${t.dim(`${s.messageCount} messages · ${s.model}`)}` + ); + }); + console.log(""); + + const { createInterface } = await import("node:readline"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question(` Enter session number (or Enter to start new): `, resolve); + }); + rl.close(); + + const idx = parseInt(answer.trim(), 10); + if (!isNaN(idx) && idx >= 1 && idx <= sessions.length) { + const session = loadSessionByIndex(workspaceName, options.agent, idx - 1); + if (session) { + initialTranscript = session.transcript; + console.log(` ${t.success(icons.check)} Restored session from ${new Date(session.createdAt).toLocaleString()}\n`); + } + } + } + } else if (options.continue) { + const { loadLastSession } = await import("./core/sessions.js"); + const session = loadLastSession(workspaceName, options.agent); + if (session) { + initialTranscript = session.transcript; + } else { + console.log(` ${t.dim("No previous session found — starting fresh.")}\n`); + } + } + // --new: no initialTranscript (start fresh, same as default but explicit) + + // Load skills for the chat session + const { SkillRegistry } = await import("./skills/registry.js"); + const skillRegistry = new SkillRegistry(); + skillRegistry.load(process.cwd()); + const React = await import("react"); const { render } = await import("ink"); const { default: App } = await import("./ui/app.js"); @@ -83,8 +302,13 @@ program fabric, initialAgent: agent, provider: llm, + providerName: provider, model, version: VERSION, + workspace: workspaceName, + yoloMode: Boolean(options.yolo), + initialTranscript: initialTranscript as any, + skillRegistry, }) ); }); @@ -130,7 +354,7 @@ agentCmd const agents = fabric.listAgents(); if (agents.length === 0) { console.log( - `\n ${t.dim("No agents yet. Create one with:")} ${t.accent("weave agent spawn ")}\n` + `\n ${t.dim("No agents yet. Create one with:")} ${t.accent("weave-test agent spawn ")}\n` ); } else { const rows = agents.map((a) => { @@ -346,6 +570,1024 @@ memoryCmd console.log(` ${t.success(icons.check)} Pruned: ${result.pruned}\n`); }); +// ── weave test ───────────────────────────────────────────── +const testCmd = program.command("test").description("Testing-focused multi-agent workflow"); + +testCmd + .command("init") + .description("Create default testing agents") + .option("-w, --workspace ", "Workspace to use", "default") + .action(async (options) => { + const config = loadConfig(); + const workspacePath = getWorkspacePath(options.workspace); + + const { MemoryFabric } = await import("./core/fabric.js"); + const fabric = await MemoryFabric.create({ ...config, workspacePath }); + + const defaults = [ + { + id: "test-orchestrator", + name: "test-orchestrator", + role: "QA Orchestrator", + description: "Coordinates multi-step test execution and prioritizes fixes by risk.", + }, + { + id: "edge-case-hunter", + name: "edge-case-hunter", + role: "Edge Case Hunter", + description: "Finds negative, boundary, race-condition, and regression edge cases.", + }, + { + id: "report-analyst", + name: "report-analyst", + role: "Test Report Analyst", + description: "Turns noisy test output into actionable summaries and next steps.", + }, + ]; + + for (const persona of defaults) { + fabric.getOrCreateAgent(persona.id, persona); + } + fabric.save(); + fabric.close(); + console.log(successLine("Testing agents initialized.")); + }); + +testCmd + .command("plan") + .description("Show discovered and autonomous testing plan without executing") + .option("-d, --dir ", "Target project directory", process.cwd()) + .option("-m, --model ", "Model to use for autonomous planning") + .option("-p, --provider ", "LLM provider (openai|anthropic|ollama|lmstudio)") + .option("--max-auto ", "Maximum autonomous test commands to add (0 disables)", "0") + .option("--no-autonomous", "Disable autonomous test expansion") + .action(async (options) => { + try { + const { planTestWorkflow } = await import("./testing/run-workflow.js"); + const targetDir = String(options.dir); + const maxAutonomous = Math.max(0, parseInt(String(options.maxAuto), 10) || 0); + const autonomousEnabled = Boolean(options.autonomous); + const plan = await planTestWorkflow({ + workspace: "default", + dir: targetDir, + model: options.model, + provider: options.provider, + maxAuto: maxAutonomous, + autonomous: autonomousEnabled, + }); + + console.log(`\n ${t.brandBold("Testing plan")}`); + console.log(` ${t.muted("─".repeat(50))}`); + for (const cmd of plan.discoveredCommands) { + console.log(` ${t.muted(icons.arrow)} ${cmd.label} ${t.dim(`(${cmd.command})`)}`); + } + if (plan.autonomousCommands.length > 0) { + console.log(` ${t.brandBold("Autonomous additions")}`); + for (const cmd of plan.autonomousCommands) { + console.log(` ${t.muted(icons.arrow)} ${cmd.label} ${t.dim(`(${cmd.command})`)}`); + } + } + console.log(""); + } catch (err) { + console.log(errorLine(err instanceof Error ? err.message : String(err))); + process.exit(1); + } + }); + +testCmd + .command("run") + .description("Run discovered + autonomous tests, analyze results, and persist findings") + .option("-w, --workspace ", "Workspace to use", "default") + .option("-d, --dir ", "Target project directory", process.cwd()) + .option("-m, --model ", "Model to use for testing insights") + .option("-p, --provider ", "LLM provider (openai|anthropic|ollama|lmstudio)") + .option("-t, --timeout ", "Per-command timeout in milliseconds", "120000") + .option("--max-auto ", "Maximum autonomous test commands to add (0 disables)", "0") + .option("--no-autonomous", "Disable autonomous test expansion") + .action(async (options) => { + try { + const { executeTestWorkflow } = await import("./testing/run-workflow.js"); + const targetDir = String(options.dir); + const timeoutMs = parseInt(String(options.timeout), 10); + const maxAutonomous = Math.max(0, parseInt(String(options.maxAuto), 10) || 0); + const autonomousEnabled = Boolean(options.autonomous); + + const result = await executeTestWorkflow({ + workspace: options.workspace, + dir: targetDir, + model: options.model, + provider: options.provider, + timeoutMs, + maxAuto: maxAutonomous, + autonomous: autonomousEnabled, + }); + + console.log(`\n ${t.brandBold("Running testing pipeline")}`); + console.log(` ${t.muted("─".repeat(40))}`); + for (const cmd of result.discoveredCommands) { + console.log(` ${t.muted(icons.arrow)} ${cmd.label} ${t.dim(`(${cmd.command})`)}`); + } + if (result.autonomousCommands.length > 0) { + console.log(` ${t.brandBold("Autonomous expansions")}`); + for (const cmd of result.autonomousCommands) { + console.log(` ${t.muted(icons.arrow)} ${cmd.label} ${t.dim(`(${cmd.command})`)}`); + } + } + console.log(""); + console.log(result.renderedReport); + } catch (err) { + console.log(errorLine(err instanceof Error ? err.message : String(err))); + process.exit(1); + } + }); + +// ── weave-test automation ───────────────────────────────── +const automationCmd = program + .command("automation") + .description("Durable automations for recurring testing workflows"); + +automationCmd + .command("create") + .description("Create a durable automation") + .requiredOption("-n, --name ", "Automation name") + .requiredOption("-d, --dir ", "Target project directory") + .option("-w, --workspace ", "Workspace to store and run in", "default") + .option("--target ", "Automation target (testRun|testPlan)", "testRun") + .option("--every ", "Recurring interval like 15m, 2h, or 1d") + .option("--cron ", "5-field cron expression") + .option("--at