diff --git a/biome.json b/biome.json index 41e8052..56db870 100644 --- a/biome.json +++ b/biome.json @@ -40,13 +40,7 @@ "organizeImports": { "level": "on", "options": { - "groups": [ - [":BUN:", ":NODE:"], - ":BLANK_LINE:", - [":PACKAGE:"], - ":BLANK_LINE:", - ":PATH:" - ] + "groups": [[":BUN:", ":NODE:"], ":BLANK_LINE:", [":PACKAGE:"], ":BLANK_LINE:", ":PATH:"] } } } diff --git a/bun.lock b/bun.lock index 4f6acef..a06c9af 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,14 @@ "": { "name": "@vtemian/opencode-config", "dependencies": { - "@opencode-ai/plugin": "1.1.6", + "@opencode-ai/plugin": "1.1.23", "bun-pty": "^0.4.5", + "valibot": "^1.2.0", }, "devDependencies": { "@biomejs/biome": "^2.3.10", "bun-types": "latest", + "lefthook": "^2.0.13", "typescript": "^5.7.3", }, }, @@ -34,9 +36,9 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.6", "", { "dependencies": { "@opencode-ai/sdk": "1.1.6", "zod": "4.1.8" } }, "sha512-psGajIrj4V03gn85/7Xy5YXdPoCsRGwBsifruG5TfG63+7Jd1TENNufp+SxGb+xtlddDteDMGVHSnE98q9LbDw=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.23", "", { "dependencies": { "@opencode-ai/sdk": "1.1.23", "zod": "4.1.8" } }, "sha512-O/iLSKOUuzD95UWhj9y/tEuycPEBv36de0suHXXqeYLWZLZ16DAUSKR+YG7rvRjJS0sbn4biVMw+k7XXk/oxiQ=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.6", "", {}, "sha512-7Tiso9BExVgxz86VY6F807McCyOgu/SCaQJ87wwxxVSN8GpPpmUIYN5h6LH38EBNJWKXDjokasn/y9EkKxOisQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.23", "", {}, "sha512-YjN9ogzkLol92s+/iARXRop9/5oFIezUkvWVay12u1IM6A/WJs50DeKl3oL0x4a68P1a5tI5gD98dLnk2+AlsA=="], "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], @@ -44,10 +46,34 @@ "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "lefthook": ["lefthook@2.0.15", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.0.15", "lefthook-darwin-x64": "2.0.15", "lefthook-freebsd-arm64": "2.0.15", "lefthook-freebsd-x64": "2.0.15", "lefthook-linux-arm64": "2.0.15", "lefthook-linux-x64": "2.0.15", "lefthook-openbsd-arm64": "2.0.15", "lefthook-openbsd-x64": "2.0.15", "lefthook-windows-arm64": "2.0.15", "lefthook-windows-x64": "2.0.15" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-sl5rePO6UUOLKp6Ci+MMKOc86zicBaPUCvSw2Cq4gCAgTmxpxhIjhz7LOu2ObYerVRPpTq3gvzPTjI71UotjnA=="], + + "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.0.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ygAqG/NzOgY9bEiqeQtiOmCRTtp9AmOd3eyrpEaSrRB9V9f3RHRgWDrWbde9BiHSsCzcbeY9/X2NuKZ69eUsNA=="], + + "lefthook-darwin-x64": ["lefthook-darwin-x64@2.0.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-3wA30CzdSL5MFKD6dk7v8BMq7ScWQivpLbmIn3Pv67AaBavN57N/hcdGqOFnDDFI5WazVwDY7UqDfMIk5HZjEA=="], + + "lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.0.15", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-FbYBBLVbX8BjdO+icN1t/pC3TOW3FAvTKv/zggBKNihv6jHNn/3s/0j2xIS0k0Pw9oOE7MVmEni3qp2j5vqHrQ=="], + + "lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.0.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-udHMjh1E8TfC0Z7Y249XZMATJOyj1Jxlj9JoEinkoBvAsePFKDEQg5teuXuTGhjsHYpqVekfSvLNNfHKUUbbjw=="], + + "lefthook-linux-arm64": ["lefthook-linux-arm64@2.0.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-1HAPmdYhfcOlubv63sTnWtW2rFuC+kT1MvC3JvdrS5V6zrOImbBSnYZMJX/Dd3w4pm0x2ZJb9T+uef8a0jUQkg=="], + + "lefthook-linux-x64": ["lefthook-linux-x64@2.0.15", "", { "os": "linux", "cpu": "x64" }, "sha512-Pho87mlNFH47zc4fPKzQSp8q9sWfIFW/KMMZfx/HZNmX25aUUTOqMyRwaXxtdAo/hNJ9FX4JeuZWq9Y3iyM5VA=="], + + "lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.0.15", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-pet03Edlj1QeFUgxcIK1xu8CeZA+ejYplvPgdfe//69+vQFGSDaEx3H2mVx8RqzWfmMbijM2/WfkZXR2EVw3bw=="], + + "lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.0.15", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i+a364CcSAeIO5wQzLMHsthHt/v6n3XwhKmRq/VBzPOUv9KutNeF55yCE/6lvuvzwxpdEfBjh6cXPERC0yp98w=="], + + "lefthook-windows-arm64": ["lefthook-windows-arm64@2.0.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-69u5GdVOT4QIxc2TK5ce0cTXLzwB55Pk9ZnnJNFf1XsyZTGcg9bUWYYTyD12CIIXbVTa0RVXIIrbU9UgP8O1AQ=="], + + "lefthook-windows-x64": ["lefthook-windows-x64@2.0.15", "", { "os": "win32", "cpu": "x64" }, "sha512-/zYEndCUgj8XK+4wvLYLRk3AcfKU6zWf2GHx+tcZ4K2bLaQdej4m+OqmQsVpUlF8N2tN9hfwlj1D50uz75LUuQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], } } diff --git a/package.json b/package.json index cf159af..60b0d1c 100644 --- a/package.json +++ b/package.json @@ -2,17 +2,18 @@ "name": "micode", "version": "0.8.4", "description": "OpenCode plugin with Brainstorm-Research-Plan-Implement workflow", - "module": "src/index.ts", - "main": "src/index.ts", + "module": "dist/index.js", + "main": "dist/index.js", "types": "src/index.ts", "type": "module", "files": [ "src", + "dist", "INSTALL_CLAUDE.md" ], "scripts": { "prepare": "lefthook install", - "build": "tsc --noEmit", + "build": "bun build src/index.ts --outdir dist --target bun", "typecheck": "tsc --noEmit", "prepublishOnly": "bun run typecheck", "test": "bun test", @@ -39,7 +40,7 @@ "url": "https://github.com/vtemian/micode/issues" }, "dependencies": { - "@opencode-ai/plugin": "1.1.6", + "@opencode-ai/plugin": "1.1.23", "bun-pty": "^0.4.5", "valibot": "^1.2.0" }, diff --git a/src/agents/artifact-searcher.ts b/src/agents/artifact-searcher.ts index f51febd..52919cb 100644 --- a/src/agents/artifact-searcher.ts +++ b/src/agents/artifact-searcher.ts @@ -4,7 +4,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"; export const artifactSearcherAgent: AgentConfig = { description: "Searches past handoffs, plans, and ledgers for relevant precedent", mode: "subagent", - model: "openai/gpt-5.2-codex", temperature: 0.3, tools: { edit: false, diff --git a/src/agents/bootstrapper.ts b/src/agents/bootstrapper.ts new file mode 100644 index 0000000..dd1e7d6 --- /dev/null +++ b/src/agents/bootstrapper.ts @@ -0,0 +1,164 @@ +// src/agents/bootstrapper.ts +import type { AgentConfig } from "@opencode-ai/sdk"; + +export const bootstrapperAgent: AgentConfig = { + description: "Analyzes a request and creates exploration branches with scopes for octto brainstorming", + mode: "subagent", + temperature: 0.5, + prompt: ` +Analyze the user's request and create 2-4 exploration branches. +Each branch explores ONE specific aspect of the design. + + + +Return ONLY a JSON object. No markdown, no explanation. + +{ + "branches": [ + { + "id": "unique_snake_case_id", + "scope": "One sentence describing what this branch explores", + "initial_question": { + "type": "", + "config": { ... } + } + } + ] +} + + + +Each branch explores ONE distinct aspect (not overlapping) +Scope is a clear boundary - questions stay within scope +2-4 branches total - don't over-decompose +Branch IDs are short snake_case identifiers + + + +Request: "Add healthcheck endpoints to the API" + +{ + "branches": [ + { + "id": "services", + "scope": "Which services and dependencies need health monitoring", + "initial_question": { + "type": "pick_many", + "config": { + "question": "Which services should the healthcheck monitor?", + "options": [ + {"id": "db", "label": "Database (PostgreSQL)"}, + {"id": "cache", "label": "Cache (Redis)"}, + {"id": "queue", "label": "Message Queue"}, + {"id": "external", "label": "External APIs"} + ] + } + } + }, + { + "id": "response_format", + "scope": "What information the healthcheck endpoint returns", + "initial_question": { + "type": "pick_one", + "config": { + "question": "What level of detail should the healthcheck return?", + "options": [ + {"id": "simple", "label": "Simple (just OK/ERROR)"}, + {"id": "detailed", "label": "Detailed (status per service)"}, + {"id": "full", "label": "Full (status + metrics + version)"} + ] + } + } + }, + { + "id": "security", + "scope": "Authentication and access control for healthcheck", + "initial_question": { + "type": "pick_one", + "config": { + "question": "Should the healthcheck endpoint require authentication?", + "options": [ + {"id": "public", "label": "Public (no auth)"}, + {"id": "internal", "label": "Internal only (IP whitelist)"}, + {"id": "authenticated", "label": "Requires API key"} + ] + } + } + } + ] +} + + + + +Single choice. config: { question, options: [{id, label, description?}], recommended?, context? } + + + +Multiple choice. config: { question, options: [{id, label, description?}], recommended?: string[], min?, max?, context? } + + + +Yes/no. config: { question, context?, yesLabel?, noLabel?, allowCancel? } + + + +Free text. config: { question, placeholder?, context?, multiline? } + + + +Numeric range. config: { question, min, max, step?, defaultValue?, context? } + + + +Order items. config: { question, options: [{id, label, description?}], context? } + + + +Rate items (stars). config: { question, options: [{id, label, description?}], min?, max?, context? } + + + +Thumbs up/down. config: { question, context? } + + + +Options with pros/cons. config: { question, options: [{id, label, description?, pros?: string[], cons?: string[]}], recommended?, allowFeedback?, context? } + + + +Code diff review. config: { question, before, after, filePath?, language? } + + + +Code input. config: { question, language?, placeholder?, context? } + + + +Image upload. config: { question, multiple?, maxImages?, context? } + + + +File upload. config: { question, multiple?, maxFiles?, accept?: string[], context? } + + + +Emoji selection. config: { question, emojis?: string[], context? } + + + +Section review. config: { question, content, context? } + + + +Plan review. config: { question, sections: [{id, title, content}] } + + + + +Never create more than 4 branches +Never create overlapping scopes +Never wrap output in markdown code blocks +Never include text outside the JSON +`, +}; diff --git a/src/agents/brainstormer.ts b/src/agents/brainstormer.ts index 9f963b2..6b43dc9 100644 --- a/src/agents/brainstormer.ts +++ b/src/agents/brainstormer.ts @@ -1,9 +1,8 @@ import type { AgentConfig } from "@opencode-ai/sdk"; export const brainstormerAgent: AgentConfig = { - description: "Refines rough ideas into fully-formed designs through collaborative questioning", + description: "Refines rough ideas into fully-formed designs through decisive collaboration", mode: "primary", - model: "openai/gpt-5.2-codex", temperature: 0.7, tools: { spawn_agent: false, // Primary agents use built-in Task tool, not spawn_agent @@ -20,8 +19,69 @@ Turn ideas into fully formed designs through natural collaborative dialogue. This is DESIGN ONLY. The planner agent handles detailed implementation plans. + +You are a SENIOR ENGINEER, not a junior seeking approval. +- Make decisions. Don't ask "what do you think?" - state "I'm doing X because Y." +- State assumptions and proceed. User will correct you if wrong. This is faster than asking. +- When you see a problem, propose a solution. Don't present problems without solutions. +- Trust your judgment. You have context. Use it to make calls. +- Disagreement is good. If user pushes back, discuss briefly, then execute their choice. + + + + Be a thoughtful colleague, not a formal document generator + Write like you're explaining to a smart peer over coffee + Show your thinking - "I'm leaning toward X because..." not just "X is the solution" + Use "we" and "our" - this is collaborative design + Be direct but warm - no corporate speak, no filler phrases + + + + USE MARKDOWN FORMATTING - headers, bullets, bold, whitespace + NEVER write walls of text - break into digestible chunks + Each section gets a ## header + Use bullet points for lists of 3+ items + Use **bold** for key terms and important concepts + Add blank lines between sections for breathing room + Keep paragraphs to 2-3 sentences max + + +## Architecture Overview + +The system treats **artifacts as first-class records** stored in SQLite, decoupled from files. + +**Key insight:** We're shifting from "file-backed" to "event-backed" artifacts. This means: +- Artifacts survive even if source files are deleted +- Search is always consistent with the database +- We don't need to re-index when files move + +The milestone pipeline becomes the single source of truth. + + + +Architecture Overview +The redesigned artifact system treats artifacts as first‑class records stored only in SQLite, decoupled from plan or ledger files. Artifacts are created at milestones (design approved, plan complete, execution done) using a classification agent that chooses exactly one type: feature, decision, or session. The agent scores the milestone content against the agreed criteria, selects the highest‑confidence type, and resolves ties using the deterministic priority order feature → decision → session. Each artifact record includes the complete metadata set you requested... + + + +## [Section Name] + +[1-2 sentence overview of what this section covers] + +**[Key concept 1]:** [Brief explanation] + +- [Detail point] +- [Detail point] +- [Detail point] + +[Optional: transition sentence to next section] + + + - ONE QUESTION AT A TIME: Ask exactly ONE question, then STOP and wait for the user's response. NEVER ask multiple questions in a single message. This is the most important rule. + BE PROACTIVE: When the user gives clear direction (e.g., "mark as solved", "fix this", "move to next"), EXECUTE IMMEDIATELY. Don't ask clarifying questions for clear instructions. + Gather requirements through STATEMENTS and PROPOSALS, not questions. "I'm assuming X" beats "What is X?" + CONTINUOUS WORKFLOW: When processing lists/items one-by-one, automatically move to the next item after completing each. Don't wait to be asked "what's next?" NO CODE: Never write code. Never provide code examples. Design only. TOOLS (grep, read, etc.): Do NOT use directly - use subagents instead. Use built-in Task tool to spawn subagents. NEVER use spawn_agent (that's for subagents only). @@ -47,21 +107,21 @@ This is DESIGN ONLY. The planner agent handles detailed implementation plans. Call multiple Task tools in ONE message for parallel execution. Results are available immediately - no polling needed. - Do NOT proceed to questions until you have codebase context + Gather codebase context BEFORE forming your approach purpose, constraints, success criteria Propose 2-3 different approaches with trade-offs - Present options conversationally with your recommendation - Lead with recommended option and explain WHY + Lead with YOUR CHOSEN approach and explain WHY you chose it + Present alternatives briefly as "I considered X but rejected it because..." effort estimate, risks, dependencies - Wait for feedback before proceeding + MAKE THE DECISION. State what you're going to do, then do it. + Only pause if you genuinely cannot choose between equally valid options - Break into sections of 200-300 words - Ask after EACH section: "Does this look right so far?" + Present ALL sections in ONE message - do not pause between sections Architecture overview Key components and responsibilities @@ -69,17 +129,14 @@ This is DESIGN ONLY. The planner agent handles detailed implementation plans. Error handling strategy Testing approach - Don't proceed to next section until current one is validated + After presenting, state: "I'm proceeding to create the design doc. Interrupt if you want changes." + Then IMMEDIATELY proceed to finalizing - don't wait for approval - + Write validated design to thoughts/shared/designs/YYYY-MM-DD-{topic}-design.md Commit the design document to git - Ask: "Ready for the planner to create a detailed implementation plan?" - - - - When user says yes/approved/ready, IMMEDIATELY spawn the planner: + IMMEDIATELY spawn planner - do NOT ask "Ready for planner?" Task( subagent_type="planner", @@ -87,16 +144,11 @@ This is DESIGN ONLY. The planner agent handles detailed implementation plans. description="Create implementation plan" ) - Do NOT ask again - if user approved, spawn planner immediately - - Report: "Implementation plan created at thoughts/shared/plans/YYYY-MM-DD-{topic}.md" - Ask user: "Ready to execute the plan?" - Wait for user response before proceeding - - - When user says yes/execute/go, spawn the executor: + + Report: "Implementation plan created at thoughts/shared/plans/YYYY-MM-DD-{topic}.md" + IMMEDIATELY spawn executor - do NOT ask "Ready to execute?" Task( subagent_type="executor", @@ -104,31 +156,86 @@ This is DESIGN ONLY. The planner agent handles detailed implementation plans. description="Execute implementation plan" ) - - Report executor results to user - YOUR JOB IS DONE. STOP HERE. - Do NOT write any code yourself - + User approved the workflow when they started brainstorming - proceed without asking + + + + Report executor results to user + YOUR JOB IS DONE. STOP HERE. + Do NOT write any code yourself + When user gives direction, EXECUTE it. Don't ask for confirmation on clear instructions. + Propose solutions, make recommendations, drive the conversation forward. You're a helper, not a stenographer. + When processing lists, automatically continue to next item after completing one. No "ready for next?" NO CODE. Describe components, not implementations. Planner writes code. Use Task tool for subagents. They complete before you continue. Multiple Task calls in one message run in parallel - Ask exactly ONE question per message. STOP after asking. Wait for user's answer before continuing. NEVER bundle multiple questions together. + During exploration, STATE your assumptions and proceed. User will correct if wrong. Remove unnecessary features from ALL designs ALWAYS propose 2-3 approaches before settling - Present in sections, validate each before proceeding - When user approves design, IMMEDIATELY spawn planner - don't ask again + Present ALL design sections in ONE message, then proceed immediately + Execute entire workflow (design + plan + execute) without pausing for approval + + You are a HELPER, not just a facilitator. Actively solve problems. + When user presents an issue, propose a concrete solution - don't just ask "what do you want to do?" + When reviewing items (bugs, comments, tasks), state your recommendation and execute it + Execute obvious actions without asking. "Mark as solved" = call the API. "Move to next" = show the next item. + + + Present current item with your analysis and recommendation + If user agrees or gives direction, EXECUTE immediately + After execution, AUTOMATICALLY present the next item - don't ask "ready for next?" + If user disagrees with your recommendation, discuss briefly then execute their choice + Track progress: "Done: 3/10. Moving to #4..." + + + + + ONLY pause for confirmation when there's a genuine decision to make + NEVER ask "Does this look right?" - present and proceed + NEVER ask "Ready for X?" when user already approved the workflow + NEVER ask "Should I proceed?" - if direction is clear, proceed + + + Multiple valid approaches with significant trade-offs - user must choose + Destructive actions (deleting, major rewrites) + + + + Progress updates between sections + Next step in an approved workflow + Obvious follow-up actions + User gave clear direction - execute it + Moving to next item in a list + Marking items as done/resolved + + + + Track what you've done to avoid repeating work + Before any action, check: "Have I already done this?" + If user says "you already did X" - acknowledge and move on + + + - NEVER ask multiple questions in one message - this breaks the collaborative flow + NEVER write walls of text - use headers, bullets, whitespace + NEVER skip markdown formatting - ## headers, **bold**, bullet lists + NEVER write paragraphs longer than 3 sentences + NEVER ask "Does this look right?" - present design and proceed + NEVER ask "Ready for X?" or "Should I proceed?" when workflow is approved or direction is clear + NEVER repeat work you've already done - check state first Never write code snippets or examples Never provide file paths with line numbers Never specify exact function signatures Never jump to implementation details - stay at design level + NEVER be passive - if user needs help, HELP them. Don't just ask what they want. + NEVER wait to be asked "what's next?" when processing a list - continue automatically + NEVER ask "which comment number should we tackle next?" - just move to the next one diff --git a/src/agents/codebase-analyzer.ts b/src/agents/codebase-analyzer.ts index 1c37a06..dead595 100644 --- a/src/agents/codebase-analyzer.ts +++ b/src/agents/codebase-analyzer.ts @@ -3,7 +3,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"; export const codebaseAnalyzerAgent: AgentConfig = { description: "Explains HOW code works with precise file:line references", mode: "subagent", - model: "openai/gpt-5.2-codex", temperature: 0.2, tools: { write: false, diff --git a/src/agents/codebase-locator.ts b/src/agents/codebase-locator.ts index f7cf5f1..de64b7d 100644 --- a/src/agents/codebase-locator.ts +++ b/src/agents/codebase-locator.ts @@ -3,7 +3,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"; export const codebaseLocatorAgent: AgentConfig = { description: "Finds WHERE files live in the codebase", mode: "subagent", - model: "openai/gpt-5.2-codex", temperature: 0.1, tools: { write: false, diff --git a/src/agents/commander.ts b/src/agents/commander.ts index 8b16468..abfb1eb 100644 --- a/src/agents/commander.ts +++ b/src/agents/commander.ts @@ -136,12 +136,46 @@ Just do it - including obvious follow-up actions. Use TodoWrite to track what you're doing Never discard tasks without explicit approval Use journal for insights, failed approaches, preferences -`; + + + + ONLY pause for confirmation when there's a genuine decision to make + NEVER ask "Does this look right?" for progress updates + NEVER ask "Ready for X?" when workflow is already approved + NEVER ask "Should I proceed?" - if direction is clear, proceed + + + Multiple valid approaches exist and choice matters + Would delete or significantly restructure existing code + Requirements are ambiguous and need clarification + Plan needs approval before implementation begins + + + + Next step in an approved workflow + Obvious follow-up actions + Progress updates - report, don't ask + Spawning subagents for approved work + + + + + Track what you've done to avoid repeating work + Before any action, check: "Have I already done this?" + If user says "you already did X" - acknowledge and move on, don't redo + Check if design/plan files exist before creating them + + + + NEVER ask "Does this look right?" after each step - batch updates + NEVER ask "Ready for X?" when user approved the workflow + NEVER repeat work you've already done + NEVER ask for permission to do obvious follow-up actions +`; export const primaryAgent: AgentConfig = { description: "Pragmatic orchestrator. Direct, honest, delegates to specialists.", mode: "primary", - model: "openai/gpt-5.2-codex", temperature: 0.2, thinking: { type: "enabled", diff --git a/src/agents/executor.ts b/src/agents/executor.ts index 61911f3..f1f1a4e 100644 --- a/src/agents/executor.ts +++ b/src/agents/executor.ts @@ -3,7 +3,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"; export const executorAgent: AgentConfig = { description: "Executes plan task-by-task with parallel execution where possible", mode: "subagent", - model: "openai/gpt-5.2-codex", temperature: 0.2, prompt: ` You are running as part of the "micode" OpenCode plugin (NOT Claude Code). @@ -182,7 +181,24 @@ spawn_agent(agent="reviewer", prompt="Review task 3 implementation", description + + You are a SUBAGENT - execute the entire plan without asking for confirmation + NEVER ask "Does this look right?" or "Should I continue?" - just execute + NEVER ask "Ready for next batch?" - if current batch is done, proceed to next + Report final results when ALL tasks are done, not after each task + If a task is blocked after 3 cycles, mark it blocked and continue with other tasks + + + + Track which tasks have been completed to avoid re-executing + Track which review cycles have been done for each task + If resuming, check what's already done before starting + Before spawning an implementer, verify the task hasn't already been completed + + +NEVER ask for confirmation - you're a subagent, just execute the plan +NEVER ask "Does this look right?" or "Should I proceed?" NEVER implement tasks yourself - ALWAYS spawn implementer agents NEVER verify implementations yourself - ALWAYS spawn reviewer agents Never skip dependency analysis @@ -190,5 +206,6 @@ spawn_agent(agent="reviewer", prompt="Review task 3 implementation", description Never skip reviewer for any task Never continue past 3 cycles for a single task Never report success if any task is blocked +Never re-execute tasks that are already completed `, }; diff --git a/src/agents/implementer.ts b/src/agents/implementer.ts index e11731d..b158038 100644 --- a/src/agents/implementer.ts +++ b/src/agents/implementer.ts @@ -3,7 +3,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"; export const implementerAgent: AgentConfig = { description: "Executes implementation tasks from a plan", mode: "subagent", - model: "openai/gpt-5.2-codex", temperature: 0.1, prompt: ` You are running as part of the "micode" OpenCode plugin (NOT Claude Code). @@ -94,11 +93,28 @@ Awaiting guidance. + + You are a SUBAGENT - execute your task completely without asking for confirmation + NEVER ask "Does this look right?" or "Should I continue?" - just execute + NEVER ask for permission to proceed - if you have the task, do it + Report results when done (success or mismatch), don't ask questions along the way + If plan doesn't match reality, report MISMATCH and STOP - don't ask what to do + + + + Before editing a file, check its current state + If the change is already applied, skip it and report already done + Track which files you've modified to avoid duplicate changes + + -Don't guess when uncertain +NEVER ask for confirmation - you're a subagent, just execute +NEVER ask "Does this look right?" or "Should I proceed?" +Don't guess when uncertain - report mismatch instead Don't add features not in plan Don't refactor adjacent code Don't "fix" things outside scope Don't skip verification steps +Don't re-apply changes that are already done `, }; diff --git a/src/agents/index.ts b/src/agents/index.ts index f67877e..c3e6766 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -1,36 +1,44 @@ import type { AgentConfig } from "@opencode-ai/sdk"; + +import { artifactSearcherAgent } from "./artifact-searcher"; +import { bootstrapperAgent } from "./bootstrapper"; import { brainstormerAgent } from "./brainstormer"; -import { codebaseLocatorAgent } from "./codebase-locator"; import { codebaseAnalyzerAgent } from "./codebase-analyzer"; +import { codebaseLocatorAgent } from "./codebase-locator"; +import { PRIMARY_AGENT_NAME, primaryAgent } from "./commander"; +import { executorAgent } from "./executor"; +import { implementerAgent } from "./implementer"; +import { ledgerCreatorAgent } from "./ledger-creator"; +import { octtoAgent } from "./octto"; import { patternFinderAgent } from "./pattern-finder"; import { plannerAgent } from "./planner"; -import { implementerAgent } from "./implementer"; -import { reviewerAgent } from "./reviewer"; -import { executorAgent } from "./executor"; -import { primaryAgent, PRIMARY_AGENT_NAME } from "./commander"; +import { probeAgent } from "./probe"; import { projectInitializerAgent } from "./project-initializer"; -import { ledgerCreatorAgent } from "./ledger-creator"; -import { artifactSearcherAgent } from "./artifact-searcher"; +import { reviewerAgent } from "./reviewer"; export const agents: Record = { - [PRIMARY_AGENT_NAME]: primaryAgent, - brainstormer: brainstormerAgent, - "codebase-locator": codebaseLocatorAgent, - "codebase-analyzer": codebaseAnalyzerAgent, - "pattern-finder": patternFinderAgent, - planner: plannerAgent, - implementer: implementerAgent, - reviewer: reviewerAgent, - executor: executorAgent, - "project-initializer": projectInitializerAgent, - "ledger-creator": ledgerCreatorAgent, - "artifact-searcher": artifactSearcherAgent, + [PRIMARY_AGENT_NAME]: { ...primaryAgent, model: "openai/gpt-5.2-codex" }, + brainstormer: { ...brainstormerAgent, model: "openai/gpt-5.2-codex" }, + bootstrapper: { ...bootstrapperAgent, model: "openai/gpt-5.2-codex" }, + "codebase-locator": { ...codebaseLocatorAgent, model: "openai/gpt-5.2-codex" }, + "codebase-analyzer": { ...codebaseAnalyzerAgent, model: "openai/gpt-5.2-codex" }, + "pattern-finder": { ...patternFinderAgent, model: "openai/gpt-5.2-codex" }, + planner: { ...plannerAgent, model: "openai/gpt-5.2-codex" }, + implementer: { ...implementerAgent, model: "openai/gpt-5.2-codex" }, + reviewer: { ...reviewerAgent, model: "openai/gpt-5.2-codex" }, + executor: { ...executorAgent, model: "openai/gpt-5.2-codex" }, + "project-initializer": { ...projectInitializerAgent, model: "openai/gpt-5.2-codex" }, + "ledger-creator": { ...ledgerCreatorAgent, model: "openai/gpt-5.2-codex" }, + "artifact-searcher": { ...artifactSearcherAgent, model: "openai/gpt-5.2-codex" }, + octto: { ...octtoAgent, model: "openai/gpt-5.2-codex" }, + probe: { ...probeAgent, model: "openai/gpt-5.2-codex" }, }; export { primaryAgent, PRIMARY_AGENT_NAME, brainstormerAgent, + bootstrapperAgent, codebaseLocatorAgent, codebaseAnalyzerAgent, patternFinderAgent, @@ -41,4 +49,6 @@ export { projectInitializerAgent, ledgerCreatorAgent, artifactSearcherAgent, + octtoAgent, + probeAgent, }; diff --git a/src/agents/ledger-creator.ts b/src/agents/ledger-creator.ts index fe5b0bf..86d9d44 100644 --- a/src/agents/ledger-creator.ts +++ b/src/agents/ledger-creator.ts @@ -4,7 +4,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"; export const ledgerCreatorAgent: AgentConfig = { description: "Creates and updates continuity ledgers for session state preservation", mode: "subagent", - model: "openai/gpt-5.2-codex", temperature: 0.2, tools: { edit: false, diff --git a/src/agents/octto.ts b/src/agents/octto.ts new file mode 100644 index 0000000..3bd5590 --- /dev/null +++ b/src/agents/octto.ts @@ -0,0 +1,70 @@ +// src/agents/octto.ts +import type { AgentConfig } from "@opencode-ai/sdk"; + +export const octtoAgent: AgentConfig = { + description: "Runs interactive browser-based brainstorming sessions using branch-based exploration", + mode: "primary", + temperature: 0.7, + prompt: ` +You are running as part of the "micode" OpenCode plugin (NOT Claude Code). +OpenCode is a different platform with its own agent system. +This agent uses browser-based interactive UI for brainstorming sessions. + + + +Run brainstorming sessions using branch-based exploration. +Each branch explores one aspect of the design within its scope. +Opens a browser window where users answer questions interactively. + + + + +Call bootstrapper subagent to create branches: +background_task(agent="bootstrapper", prompt="Create branches for: {request}") +Parse the JSON response to get branches array. + + + +Create brainstorm session with the branches: +create_brainstorm(request="{request}", branches=[...parsed branches...]) +Save the session_id and browser_session_id from the response. + + + +Wait for brainstorm to complete (handles everything automatically): +await_brainstorm_complete(session_id, browser_session_id) +This processes all answers asynchronously and returns when all branches are done. + + + +End the session and write design document: +end_brainstorm(session_id) +Write to thoughts/shared/plans/YYYY-MM-DD-{topic}-design.md + + + + +Start session with branches, returns session_id AND browser_session_id +Wait for all branches to complete - handles answer processing automatically +End session and get final findings + + + +You MUST use create_brainstorm to start sessions - it creates the state file for branch tracking +The bootstrapper returns {"branches": [...]} - pass this directly to create_brainstorm +create_brainstorm returns TWO IDs: session_id (for state) and browser_session_id (for await_brainstorm_complete) +await_brainstorm_complete handles all answer processing - no manual loop needed + + + +NEVER use start_session directly - always use create_brainstorm +NEVER manually loop with get_next_answer - use await_brainstorm_complete instead + + + +After end_brainstorm, write to thoughts/shared/plans/YYYY-MM-DD-{topic}-design.md with: +
Problem statement from original request
+
Findings by branch - each branch's finding
+
Recommended approach - synthesize all findings
+
`, +}; diff --git a/src/agents/pattern-finder.ts b/src/agents/pattern-finder.ts index ff6da38..700d91e 100644 --- a/src/agents/pattern-finder.ts +++ b/src/agents/pattern-finder.ts @@ -3,7 +3,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"; export const patternFinderAgent: AgentConfig = { description: "Finds existing patterns and examples to model after", mode: "subagent", - model: "openai/gpt-5.2-codex", temperature: 0.2, tools: { write: false, diff --git a/src/agents/planner.ts b/src/agents/planner.ts index e2cde6b..2e53de7 100644 --- a/src/agents/planner.ts +++ b/src/agents/planner.ts @@ -3,11 +3,10 @@ import type { AgentConfig } from "@opencode-ai/sdk"; export const plannerAgent: AgentConfig = { description: "Creates detailed implementation plans with exact file paths, complete code examples, and TDD steps", mode: "subagent", - model: "openai/gpt-5.2-codex", temperature: 0.3, prompt: ` You are running as part of the "micode" OpenCode plugin (NOT Claude Code). -You are a SUBAGENT - use spawn_agent tool (not Task tool) to spawn other subagents. +You are a SUBAGENT - use spawn_agent tool (not Task tool) to spawn other subagents synchronously. Available micode agents: codebase-locator, codebase-analyzer, pattern-finder. @@ -19,20 +18,46 @@ Every task is bite-sized (2-5 minutes), with exact paths and complete code. FOLLOW THE DESIGN: The brainstormer's design is the spec. Do not explore alternatives. - SUBAGENTS: Use spawn_agent tool to spawn subagents. They complete before you continue. - TOOLS (grep, read, etc.): Do NOT use directly - use subagents instead. Every code example MUST be complete - never write "add validation here" Every file path MUST be exact - never write "somewhere in src/" Follow TDD: failing test → verify fail → implement → verify pass → commit + MINIMAL RESEARCH: Most plans need 0-3 subagent calls total. Use tools directly first. + + READ THE DESIGN FIRST - it often contains everything you need + USE TOOLS DIRECTLY for simple lookups (read, grep, glob) - no subagent needed + SUBAGENTS are for complex analysis only - not simple file reads + MOST PLANS need zero subagent calls if design is detailed + + + Read a specific file: use Read tool + Find files by name: use Glob tool + Search for a string: use Grep tool + Check if file exists: use Glob tool + Read the design doc: use Read tool + + + + Deep analysis of complex module interactions + Finding non-obvious patterns across many files + Understanding unfamiliar architectural decisions + + + + MAX 3-5 subagent calls per plan - if you need more, you're over-researching + Before spawning a subagent, ask: "Can I do this with a simple Read/Grep?" + ONE round of research - no iterative refinement loops + + + Brainstormer did conceptual research (architecture, patterns, approaches). Your research is IMPLEMENTATION-LEVEL only: -- Exact file paths and line numbers -- Exact function signatures and types -- Exact test file conventions -- Exact import paths +- Exact file paths and line numbers (use Glob/Read directly) +- Exact function signatures and types (use Read directly) +- Exact test file conventions (use Glob/Read directly) +- Exact import paths (use Read directly) All research must serve the design - never second-guess design decisions. @@ -42,23 +67,21 @@ All research must serve the design - never second-guess design decisions. Use these directly - no subagent needed for library research. - + - Find exact file paths needed for implementation. - Examples: "Find exact path to UserService", "Find test directory structure" - spawn_agent(agent="codebase-locator", prompt="Find exact path to UserService", description="Find UserService") + ONLY for: Finding files when you don't know the naming convention. + DON'T USE for: Finding a file you already know exists (use Glob instead). - Get exact signatures and types for code examples. - Examples: "Get function signature for createUser", "Get type definition for UserConfig" - spawn_agent(agent="codebase-analyzer", prompt="Get function signature for createUser", description="Get signature") + ONLY for: Understanding complex module interactions or unfamiliar code. + DON'T USE for: Reading a file (use Read instead). - Find exact patterns to copy in code examples. - Examples: "Find exact test setup pattern", "Find exact error handling in similar endpoint" - spawn_agent(agent="pattern-finder", prompt="Find test setup pattern", description="Find patterns") + ONLY for: Finding patterns across many files when you don't know where to look. + DON'T USE for: Reading an example file you already identified (use Read instead). - Use spawn_agent tool to spawn subagents. Call multiple in ONE message for parallel execution. + MAX 3-5 subagent calls total. If you need more, you're over-researching. + If multiple needed, call in ONE message for parallel execution. @@ -69,28 +92,30 @@ All research must serve the design - never second-guess design decisions. - Read the design document thoroughly + Read the design document using Read tool (NOT a subagent) Identify all components, files, and interfaces mentioned Note any constraints or decisions made by brainstormer + The design doc often contains 80% of what you need - read it carefully - - Spawn subagents using spawn_agent tool (they run synchronously): - - In a SINGLE message, call multiple spawn_agent tools in parallel: - - spawn_agent(agent="codebase-locator", prompt="Find exact path to [component]", description="Find [component]") - - spawn_agent(agent="codebase-analyzer", prompt="Get signature for [function]", description="Get signature") - - spawn_agent(agent="pattern-finder", prompt="Find test setup pattern", description="Find patterns") - - context7_resolve-library-id + context7_query-docs for API docs - - btca_ask for library internals when needed - - Only research what's needed to implement the design - Never research alternatives to design decisions + + MOST PLANS SKIP THIS PHASE - design doc is usually sufficient + + - Glob: Find files by pattern (e.g., "src/**/*.ts") + - Read: Read specific files the design mentions + - Grep: Search for specific strings + + + - MAX 3-5 calls total + - Call all needed subagents in ONE message (parallel) + - If you're spawning more than 5, STOP and reconsider + + ONE round of research only - no iterative refinement Break design into sequential tasks (2-5 minutes each) - For each task, determine exact file paths from research + For each task, determine exact file paths Write complete code examples following CODE_STYLE.md Include exact verification commands with expected output @@ -176,18 +201,33 @@ git commit -m "feat(scope): add specific feature" - -// In a SINGLE message, spawn all research tasks in parallel: -spawn_agent(agent="codebase-locator", prompt="Find UserService path", description="Find UserService") -spawn_agent(agent="codebase-analyzer", prompt="Get createUser signature", description="Get signature") -spawn_agent(agent="pattern-finder", prompt="Find test setup pattern", description="Find patterns") -context7_resolve-library-id(libraryName="express") -btca_ask(tech="express", question="middleware chain order") -// All complete before next message - results available immediately - - -// Use all collected results to write the implementation plan - + +// Step 1: Read the design doc directly +Read(file_path="thoughts/shared/designs/2026-01-16-feature-design.md") + +// Step 2: Design mentions src/services/user.ts - read it directly +Read(file_path="src/services/user.ts") + +// Step 3: Need to find test conventions - use Glob, not subagent +Glob(pattern="tests/**/*.test.ts") + +// Step 4: Write the plan - no subagents needed! +Write(file_path="thoughts/shared/plans/2026-01-16-feature.md", content="...") + + + +// WRONG: 18 subagent calls for a simple plan +spawn_agent(agent="codebase-analyzer", prompt="Read src/hooks/...") // Just use Read! +spawn_agent(agent="codebase-locator", prompt="Find existing files under thoughts/...") // Just use Glob! +spawn_agent(agent="codebase-analyzer", prompt="Read thoughts/shared/designs/...") // Just use Read! +// ... 15 more unnecessary subagent calls + + + +// Complex pattern discovery across unfamiliar codebase: +spawn_agent(agent="pattern-finder", prompt="Find auth middleware patterns", description="Find auth patterns") +// That's it - ONE subagent call, not 18 + @@ -202,7 +242,26 @@ btca_ask(tech="express", question="middleware chain order") Extract duplication in code examples + + You are a SUBAGENT - execute your task completely without asking for confirmation + NEVER ask "Does this look right?" or "Should I continue?" - just do your job + NEVER ask "Ready for X?" - if you have the inputs, produce the outputs + Report results when done, don't ask for permission along the way + If you encounter a genuine blocker, report it clearly and stop - don't ask what to do + + + + Before writing a file, check if it already exists with the expected content + Track what research you've done to avoid duplicate subagent calls + If the plan file already exists, read it first before overwriting + + + NEVER spawn a subagent to READ A FILE - use Read tool directly + NEVER spawn a subagent to FIND FILES - use Glob tool directly + NEVER spawn more than 5 subagents total - you're over-researching + NEVER ask for confirmation - you're a subagent, just execute + NEVER ask "Does this look right?" or "Should I proceed?" Never second-guess the design - brainstormer made those decisions Never propose alternative approaches - implement what's in the design Never write "add validation here" - write the actual validation diff --git a/src/agents/probe.ts b/src/agents/probe.ts new file mode 100644 index 0000000..15fba0f --- /dev/null +++ b/src/agents/probe.ts @@ -0,0 +1,121 @@ +// src/agents/probe.ts +import type { AgentConfig } from "@opencode-ai/sdk"; + +export const probeAgent: AgentConfig = { + description: "Evaluates octto branch Q&A and decides whether to ask more or complete with finding", + mode: "subagent", + temperature: 0.5, + prompt: ` +You evaluate a brainstorming branch's Q&A history and decide: +1. Need more information? Return a follow-up question +2. Have enough? Return a finding that synthesizes the user's preferences + + + +You receive: +- The original user request +- All branches with their scopes (to understand the full picture) +- The Q&A history for the branch you're evaluating + + + +Return ONLY a JSON object. No markdown, no explanation. + +If MORE information needed: +{ + "done": false, + "question": { + "type": "pick_one|pick_many|...", + "config": { ... } + } +} + +If ENOUGH information gathered: +{ + "done": true, + "finding": "Clear summary of what the user wants for this aspect" +} + + + +Stay within the branch's scope - don't ask about other branches' concerns +2-4 questions per branch is usually enough - be concise +Complete when you understand the user's intent for this aspect +Synthesize a finding that captures the decision/preference clearly +Choose question types that best fit what you're trying to learn + + + + +Single choice. config: { question, options: [{id, label, description?}], recommended?, context? } + + + +Multiple choice. config: { question, options: [{id, label, description?}], recommended?: string[], min?, max?, context? } + + + +Yes/no. config: { question, context?, yesLabel?, noLabel?, allowCancel? } + + + +Free text. config: { question, placeholder?, context?, multiline? } + + + +Numeric range. config: { question, min, max, step?, defaultValue?, context? } + + + +Order items. config: { question, options: [{id, label, description?}], context? } + + + +Rate items (stars). config: { question, options: [{id, label, description?}], min?, max?, context? } + + + +Thumbs up/down. config: { question, context? } + + + +Options with pros/cons. config: { question, options: [{id, label, description?, pros?: string[], cons?: string[]}], recommended?, allowFeedback?, context? } + + + +Code diff review. config: { question, before, after, filePath?, language? } + + + +Code input. config: { question, language?, placeholder?, context? } + + + +Image upload. config: { question, multiple?, maxImages?, context? } + + + +File upload. config: { question, multiple?, maxFiles?, accept?: string[], context? } + + + +Emoji selection. config: { question, emojis?: string[], context? } + + + +Section review. config: { question, content, context? } + + + +Plan review. config: { question, sections: [{id, title, content}] } + + + + +Never ask questions outside the branch's scope +Never ask more than needed - if you understand, complete the branch +Never wrap output in markdown code blocks +Never include text outside the JSON +Never repeat questions that were already asked +`, +}; diff --git a/src/agents/project-initializer.ts b/src/agents/project-initializer.ts index 130e41b..a40ac3a 100644 --- a/src/agents/project-initializer.ts +++ b/src/agents/project-initializer.ts @@ -218,7 +218,6 @@ Available micode agents: codebase-locator, codebase-analyzer, pattern-finder. export const projectInitializerAgent: AgentConfig = { mode: "subagent", - model: "openai/gpt-5.2-codex", temperature: 0.3, maxTokens: 32000, prompt: PROMPT, diff --git a/src/agents/reviewer.ts b/src/agents/reviewer.ts index 506c10a..83829ca 100644 --- a/src/agents/reviewer.ts +++ b/src/agents/reviewer.ts @@ -3,7 +3,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"; export const reviewerAgent: AgentConfig = { description: "Reviews implementation for correctness and style", mode: "subagent", - model: "openai/gpt-5.2-codex", temperature: 0.3, tools: { write: false, @@ -103,5 +102,20 @@ Check correctness and style. Be specific. Run code, don't just read. Missing functionality Test coverage Style/readability -`, + + + + You are a SUBAGENT - complete your review without asking for confirmation + NEVER ask "Does this look right?" or "Should I continue?" - just review + NEVER ask for permission to run tests or checks - just run them + Report APPROVED or CHANGES REQUESTED - don't ask what to do next + Make a decision and state it clearly - executor handles next steps + + + +NEVER ask for confirmation - you're a subagent, just review +NEVER ask "Does this look right?" or "Should I proceed?" +NEVER hedge your verdict - state APPROVED or CHANGES REQUESTED clearly +Don't defer decisions to executor - make the call yourself +`, }; diff --git a/src/config-loader.test.ts b/src/config-loader.test.ts new file mode 100644 index 0000000..ff65d09 --- /dev/null +++ b/src/config-loader.test.ts @@ -0,0 +1,226 @@ +// src/config-loader.test.ts +import { describe, expect, test } from "bun:test"; + +import { type MicodeConfig, type ProviderInfo, validateAgentModels } from "./config-loader"; + +// Helper to create a minimal ProviderInfo for testing +function createProvider(id: string, modelIds: string[]): ProviderInfo { + const models: Record = {}; + for (const modelId of modelIds) { + models[modelId] = { id: modelId }; + } + return { id, models }; +} + +describe("validateAgentModels", () => { + test("returns config unchanged when all models are valid", () => { + const userConfig: MicodeConfig = { + agents: { + commander: { model: "openai/gpt-4" }, + brainstormer: { model: "anthropic/claude-3" }, + }, + }; + + const providers: ProviderInfo[] = [ + createProvider("openai", ["gpt-4", "gpt-3.5"]), + createProvider("anthropic", ["claude-3", "claude-2"]), + ]; + + const result = validateAgentModels(userConfig, providers); + + expect(result.agents?.commander?.model).toBe("openai/gpt-4"); + expect(result.agents?.brainstormer?.model).toBe("anthropic/claude-3"); + }); + + test("removes model override when provider does not exist", () => { + const userConfig: MicodeConfig = { + agents: { + commander: { model: "nonexistent/gpt-4" }, + }, + }; + + const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4"])]; + + const result = validateAgentModels(userConfig, providers); + + // Model should be removed, falling back to default + expect(result.agents?.commander?.model).toBeUndefined(); + }); + + test("removes model override when model does not exist in provider", () => { + const userConfig: MicodeConfig = { + agents: { + commander: { model: "openai/nonexistent-model" }, + }, + }; + + const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4", "gpt-3.5"])]; + + const result = validateAgentModels(userConfig, providers); + + // Model should be removed, falling back to default + expect(result.agents?.commander?.model).toBeUndefined(); + }); + + test("preserves other properties when model is invalid", () => { + const userConfig: MicodeConfig = { + agents: { + commander: { + model: "nonexistent/model", + temperature: 0.7, + maxTokens: 4000, + }, + }, + }; + + const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4"])]; + + const result = validateAgentModels(userConfig, providers); + + // Model removed but other properties preserved + expect(result.agents?.commander?.model).toBeUndefined(); + expect(result.agents?.commander?.temperature).toBe(0.7); + expect(result.agents?.commander?.maxTokens).toBe(4000); + }); + + test("handles config with no agents", () => { + const userConfig: MicodeConfig = {}; + + const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4"])]; + + const result = validateAgentModels(userConfig, providers); + + expect(result).toEqual({}); + }); + + test("handles agent override with no model specified", () => { + const userConfig: MicodeConfig = { + agents: { + commander: { temperature: 0.5 }, + }, + }; + + const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4"])]; + + const result = validateAgentModels(userConfig, providers); + + // No model to validate, config unchanged + expect(result.agents?.commander?.temperature).toBe(0.5); + expect(result.agents?.commander?.model).toBeUndefined(); + }); + + test("handles empty providers list", () => { + const userConfig: MicodeConfig = { + agents: { + commander: { model: "openai/gpt-4" }, + }, + }; + + const providers: ProviderInfo[] = []; + + const result = validateAgentModels(userConfig, providers); + + // No providers available, config should remain unchanged + expect(result).toEqual(userConfig); + }); + + test("handles providers with no models", () => { + const userConfig: MicodeConfig = { + agents: { + commander: { model: "openai/gpt-4" }, + }, + }; + + const providers: ProviderInfo[] = [{ id: "openai", models: {} }]; + + const result = validateAgentModels(userConfig, providers); + + // No provider models available, config should remain unchanged + expect(result).toEqual(userConfig); + }); + + test("validates multiple agents with mixed valid/invalid models", () => { + const userConfig: MicodeConfig = { + agents: { + commander: { model: "openai/gpt-4" }, // valid + brainstormer: { model: "fake/model" }, // invalid provider + planner: { model: "openai/fake-model" }, // invalid model + reviewer: { model: "anthropic/claude-3" }, // valid + }, + }; + + const providers: ProviderInfo[] = [ + createProvider("openai", ["gpt-4", "gpt-3.5"]), + createProvider("anthropic", ["claude-3"]), + ]; + + const result = validateAgentModels(userConfig, providers); + + expect(result.agents?.commander?.model).toBe("openai/gpt-4"); + expect(result.agents?.brainstormer?.model).toBeUndefined(); + expect(result.agents?.planner?.model).toBeUndefined(); + expect(result.agents?.reviewer?.model).toBe("anthropic/claude-3"); + }); + + test("removes empty string model", () => { + const userConfig: MicodeConfig = { + agents: { + commander: { model: "", temperature: 0.5 }, + }, + }; + + const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4"])]; + + const result = validateAgentModels(userConfig, providers); + + // Empty string model should be removed as invalid + expect(result.agents?.commander?.model).toBeUndefined(); + expect(result.agents?.commander?.temperature).toBe(0.5); + }); + + test("removes model string without slash (malformed)", () => { + const userConfig: MicodeConfig = { + agents: { + commander: { model: "gpt-4-no-provider" }, + }, + }; + + const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4"])]; + + const result = validateAgentModels(userConfig, providers); + + // Malformed model (no slash) should be removed + expect(result.agents?.commander?.model).toBeUndefined(); + }); + + test("handles model with multiple slashes in model ID", () => { + const userConfig: MicodeConfig = { + agents: { + commander: { model: "openai/gpt-4/turbo" }, + }, + }; + + // Model ID is "gpt-4/turbo" (contains slash) + const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4/turbo"])]; + + const result = validateAgentModels(userConfig, providers); + + // Should be valid - "gpt-4/turbo" is the full model ID + expect(result.agents?.commander?.model).toBe("openai/gpt-4/turbo"); + }); + + test("returns consistent shape when all agents have invalid models", () => { + const userConfig: MicodeConfig = { + agents: { + commander: { model: "invalid/model" }, + }, + }; + + const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4"])]; + + const result = validateAgentModels(userConfig, providers); + + // Should return { agents: {} } for consistency, not {} + expect(result).toEqual({ agents: {} }); + }); +}); diff --git a/src/config-loader.ts b/src/config-loader.ts index d467cb3..5e987c9 100644 --- a/src/config-loader.ts +++ b/src/config-loader.ts @@ -1,9 +1,46 @@ // src/config-loader.ts +import { readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import { homedir } from "node:os"; +import { join } from "node:path"; + import type { AgentConfig } from "@opencode-ai/sdk"; +// Minimal type for provider validation - only what we need +export interface ProviderInfo { + id: string; + models: Record; +} + +/** + * Load available models from opencode.json config file (synchronous) + * Returns a Set of "provider/model" strings + */ +export function loadAvailableModels(configDir?: string): Set { + const availableModels = new Set(); + const baseDir = configDir ?? join(homedir(), ".config", "opencode"); + + try { + const configPath = join(baseDir, "opencode.json"); + const content = readFileSync(configPath, "utf-8"); + const config = JSON.parse(content) as { provider?: Record }> }; + + if (config.provider) { + for (const [providerId, providerConfig] of Object.entries(config.provider)) { + if (providerConfig.models) { + for (const modelId of Object.keys(providerConfig.models)) { + availableModels.add(`${providerId}/${modelId}`); + } + } + } + } + } catch { + // Config doesn't exist or can't be parsed - return empty set + } + + return availableModels; +} + // Safe properties that users can override const SAFE_AGENT_PROPERTIES = ["model", "temperature", "maxTokens"] as const; @@ -60,26 +97,53 @@ export async function loadMicodeConfig(configDir?: string): Promise, userConfig: MicodeConfig | null, + availableModels?: Set, ): Record { if (!userConfig?.agents) { return pluginAgents; } + const models = availableModels ?? loadAvailableModels(); + const shouldValidateModels = models.size > 0; + const merged: Record = {}; for (const [name, agentConfig] of Object.entries(pluginAgents)) { const userOverride = userConfig.agents[name]; if (userOverride) { - merged[name] = { - ...agentConfig, - ...userOverride, - }; + // Validate model if specified + if (userOverride.model) { + if (!shouldValidateModels || models.has(userOverride.model)) { + // Model is valid (or validation unavailable) - apply all overrides + merged[name] = { + ...agentConfig, + ...userOverride, + }; + } else { + // Model is invalid - log warning and apply other overrides only + console.warn( + `[micode] Model "${userOverride.model}" for agent "${name}" is not available. Using opencode default.`, + ); + const { model: _ignored, ...safeOverrides } = userOverride; + merged[name] = { + ...agentConfig, + ...safeOverrides, + }; + } + } else { + // No model specified - apply all overrides + merged[name] = { + ...agentConfig, + ...userOverride, + }; + } } else { merged[name] = agentConfig; } @@ -87,3 +151,65 @@ export function mergeAgentConfigs( return merged; } + +/** + * Validate that configured models exist in available providers + * Removes invalid model overrides and logs warnings + */ +export function validateAgentModels(userConfig: MicodeConfig, providers: ProviderInfo[]): MicodeConfig { + if (!userConfig.agents) { + return userConfig; + } + + const hasAnyModels = providers.some((provider) => Object.keys(provider.models).length > 0); + if (!hasAnyModels) { + return userConfig; + } + + // Build lookup map for providers and their models + const providerMap = new Map>(); + for (const provider of providers) { + providerMap.set(provider.id, new Set(Object.keys(provider.models))); + } + + const validatedAgents: Record = {}; + + for (const [agentName, override] of Object.entries(userConfig.agents)) { + // No model specified - keep other properties as-is + if (override.model === undefined) { + validatedAgents[agentName] = override; + continue; + } + + // Empty or whitespace-only model - treat as invalid + const trimmedModel = override.model.trim(); + if (!trimmedModel) { + const { model: _removed, ...otherProps } = override; + console.warn(`[micode] Empty model for agent "${agentName}". Using default model.`); + if (Object.keys(otherProps).length > 0) { + validatedAgents[agentName] = otherProps; + } + continue; + } + + // Parse "provider/model" format + const [providerID, ...rest] = trimmedModel.split("/"); + const modelID = rest.join("/"); + + const providerModels = providerMap.get(providerID); + const isValid = providerModels?.has(modelID) ?? false; + + if (isValid) { + validatedAgents[agentName] = override; + } else { + // Remove invalid model but keep other properties + const { model: _removed, ...otherProps } = override; + console.warn(`[micode] Model "${override.model}" not found for agent "${agentName}". Using default model.`); + if (Object.keys(otherProps).length > 0) { + validatedAgents[agentName] = otherProps; + } + } + } + + return { agents: validatedAgents }; +} diff --git a/src/hooks/artifact-auto-index.ts b/src/hooks/artifact-auto-index.ts index bebc246..648d4c3 100644 --- a/src/hooks/artifact-auto-index.ts +++ b/src/hooks/artifact-auto-index.ts @@ -4,6 +4,7 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { readFileSync } from "node:fs"; import { getArtifactIndex } from "../tools/artifact-index"; +import { log } from "../utils/logger"; const LEDGER_PATH_PATTERN = /thoughts\/ledgers\/CONTINUITY_(.+)\.md$/; const PLAN_PATH_PATTERN = /thoughts\/shared\/plans\/(.+)\.md$/; @@ -102,7 +103,7 @@ export function createArtifactAutoIndexHook(_ctx: PluginInput) { } } catch (e) { // Silent failure - don't interrupt user flow - console.error(`[artifact-auto-index] Error indexing ${filePath}:`, e); + log.error("artifact-auto-index", `Error indexing ${filePath}`, e); } }, }; diff --git a/src/hooks/auto-compact.ts b/src/hooks/auto-compact.ts index 165c0aa..e5a32a8 100644 --- a/src/hooks/auto-compact.ts +++ b/src/hooks/auto-compact.ts @@ -1,15 +1,11 @@ -import type { PluginInput } from "@opencode-ai/plugin"; -import { getContextLimit } from "../utils/model-limits"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -// Compact when this percentage of context is used -const COMPACT_THRESHOLD = 0.5; - -const LEDGER_DIR = "thoughts/ledgers"; +import type { PluginInput } from "@opencode-ai/plugin"; -// Timeout for waiting for compaction to complete (2 minutes) -const COMPACTION_TIMEOUT_MS = 120_000; +import { config } from "../utils/config"; +import { extractErrorMessage } from "../utils/errors"; +import { getContextLimit } from "../utils/model-limits"; interface PendingCompaction { resolve: () => void; @@ -23,9 +19,6 @@ interface AutoCompactState { pendingCompactions: Map; } -// Cooldown between compaction attempts (prevent rapid re-triggering) -const COMPACT_COOLDOWN_MS = 30_000; // 30 seconds - export function createAutoCompactHook(ctx: PluginInput) { const state: AutoCompactState = { inProgress: new Set(), @@ -65,13 +58,13 @@ export function createAutoCompactHook(ctx: PluginInput) { if (!summaryText.trim()) return; // Create ledger directory if needed - const ledgerDir = join(ctx.directory, LEDGER_DIR); + const ledgerDir = join(ctx.directory, config.paths.ledgerDir); await mkdir(ledgerDir, { recursive: true }); // Write ledger file - summary is already structured (Factory.ai/pi-mono format) const timestamp = new Date().toISOString(); const sessionName = sessionID.slice(0, 8); // Use first 8 chars of session ID - const ledgerPath = join(ledgerDir, `CONTINUITY_${sessionName}.md`); + const ledgerPath = join(ledgerDir, `${config.paths.ledgerPrefix}${sessionName}.md`); // Add metadata header, then the structured summary as-is const ledgerContent = `--- @@ -94,7 +87,7 @@ ${summaryText} const timeoutId = setTimeout(() => { state.pendingCompactions.delete(sessionID); reject(new Error("Compaction timed out")); - }, COMPACTION_TIMEOUT_MS); + }, config.compaction.timeoutMs); state.pendingCompactions.set(sessionID, { resolve, reject, timeoutId }); }); @@ -112,7 +105,7 @@ ${summaryText} // Check cooldown const lastCompact = state.lastCompactTime.get(sessionID) || 0; - if (Date.now() - lastCompact < COMPACT_COOLDOWN_MS) { + if (Date.now() - lastCompact < config.compaction.cooldownMs) { return; } @@ -120,7 +113,7 @@ ${summaryText} try { const usedPercent = Math.round(usageRatio * 100); - const thresholdPercent = Math.round(COMPACT_THRESHOLD * 100); + const thresholdPercent = Math.round(config.compaction.threshold * 100); await ctx.client.tui .showToast({ @@ -128,7 +121,7 @@ ${summaryText} title: "Auto Compacting", message: `Context at ${usedPercent}% (threshold: ${thresholdPercent}%). Summarizing...`, variant: "warning", - duration: 3000, + duration: config.timeouts.toastWarningMs, }, }) .catch(() => {}); @@ -158,19 +151,19 @@ ${summaryText} title: "Compaction Complete", message: "Session summarized and ledger updated.", variant: "success", - duration: 3000, + duration: config.timeouts.toastSuccessMs, }, }) .catch(() => {}); } catch (e) { - const errorMsg = e instanceof Error ? e.message : String(e); + const errorMsg = extractErrorMessage(e); await ctx.client.tui .showToast({ body: { title: "Compaction Failed", message: errorMsg.slice(0, 100), variant: "error", - duration: 5000, + duration: config.timeouts.toastErrorMs, }, }) .catch(() => {}); @@ -233,7 +226,7 @@ ${summaryText} const usageRatio = totalUsed / contextLimit; // Trigger compaction if over threshold - if (usageRatio >= COMPACT_THRESHOLD) { + if (usageRatio >= config.compaction.threshold) { triggerCompaction(sessionID, providerID, modelID, usageRatio); } } diff --git a/src/hooks/context-injector.ts b/src/hooks/context-injector.ts index 30048b8..d142571 100644 --- a/src/hooks/context-injector.ts +++ b/src/hooks/context-injector.ts @@ -1,12 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { readFile } from "node:fs/promises"; import { join, dirname, resolve } from "node:path"; - -// Files to inject at project root level (AGENTS.md and CLAUDE.md handled by OpenCode natively) -const ROOT_CONTEXT_FILES = ["ARCHITECTURE.md", "CODE_STYLE.md", "README.md"] as const; - -// Files to collect when walking up directories (AGENTS.md handled by OpenCode natively) -const DIRECTORY_CONTEXT_FILES = ["README.md"] as const; +import { config } from "../utils/config"; // Tools that trigger directory-aware context injection const FILE_ACCESS_TOOLS = ["Read", "read", "Edit", "edit"]; @@ -18,8 +13,6 @@ interface ContextCache { lastRootCheck: number; } -const CACHE_TTL = 30_000; // 30 seconds - export function createContextInjectorHook(ctx: PluginInput) { const cache: ContextCache = { rootContent: new Map(), @@ -30,14 +23,14 @@ export function createContextInjectorHook(ctx: PluginInput) { async function loadRootContextFiles(): Promise> { const now = Date.now(); - if (now - cache.lastRootCheck < CACHE_TTL && cache.rootContent.size > 0) { + if (now - cache.lastRootCheck < config.limits.contextCacheTtlMs && cache.rootContent.size > 0) { return cache.rootContent; } cache.rootContent.clear(); cache.lastRootCheck = now; - for (const filename of ROOT_CONTEXT_FILES) { + for (const filename of config.paths.rootContextFiles) { try { const filepath = join(ctx.directory, filename); const content = await readFile(filepath, "utf-8"); @@ -67,7 +60,7 @@ export function createContextInjectorHook(ctx: PluginInput) { // Walk up from file directory to project root while (currentDir.startsWith(projectRoot) || currentDir === projectRoot) { - for (const filename of DIRECTORY_CONTEXT_FILES) { + for (const filename of config.paths.dirContextFiles) { const contextPath = join(currentDir, filename); const relPath = currentDir.replace(projectRoot, "").replace(/^\//, "") || "."; const key = `${relPath}/${filename}`; @@ -98,7 +91,7 @@ export function createContextInjectorHook(ctx: PluginInput) { cache.directoryContent.set(cacheKey, collected); // Limit cache size - if (cache.directoryContent.size > 100) { + if (cache.directoryContent.size > config.limits.contextCacheMaxSize) { const firstKey = cache.directoryContent.keys().next().value; if (firstKey) cache.directoryContent.delete(firstKey); } @@ -143,7 +136,7 @@ export function createContextInjectorHook(ctx: PluginInput) { ) => { if (!FILE_ACCESS_TOOLS.includes(input.tool)) return; - const filePath = input.args?.file_path as string | undefined; + const filePath = input.args?.filePath as string | undefined; if (!filePath) return; try { diff --git a/src/hooks/context-window-monitor.ts b/src/hooks/context-window-monitor.ts index 206ebdc..07a7e35 100644 --- a/src/hooks/context-window-monitor.ts +++ b/src/hooks/context-window-monitor.ts @@ -1,17 +1,12 @@ import type { PluginInput } from "@opencode-ai/plugin"; +import { config } from "../utils/config"; import { getContextLimit } from "../utils/model-limits"; -// Thresholds for context window warnings -const WARNING_THRESHOLD = 0.7; // 70% - remind there's still room -const CRITICAL_THRESHOLD = 0.85; // 85% - getting tight - interface MonitorState { lastWarningTime: Map; lastUsageRatio: Map; } -const WARNING_COOLDOWN_MS = 120_000; // 2 minutes between warnings - export function createContextWindowMonitorHook(ctx: PluginInput) { const state: MonitorState = { lastWarningTime: new Map(), @@ -21,11 +16,11 @@ export function createContextWindowMonitorHook(ctx: PluginInput) { function getEncouragementMessage(usageRatio: number): string { const remaining = Math.round((1 - usageRatio) * 100); - if (usageRatio < WARNING_THRESHOLD) { + if (usageRatio < config.contextWindow.warningThreshold) { return ""; // No message needed } - if (usageRatio < CRITICAL_THRESHOLD) { + if (usageRatio < config.contextWindow.criticalThreshold) { return `Context: ${remaining}% remaining. Plenty of room - don't rush.`; } @@ -40,7 +35,7 @@ export function createContextWindowMonitorHook(ctx: PluginInput) { ) => { const usageRatio = state.lastUsageRatio.get(input.sessionID); - if (usageRatio && usageRatio >= WARNING_THRESHOLD) { + if (usageRatio && usageRatio >= config.contextWindow.warningThreshold) { const message = getEncouragementMessage(usageRatio); if (message && output.system) { output.system = `${output.system}\n\n${message}`; @@ -80,13 +75,13 @@ export function createContextWindowMonitorHook(ctx: PluginInput) { state.lastUsageRatio.set(sessionID, usageRatio); // Show toast warning if threshold crossed - if (usageRatio >= WARNING_THRESHOLD) { + if (usageRatio >= config.contextWindow.warningThreshold) { const lastWarning = state.lastWarningTime.get(sessionID) || 0; - if (Date.now() - lastWarning > WARNING_COOLDOWN_MS) { + if (Date.now() - lastWarning > config.contextWindow.warningCooldownMs) { state.lastWarningTime.set(sessionID, Date.now()); const remaining = Math.round((1 - usageRatio) * 100); - const variant = usageRatio >= CRITICAL_THRESHOLD ? "warning" : "info"; + const variant = usageRatio >= config.contextWindow.criticalThreshold ? "warning" : "info"; await ctx.client.tui .showToast({ @@ -94,7 +89,7 @@ export function createContextWindowMonitorHook(ctx: PluginInput) { title: "Context Window", message: `${remaining}% remaining (${Math.round(totalUsed / 1000)}K / ${Math.round(contextLimit / 1000)}K tokens)`, variant, - duration: 4000, + duration: config.timeouts.toastWarningMs, }, }) .catch(() => {}); diff --git a/src/hooks/ledger-loader.ts b/src/hooks/ledger-loader.ts index ad26c1b..31703bd 100644 --- a/src/hooks/ledger-loader.ts +++ b/src/hooks/ledger-loader.ts @@ -2,9 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; - -const LEDGER_DIR = "thoughts/ledgers"; -const LEDGER_PREFIX = "CONTINUITY_"; +import { config } from "../utils/config"; export interface LedgerInfo { sessionName: string; @@ -13,11 +11,11 @@ export interface LedgerInfo { } export async function findCurrentLedger(directory: string): Promise { - const ledgerDir = join(directory, LEDGER_DIR); + const ledgerDir = join(directory, config.paths.ledgerDir); try { const files = await readdir(ledgerDir); - const ledgerFiles = files.filter((f) => f.startsWith(LEDGER_PREFIX) && f.endsWith(".md")); + const ledgerFiles = files.filter((f) => f.startsWith(config.paths.ledgerPrefix) && f.endsWith(".md")); if (ledgerFiles.length === 0) return null; @@ -40,7 +38,7 @@ export async function findCurrentLedger(directory: string): Promise | undefined; if (!lastAssistant) { - return { used: 0, limit: DEFAULT_CONTEXT_LIMIT }; + return { used: 0, limit: config.tokens.defaultContextLimit }; } const info = lastAssistant.info as Record | undefined; @@ -107,25 +101,25 @@ export function createTokenAwareTruncationHook(ctx: PluginInput) { const used = inputTokens + cacheRead; // Get model limit (simplified - use default for now) - const limit = DEFAULT_CONTEXT_LIMIT; + const limit = config.tokens.defaultContextLimit; const result = { used, limit }; state.sessionTokenUsage.set(sessionID, result); return result; } catch { - return state.sessionTokenUsage.get(sessionID) || { used: 0, limit: DEFAULT_CONTEXT_LIMIT }; + return state.sessionTokenUsage.get(sessionID) || { used: 0, limit: config.tokens.defaultContextLimit }; } } function calculateMaxOutputTokens(used: number, limit: number): number { const remaining = limit - used; - const available = Math.floor(remaining * SAFETY_MARGIN); + const available = Math.floor(remaining * config.tokens.safetyMargin); if (available <= 0) { return 0; } - return Math.min(available, DEFAULT_MAX_OUTPUT_TOKENS); + return Math.min(available, config.tokens.defaultMaxOutputTokens); } return { @@ -180,8 +174,8 @@ export function createTokenAwareTruncationHook(ctx: PluginInput) { } catch { // On error, apply static truncation as fallback const currentTokens = estimateTokens(output.output); - if (currentTokens > DEFAULT_MAX_OUTPUT_TOKENS) { - output.output = truncateToTokenLimit(output.output, DEFAULT_MAX_OUTPUT_TOKENS); + if (currentTokens > config.tokens.defaultMaxOutputTokens) { + output.output = truncateToTokenLimit(output.output, config.tokens.defaultMaxOutputTokens); } } }, diff --git a/src/index.ts b/src/index.ts index 7ace5f7..08afa5f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,30 +3,28 @@ import type { McpLocalConfig } from "@opencode-ai/sdk"; // Agents import { agents, PRIMARY_AGENT_NAME } from "./agents"; - -// Tools -import { ast_grep_search, ast_grep_replace, checkAstGrepAvailable } from "./tools/ast-grep"; -import { btca_ask, checkBtcaAvailable } from "./tools/btca"; -import { look_at } from "./tools/look-at"; -import { artifact_search } from "./tools/artifact-search"; -import { createSpawnAgentTool } from "./tools/spawn-agent"; - +// Config loader +import { loadMicodeConfig, mergeAgentConfigs } from "./config-loader"; +import { createArtifactAutoIndexHook } from "./hooks/artifact-auto-index"; // Hooks import { createAutoCompactHook } from "./hooks/auto-compact"; +import { createCommentCheckerHook } from "./hooks/comment-checker"; import { createContextInjectorHook } from "./hooks/context-injector"; -import { createSessionRecoveryHook } from "./hooks/session-recovery"; -import { createTokenAwareTruncationHook } from "./hooks/token-aware-truncation"; import { createContextWindowMonitorHook } from "./hooks/context-window-monitor"; -import { createCommentCheckerHook } from "./hooks/comment-checker"; -import { createLedgerLoaderHook } from "./hooks/ledger-loader"; -import { createArtifactAutoIndexHook } from "./hooks/artifact-auto-index"; import { createFileOpsTrackerHook, getFileOps } from "./hooks/file-ops-tracker"; - +import { createLedgerLoaderHook } from "./hooks/ledger-loader"; +import { createSessionRecoveryHook } from "./hooks/session-recovery"; +import { createTokenAwareTruncationHook } from "./hooks/token-aware-truncation"; +import { artifact_search } from "./tools/artifact-search"; +// Tools +import { ast_grep_replace, ast_grep_search, checkAstGrepAvailable } from "./tools/ast-grep"; +import { btca_ask, checkBtcaAvailable } from "./tools/btca"; +import { look_at } from "./tools/look-at"; +import { milestone_artifact_search } from "./tools/milestone-artifact-search"; +import { createOcttoTools, createSessionStore } from "./tools/octto"; // PTY System -import { PTYManager, createPtyTools } from "./tools/pty"; - -// Config loader -import { loadMicodeConfig, mergeAgentConfigs } from "./config-loader"; +import { createPtyTools, PTYManager } from "./tools/pty"; +import { createSpawnAgentTool } from "./tools/spawn-agent"; // Think mode: detect keywords and enable extended thinking const THINK_KEYWORDS = [ @@ -75,7 +73,7 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => { console.warn(`[micode] ${btcaStatus.message}`); } - // Load user config for model overrides + // Load user config for temperature/maxTokens overrides (model overrides not supported) const userConfig = await loadMicodeConfig(); // Think mode state per session @@ -99,6 +97,28 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => { // Spawn agent tool (for subagents to spawn other subagents) const spawn_agent = createSpawnAgentTool(ctx); + // Octto (browser-based brainstorming) tools + const octtoSessionStore = createSessionStore(); + + // Track octto sessions per opencode session for cleanup + const octtoSessionsMap = new Map>(); + + const octtoTools = createOcttoTools(octtoSessionStore, ctx.client, { + onCreated: (parentSessionId, octtoSessionId) => { + const sessions = octtoSessionsMap.get(parentSessionId) ?? new Set(); + sessions.add(octtoSessionId); + octtoSessionsMap.set(parentSessionId, sessions); + }, + onEnded: (parentSessionId, octtoSessionId) => { + const sessions = octtoSessionsMap.get(parentSessionId); + if (!sessions) return; + sessions.delete(octtoSessionId); + if (sessions.size === 0) { + octtoSessionsMap.delete(parentSessionId); + } + }, + }); + return { // Tools tool: { @@ -107,8 +127,10 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => { btca_ask, look_at, artifact_search, + milestone_artifact_search, spawn_agent, ...ptyTools, + ...octtoTools, }, config: async (config) => { @@ -292,12 +314,22 @@ IMPORTANT: }, event: async ({ event }) => { - // Session cleanup (think mode + PTY) + // Session cleanup (think mode + PTY + octto) if (event.type === "session.deleted") { const props = event.properties as { info?: { id?: string } } | undefined; if (props?.info?.id) { - thinkModeState.delete(props.info.id); - ptyManager.cleanupBySession(props.info.id); + const sessionId = props.info.id; + thinkModeState.delete(sessionId); + ptyManager.cleanupBySession(sessionId); + + // Cleanup octto sessions + const octtoSessions = octtoSessionsMap.get(sessionId); + if (octtoSessions) { + for (const octtoSessionId of octtoSessions) { + await octtoSessionStore.endSession(octtoSessionId).catch(() => {}); + } + octtoSessionsMap.delete(sessionId); + } } } diff --git a/src/indexing/milestone-artifact-classifier.ts b/src/indexing/milestone-artifact-classifier.ts new file mode 100644 index 0000000..0e96333 --- /dev/null +++ b/src/indexing/milestone-artifact-classifier.ts @@ -0,0 +1,26 @@ +export const MILESTONE_ARTIFACT_TYPES = { + FEATURE: "feature", + DECISION: "decision", + SESSION: "session", +} as const; + +export type MilestoneArtifactType = (typeof MILESTONE_ARTIFACT_TYPES)[keyof typeof MILESTONE_ARTIFACT_TYPES]; + +const FEATURE_HINTS = ["requirement", "implementation", "capability", "scope", "spec"]; +const DECISION_HINTS = ["decision", "decided", "trade-off", "rationale", "chosen"]; +const SESSION_HINTS = ["meeting", "status", "discussion", "notes", "update"]; + +const matchesAny = (content: string, hints: string[]) => hints.some((hint) => content.includes(hint)); + +export function classifyMilestoneArtifact(content: string): MilestoneArtifactType { + const normalized = content.toLowerCase(); + const isFeature = matchesAny(normalized, FEATURE_HINTS); + const isDecision = matchesAny(normalized, DECISION_HINTS); + const isSession = matchesAny(normalized, SESSION_HINTS); + + if (isFeature) return MILESTONE_ARTIFACT_TYPES.FEATURE; + if (isDecision) return MILESTONE_ARTIFACT_TYPES.DECISION; + if (isSession) return MILESTONE_ARTIFACT_TYPES.SESSION; + + return MILESTONE_ARTIFACT_TYPES.SESSION; +} diff --git a/src/indexing/milestone-artifact-ingest.ts b/src/indexing/milestone-artifact-ingest.ts new file mode 100644 index 0000000..7014603 --- /dev/null +++ b/src/indexing/milestone-artifact-ingest.ts @@ -0,0 +1,42 @@ +import { type ArtifactIndex, getArtifactIndex } from "../tools/artifact-index"; +import { log } from "../utils/logger"; +import { + classifyMilestoneArtifact, + MILESTONE_ARTIFACT_TYPES, + type MilestoneArtifactType, +} from "./milestone-artifact-classifier"; + +export interface MilestoneArtifactInput { + id: string; + milestoneId: string; + sourceSessionId?: string; + createdAt?: string; + tags?: string[]; + payload: string; +} + +export async function ingestMilestoneArtifact( + input: MilestoneArtifactInput, + index?: ArtifactIndex, + classifier: (content: string) => MilestoneArtifactType = classifyMilestoneArtifact, +): Promise { + const artifactIndex = index ?? (await getArtifactIndex()); + let artifactType: MilestoneArtifactType; + + try { + artifactType = classifier(input.payload); + } catch (error) { + log.error("milestone-ingest", "Failed to classify milestone artifact, defaulting to session", error); + artifactType = MILESTONE_ARTIFACT_TYPES.SESSION; + } + + await artifactIndex.indexMilestoneArtifact({ + id: input.id, + milestoneId: input.milestoneId, + artifactType, + sourceSessionId: input.sourceSessionId, + createdAt: input.createdAt, + tags: input.tags, + payload: input.payload, + }); +} diff --git a/src/octto/constants.ts b/src/octto/constants.ts new file mode 100644 index 0000000..6f35a29 --- /dev/null +++ b/src/octto/constants.ts @@ -0,0 +1,20 @@ +// src/octto/constants.ts +// Re-exports from centralized config for backward compatibility +// Single source of truth is in src/utils/config.ts + +import { config } from "../utils/config"; + +/** Default timeout for waiting for user answers (5 minutes) */ +export const DEFAULT_ANSWER_TIMEOUT_MS = config.octto.answerTimeoutMs; + +/** Default maximum number of follow-up questions per branch */ +export const DEFAULT_MAX_QUESTIONS = config.octto.maxQuestions; + +/** Default timeout for brainstorm review (10 minutes) */ +export const DEFAULT_REVIEW_TIMEOUT_MS = config.octto.reviewTimeoutMs; + +/** Maximum number of brainstorm iterations */ +export const MAX_ITERATIONS = config.octto.maxIterations; + +/** Directory for persisting brainstorm state files */ +export const STATE_DIR = config.octto.stateDir; diff --git a/src/octto/session/browser.ts b/src/octto/session/browser.ts new file mode 100644 index 0000000..6c44e00 --- /dev/null +++ b/src/octto/session/browser.ts @@ -0,0 +1,32 @@ +// src/octto/session/browser.ts +// Cross-platform browser opener + +/** + * Opens the default browser to the specified URL. + * Detects platform and uses appropriate command. + */ +export async function openBrowser(url: string): Promise { + const platform = process.platform; + + let command: string[]; + + switch (platform) { + case "darwin": + command = ["open", url]; + break; + case "win32": + command = ["cmd", "/c", "start", url]; + break; + default: + // Linux and others + command = ["xdg-open", url]; + break; + } + + const proc = Bun.spawn(command, { + stdout: "ignore", + stderr: "ignore", + }); + + await proc.exited; +} diff --git a/src/octto/session/index.ts b/src/octto/session/index.ts new file mode 100644 index 0000000..0d4c431 --- /dev/null +++ b/src/octto/session/index.ts @@ -0,0 +1,25 @@ +// src/octto/session/index.ts +export type { SessionStore, SessionStoreOptions } from "./sessions"; +export { createSessionStore } from "./sessions"; +export type { + Answer, + AskCodeAnswer, + AskFileAnswer, + AskImageAnswer, + AskTextAnswer, + BaseConfig, + ConfirmAnswer, + EmojiReactAnswer, + PickManyAnswer, + PickOneAnswer, + QuestionAnswers, + QuestionConfig, + QuestionType, + RankAnswer, + RateAnswer, + ReviewAnswer, + ShowOptionsAnswer, + SliderAnswer, + ThumbsAnswer, +} from "./types"; +export { QUESTION_TYPES, QUESTIONS, STATUSES, WS_MESSAGES } from "./types"; diff --git a/src/octto/session/server.ts b/src/octto/session/server.ts new file mode 100644 index 0000000..cee99f9 --- /dev/null +++ b/src/octto/session/server.ts @@ -0,0 +1,89 @@ +// src/octto/session/server.ts +import type { Server, ServerWebSocket } from "bun"; + +import { config } from "../../utils/config"; +import { getHtmlBundle } from "../ui"; +import type { SessionStore } from "./sessions"; +import type { WsClientMessage } from "./types"; + +interface WsData { + sessionId: string; +} + +export async function createServer( + sessionId: string, + store: SessionStore, +): Promise<{ server: Server; port: number }> { + const htmlBundle = getHtmlBundle(); + + const server = Bun.serve({ + port: 0, // Random available port + hostname: config.octto.allowRemoteBind ? config.octto.bindAddress : "127.0.0.1", + fetch(req, server) { + const url = new URL(req.url); + + // WebSocket upgrade + if (url.pathname === "/ws") { + const success = server.upgrade(req, { + data: { sessionId }, + }); + if (success) { + return undefined; + } + return new Response("WebSocket upgrade failed", { status: 400 }); + } + + // Serve the bundled HTML app + if (url.pathname === "/" || url.pathname === "/index.html") { + return new Response(htmlBundle, { + headers: { + "Content-Type": "text/html; charset=utf-8", + }, + }); + } + + return new Response("Not Found", { status: 404 }); + }, + websocket: { + open(ws: ServerWebSocket) { + const { sessionId } = ws.data; + store.handleWsConnect(sessionId, ws); + }, + close(ws: ServerWebSocket) { + const { sessionId } = ws.data; + store.handleWsDisconnect(sessionId); + }, + message(ws: ServerWebSocket, message: string | Buffer) { + const { sessionId } = ws.data; + + let parsed: WsClientMessage; + try { + parsed = JSON.parse(message.toString()) as WsClientMessage; + } catch (error) { + console.error("[octto] Failed to parse WebSocket message:", error); + ws.send( + JSON.stringify({ + type: "error", + error: "Invalid message format", + details: error instanceof Error ? error.message : "Parse failed", + }), + ); + return; + } + + store.handleWsMessage(sessionId, parsed); + }, + }, + }); + + // Port is always defined when using port: 0 + const port = server.port; + if (port === undefined) { + throw new Error("Failed to get server port"); + } + + return { + server, + port, + }; +} diff --git a/src/octto/session/sessions.ts b/src/octto/session/sessions.ts new file mode 100644 index 0000000..f93ddc4 --- /dev/null +++ b/src/octto/session/sessions.ts @@ -0,0 +1,383 @@ +// src/octto/session/sessions.ts +import type { ServerWebSocket } from "bun"; + +import { DEFAULT_ANSWER_TIMEOUT_MS } from "../constants"; +import { openBrowser } from "./browser"; +import { createServer } from "./server"; +import { + type Answer, + type BaseConfig, + type EndSessionOutput, + type GetAnswerInput, + type GetAnswerOutput, + type GetNextAnswerInput, + type GetNextAnswerOutput, + type ListQuestionsOutput, + type PushQuestionOutput, + type Question, + type QuestionType, + type Session, + STATUSES, + type StartSessionInput, + type StartSessionOutput, + WS_MESSAGES, + type WsClientMessage, + type WsServerMessage, +} from "./types"; +import { generateQuestionId, generateSessionId } from "./utils"; +import { createWaiters } from "./waiter"; + +export interface SessionStoreOptions { + /** Skip opening browser - useful for tests */ + skipBrowser?: boolean; +} + +export interface SessionStore { + startSession: (input: StartSessionInput) => Promise; + endSession: (sessionId: string) => Promise; + pushQuestion: (sessionId: string, type: QuestionType, config: BaseConfig) => PushQuestionOutput; + getAnswer: (input: GetAnswerInput) => Promise; + getNextAnswer: (input: GetNextAnswerInput) => Promise; + cancelQuestion: (questionId: string) => { ok: boolean }; + listQuestions: (sessionId?: string) => ListQuestionsOutput; + handleWsConnect: (sessionId: string, ws: ServerWebSocket) => void; + handleWsDisconnect: (sessionId: string) => void; + handleWsMessage: (sessionId: string, message: WsClientMessage) => void; + getSession: (sessionId: string) => Session | undefined; + cleanup: () => Promise; +} + +export function createSessionStore(options: SessionStoreOptions = {}): SessionStore { + const sessions = new Map(); + const questionToSession = new Map(); + const responseWaiters = createWaiters(); + const sessionWaiters = createWaiters(); + + const store: SessionStore = { + async startSession(input: StartSessionInput): Promise { + const sessionId = generateSessionId(); + const { server, port } = await createServer(sessionId, store); + const urlHost = server.hostname ?? "localhost"; + const url = `http://${urlHost}:${port}`; + + const session: Session = { + id: sessionId, + title: input.title, + port, + url, + createdAt: new Date(), + questions: new Map(), + wsConnected: false, + server, + }; + sessions.set(sessionId, session); + + const questionIds = (input.questions ?? []).map((q) => { + const questionId = generateQuestionId(); + const question: Question = { + id: questionId, + sessionId, + type: q.type, + config: q.config, + status: STATUSES.PENDING, + createdAt: new Date(), + }; + session.questions.set(questionId, question); + questionToSession.set(questionId, sessionId); + return questionId; + }); + + if (!options.skipBrowser) { + await openBrowser(url).catch((error) => { + sessions.delete(sessionId); + for (const qId of questionIds) questionToSession.delete(qId); + server.stop(); + throw error; + }); + } + + return { + session_id: sessionId, + url, + question_ids: questionIds.length > 0 ? questionIds : undefined, + }; + }, + + async endSession(sessionId: string): Promise { + const session = sessions.get(sessionId); + if (!session) { + return { ok: false }; + } + + if (session.wsClient) { + const msg: WsServerMessage = { type: WS_MESSAGES.END }; + session.wsClient.send(JSON.stringify(msg)); + } + + if (session.server) { + session.server.stop(); + } + + for (const questionId of session.questions.keys()) { + questionToSession.delete(questionId); + responseWaiters.clear(questionId); + } + + sessions.delete(sessionId); + return { ok: true }; + }, + + pushQuestion(sessionId: string, type: QuestionType, config: BaseConfig): PushQuestionOutput { + const session = sessions.get(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + const questionId = generateQuestionId(); + + const question: Question = { + id: questionId, + sessionId, + type, + config, + status: STATUSES.PENDING, + createdAt: new Date(), + }; + + session.questions.set(questionId, question); + questionToSession.set(questionId, sessionId); + + if (session.wsConnected && session.wsClient) { + const msg: WsServerMessage = { + type: WS_MESSAGES.QUESTION, + id: questionId, + questionType: type, + config, + }; + session.wsClient.send(JSON.stringify(msg)); + } else if (!options.skipBrowser) { + openBrowser(session.url).catch(console.error); + } + + return { question_id: questionId }; + }, + + async getAnswer(input: GetAnswerInput): Promise { + const sessionId = questionToSession.get(input.question_id); + if (!sessionId) { + return { completed: false, status: STATUSES.CANCELLED, reason: STATUSES.CANCELLED }; + } + + const session = sessions.get(sessionId); + if (!session) { + return { completed: false, status: STATUSES.CANCELLED, reason: STATUSES.CANCELLED }; + } + + const question = session.questions.get(input.question_id); + if (!question) { + return { completed: false, status: STATUSES.CANCELLED, reason: STATUSES.CANCELLED }; + } + + if (question.status === STATUSES.ANSWERED) { + return { completed: true, status: STATUSES.ANSWERED, response: question.response }; + } + + if (question.status === STATUSES.CANCELLED || question.status === STATUSES.TIMEOUT) { + return { completed: false, status: question.status, reason: question.status }; + } + + if (!input.block) { + return { completed: false, status: STATUSES.PENDING, reason: STATUSES.PENDING }; + } + + const timeout = input.timeout ?? DEFAULT_ANSWER_TIMEOUT_MS; + + return new Promise((resolve) => { + let timeoutId: ReturnType | undefined; + + const cleanup = responseWaiters.register(input.question_id, (response) => { + if (timeoutId) clearTimeout(timeoutId); + if (response && typeof response === "object" && "cancelled" in response) { + resolve({ completed: false, status: STATUSES.CANCELLED, reason: STATUSES.CANCELLED }); + } else { + resolve({ completed: true, status: STATUSES.ANSWERED, response }); + } + }); + + timeoutId = setTimeout(() => { + cleanup(); + resolve({ completed: false, status: STATUSES.TIMEOUT, reason: STATUSES.TIMEOUT }); + }, timeout); + }); + }, + + async getNextAnswer(input: GetNextAnswerInput): Promise { + const session = sessions.get(input.session_id); + if (!session) { + return { completed: false, status: STATUSES.NONE_PENDING, reason: STATUSES.NONE_PENDING }; + } + + for (const question of session.questions.values()) { + if (question.status === STATUSES.ANSWERED && !question.retrieved) { + question.retrieved = true; + return { + completed: true, + question_id: question.id, + question_type: question.type, + status: STATUSES.ANSWERED, + response: question.response, + }; + } + } + + const hasPending = Array.from(session.questions.values()).some((q) => q.status === STATUSES.PENDING); + + if (!hasPending) { + return { completed: false, status: STATUSES.NONE_PENDING, reason: STATUSES.NONE_PENDING }; + } + + if (!input.block) { + return { completed: false, status: STATUSES.PENDING }; + } + + const timeout = input.timeout ?? DEFAULT_ANSWER_TIMEOUT_MS; + + return new Promise((resolve) => { + let timeoutId: ReturnType | undefined; + + const cleanup = sessionWaiters.register(input.session_id, ({ questionId, response }) => { + if (timeoutId) clearTimeout(timeoutId); + const question = session.questions.get(questionId); + if (question) question.retrieved = true; + resolve({ + completed: true, + question_id: questionId, + question_type: question?.type, + status: STATUSES.ANSWERED, + response, + }); + }); + + timeoutId = setTimeout(() => { + cleanup(); + resolve({ completed: false, status: STATUSES.TIMEOUT, reason: STATUSES.TIMEOUT }); + }, timeout); + }); + }, + + cancelQuestion(questionId: string): { ok: boolean } { + const sessionId = questionToSession.get(questionId); + if (!sessionId) { + return { ok: false }; + } + + const session = sessions.get(sessionId); + if (!session) { + return { ok: false }; + } + + const question = session.questions.get(questionId); + if (!question || question.status !== STATUSES.PENDING) { + return { ok: false }; + } + + question.status = STATUSES.CANCELLED; + + if (session.wsClient) { + const msg: WsServerMessage = { type: WS_MESSAGES.CANCEL, id: questionId }; + session.wsClient.send(JSON.stringify(msg)); + } + + responseWaiters.notifyAll(questionId, { cancelled: true }); + + return { ok: true }; + }, + + listQuestions(sessionId?: string): ListQuestionsOutput { + const questions: ListQuestionsOutput["questions"] = []; + + const sessionsToCheck = sessionId ? [sessions.get(sessionId)].filter(Boolean) : Array.from(sessions.values()); + + for (const session of sessionsToCheck) { + if (!session) continue; + for (const question of session.questions.values()) { + questions.push({ + id: question.id, + type: question.type, + status: question.status, + createdAt: question.createdAt.toISOString(), + answeredAt: question.answeredAt?.toISOString(), + }); + } + } + + questions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + return { questions }; + }, + + handleWsConnect(sessionId: string, ws: ServerWebSocket): void { + const session = sessions.get(sessionId); + if (!session) return; + + session.wsConnected = true; + session.wsClient = ws; + + for (const question of session.questions.values()) { + if (question.status === STATUSES.PENDING) { + const msg: WsServerMessage = { + type: WS_MESSAGES.QUESTION, + id: question.id, + questionType: question.type, + config: question.config, + }; + ws.send(JSON.stringify(msg)); + } + } + }, + + handleWsDisconnect(sessionId: string): void { + const session = sessions.get(sessionId); + if (!session) return; + + session.wsConnected = false; + session.wsClient = undefined; + }, + + handleWsMessage(sessionId: string, message: WsClientMessage): void { + if (message.type === WS_MESSAGES.CONNECTED) { + return; + } + + if (message.type === WS_MESSAGES.RESPONSE) { + const session = sessions.get(sessionId); + if (!session) return; + + const question = session.questions.get(message.id); + if (!question || question.status !== STATUSES.PENDING) return; + + question.status = STATUSES.ANSWERED; + question.answeredAt = new Date(); + question.response = message.answer; + + responseWaiters.notifyAll(message.id, message.answer); + sessionWaiters.notifyFirst(sessionId, { + questionId: message.id, + response: message.answer, + }); + } + }, + + getSession(sessionId: string): Session | undefined { + return sessions.get(sessionId); + }, + + async cleanup(): Promise { + for (const sessionId of sessions.keys()) { + await store.endSession(sessionId); + } + }, + }; + + return store; +} diff --git a/src/octto/session/types.ts b/src/octto/session/types.ts new file mode 100644 index 0000000..e236257 --- /dev/null +++ b/src/octto/session/types.ts @@ -0,0 +1,305 @@ +// src/octto/session/types.ts +// Session and Question types for the octto module +import type { ServerWebSocket } from "bun"; + +import type { + AskCodeConfig, + AskFileConfig, + AskImageConfig, + AskTextConfig, + ConfirmConfig, + EmojiReactConfig, + PickManyConfig, + PickOneConfig, + RankConfig, + RateConfig, + ReviewSectionConfig, + ShowDiffConfig, + ShowOptionsConfig, + ShowPlanConfig, + SliderConfig, + ThumbsConfig, +} from "../types"; + +export const STATUSES = { + PENDING: "pending", + ANSWERED: "answered", + CANCELLED: "cancelled", + TIMEOUT: "timeout", + NONE_PENDING: "none_pending", +} as const; + +export type QuestionStatus = (typeof STATUSES)[Exclude]; + +export interface Question { + id: string; + sessionId: string; + type: QuestionType; + config: BaseConfig; + status: QuestionStatus; + createdAt: Date; + answeredAt?: Date; + response?: Answer; + /** True if this answer was already returned via get_next_answer */ + retrieved?: boolean; +} + +export const QUESTIONS = { + PICK_ONE: "pick_one", + PICK_MANY: "pick_many", + CONFIRM: "confirm", + RANK: "rank", + RATE: "rate", + ASK_TEXT: "ask_text", + ASK_IMAGE: "ask_image", + ASK_FILE: "ask_file", + ASK_CODE: "ask_code", + SHOW_DIFF: "show_diff", + SHOW_PLAN: "show_plan", + SHOW_OPTIONS: "show_options", + REVIEW_SECTION: "review_section", + THUMBS: "thumbs", + EMOJI_REACT: "emoji_react", + SLIDER: "slider", +} as const; + +export type QuestionType = (typeof QUESTIONS)[keyof typeof QUESTIONS]; +export const QUESTION_TYPES = Object.values(QUESTIONS); + +// --- Answer Types --- + +export interface PickOneAnswer { + selected: string; +} + +export interface PickManyAnswer { + selected: string[]; +} + +export interface ConfirmAnswer { + choice: "yes" | "no" | "cancel"; +} + +export interface ThumbsAnswer { + choice: "up" | "down"; +} + +export interface EmojiReactAnswer { + emoji: string; +} + +export interface AskTextAnswer { + text: string; +} + +export interface SliderAnswer { + value: number; +} + +export interface RankAnswer { + ranking: Array<{ id: string; rank: number }>; +} + +export interface RateAnswer { + ratings: Record; +} + +export interface AskCodeAnswer { + code: string; +} + +export interface AskImageAnswer { + images: Array<{ name: string; data: string; type: string }>; +} + +export interface AskFileAnswer { + files: Array<{ name: string; data: string; type: string }>; +} + +export interface ReviewAnswer { + decision: string; + feedback?: string; +} + +export interface ShowOptionsAnswer { + selected: string; + feedback?: string; +} + +export type Answer = + | PickOneAnswer + | PickManyAnswer + | ConfirmAnswer + | ThumbsAnswer + | EmojiReactAnswer + | AskTextAnswer + | SliderAnswer + | RankAnswer + | RateAnswer + | AskCodeAnswer + | AskImageAnswer + | AskFileAnswer + | ReviewAnswer + | ShowOptionsAnswer; + +export interface QuestionAnswers { + [QUESTIONS.PICK_ONE]: PickOneAnswer; + [QUESTIONS.PICK_MANY]: PickManyAnswer; + [QUESTIONS.CONFIRM]: ConfirmAnswer; + [QUESTIONS.THUMBS]: ThumbsAnswer; + [QUESTIONS.EMOJI_REACT]: EmojiReactAnswer; + [QUESTIONS.ASK_TEXT]: AskTextAnswer; + [QUESTIONS.SLIDER]: SliderAnswer; + [QUESTIONS.RANK]: RankAnswer; + [QUESTIONS.RATE]: RateAnswer; + [QUESTIONS.ASK_CODE]: AskCodeAnswer; + [QUESTIONS.ASK_IMAGE]: AskImageAnswer; + [QUESTIONS.ASK_FILE]: AskFileAnswer; + [QUESTIONS.SHOW_DIFF]: ReviewAnswer; + [QUESTIONS.SHOW_PLAN]: ReviewAnswer; + [QUESTIONS.REVIEW_SECTION]: ReviewAnswer; + [QUESTIONS.SHOW_OPTIONS]: ShowOptionsAnswer; +} + +export type QuestionConfig = + | PickOneConfig + | PickManyConfig + | ConfirmConfig + | RankConfig + | RateConfig + | AskTextConfig + | AskImageConfig + | AskFileConfig + | AskCodeConfig + | ShowDiffConfig + | ShowPlanConfig + | ShowOptionsConfig + | ReviewSectionConfig + | ThumbsConfig + | EmojiReactConfig + | SliderConfig; + +/** Config type for transit - accepts both strict QuestionConfig and loose objects */ +export type BaseConfig = + | QuestionConfig + | { + question?: string; + context?: string; + [key: string]: unknown; + }; + +export interface Session { + id: string; + title?: string; + port: number; + url: string; + createdAt: Date; + questions: Map; + wsConnected: boolean; + server?: ReturnType; + wsClient?: ServerWebSocket; +} + +export interface InitialQuestion { + type: QuestionType; + config: BaseConfig; +} + +export interface StartSessionInput { + title?: string; + /** Initial questions to display immediately when browser opens */ + questions?: InitialQuestion[]; +} + +export interface StartSessionOutput { + session_id: string; + url: string; + /** IDs of initial questions if any were provided */ + question_ids?: string[]; +} + +export interface EndSessionOutput { + ok: boolean; +} + +export interface PushQuestionOutput { + question_id: string; +} + +export interface GetAnswerInput { + question_id: string; + block?: boolean; + timeout?: number; +} + +export interface GetAnswerOutput { + completed: boolean; + status: QuestionStatus; + response?: Answer; + reason?: "timeout" | "cancelled" | "pending"; +} + +export interface GetNextAnswerInput { + session_id: string; + block?: boolean; + timeout?: number; +} + +export type AnswerStatus = (typeof STATUSES)[keyof typeof STATUSES]; + +export interface GetNextAnswerOutput { + completed: boolean; + question_id?: string; + question_type?: QuestionType; + status: AnswerStatus; + response?: Answer; + reason?: typeof STATUSES.TIMEOUT | typeof STATUSES.NONE_PENDING; +} + +export interface ListQuestionsOutput { + questions: Array<{ + id: string; + type: QuestionType; + status: QuestionStatus; + createdAt: string; + answeredAt?: string; + }>; +} + +// WebSocket message types +export const WS_MESSAGES = { + QUESTION: "question", + CANCEL: "cancel", + END: "end", + RESPONSE: "response", + CONNECTED: "connected", +} as const; + +export interface WsQuestionMessage { + type: "question"; + id: string; + questionType: QuestionType; + config: BaseConfig; +} + +export interface WsCancelMessage { + type: "cancel"; + id: string; +} + +export interface WsEndMessage { + type: "end"; +} + +export interface WsResponseMessage { + type: "response"; + id: string; + answer: Answer; +} + +export interface WsConnectedMessage { + type: "connected"; +} + +export type WsServerMessage = WsQuestionMessage | WsCancelMessage | WsEndMessage; +export type WsClientMessage = WsResponseMessage | WsConnectedMessage; diff --git a/src/octto/session/utils.ts b/src/octto/session/utils.ts new file mode 100644 index 0000000..686664a --- /dev/null +++ b/src/octto/session/utils.ts @@ -0,0 +1,25 @@ +// src/octto/session/utils.ts +// ID generation utilities for octto sessions and questions + +const ID_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"; +const ID_LENGTH = 8; + +function generateId(prefix: string): string { + let result = `${prefix}_`; + for (let i = 0; i < ID_LENGTH; i++) { + result += ID_CHARS.charAt(Math.floor(Math.random() * ID_CHARS.length)); + } + return result; +} + +export function generateSessionId(): string { + return generateId("ses"); +} + +export function generateQuestionId(): string { + return generateId("q"); +} + +export function generateBrainstormId(): string { + return generateId("bs"); +} diff --git a/src/octto/session/waiter.ts b/src/octto/session/waiter.ts new file mode 100644 index 0000000..108ef02 --- /dev/null +++ b/src/octto/session/waiter.ts @@ -0,0 +1,139 @@ +// src/octto/session/waiter.ts +// Immutable waiter management for async response handling + +export interface Waiters { + register: (key: K, callback: (data: T) => void) => () => void; + notifyFirst: (key: K, data: T) => void; + notifyAll: (key: K, data: T) => void; + has: (key: K) => boolean; + count: (key: K) => number; + clear: (key: K) => void; +} + +/** + * Create a waiter registry for async response handling. + * Each operation creates a new array rather than mutating in place. + * + * @typeParam K - Key type (e.g., string for question_id or session_id) + * @typeParam T - Data type passed to waiter callbacks + */ +export function createWaiters(): Waiters { + const waiters = new Map void>>(); + + return { + /** + * Register a waiter callback for a key. + * Returns a cleanup function to remove this specific waiter. + */ + register(key: K, callback: (data: T) => void): () => void { + // Create new array with callback appended (immutable) + const current = waiters.get(key) || []; + waiters.set(key, [...current, callback]); + + // Return cleanup function that removes this specific callback + return () => { + const callbacks = waiters.get(key); + if (!callbacks) return; + + const idx = callbacks.indexOf(callback); + if (idx >= 0) { + // Create new array without this callback (immutable) + const remaining = [...callbacks.slice(0, idx), ...callbacks.slice(idx + 1)]; + if (remaining.length === 0) { + waiters.delete(key); + } else { + waiters.set(key, remaining); + } + } + }; + }, + + /** + * Notify only the first waiter for a key and remove it. + * Other waiters remain registered for subsequent notifications. + */ + notifyFirst(key: K, data: T): void { + const callbacks = waiters.get(key); + if (!callbacks || callbacks.length === 0) return; + + const [first, ...rest] = callbacks; + first(data); + + // Set new array without first element (immutable) + if (rest.length === 0) { + waiters.delete(key); + } else { + waiters.set(key, rest); + } + }, + + /** + * Notify all waiters for a key and remove them all. + */ + notifyAll(key: K, data: T): void { + const callbacks = waiters.get(key); + if (!callbacks) return; + + try { + for (const callback of callbacks) { + try { + callback(data); + } catch (error) { + console.error("Waiter notifyAll failed", error); + break; + } + } + } finally { + waiters.delete(key); + } + }, + + /** + * Check if there are any waiters for a key. + */ + has(key: K): boolean { + const callbacks = waiters.get(key); + return callbacks !== undefined && callbacks.length > 0; + }, + + /** + * Get the number of waiters for a key. + */ + count(key: K): number { + return waiters.get(key)?.length ?? 0; + }, + + /** + * Remove all waiters for a key without notifying them. + */ + clear(key: K): void { + waiters.delete(key); + }, + }; +} + +/** + * Result of waiting for a response + */ +export type WaitResult = { ok: true; data: T } | { ok: false; reason: "timeout" }; + +/** + * Wait for a response with timeout. + * Registers a waiter and returns a promise that resolves when notified or times out. + */ +export function waitForResponse(waiters: Waiters, key: K, timeoutMs: number): Promise> { + return new Promise((resolve) => { + let timeoutId: ReturnType | undefined; + let cleanup: (() => void) | undefined; + + cleanup = waiters.register(key, (data) => { + if (timeoutId) clearTimeout(timeoutId); + resolve({ ok: true, data }); + }); + + timeoutId = setTimeout(() => { + if (cleanup) cleanup(); + resolve({ ok: false, reason: "timeout" }); + }, timeoutMs); + }); +} diff --git a/src/octto/state/index.ts b/src/octto/state/index.ts new file mode 100644 index 0000000..3381f48 --- /dev/null +++ b/src/octto/state/index.ts @@ -0,0 +1,5 @@ +// src/octto/state/index.ts + +export * from "./persistence"; +export * from "./store"; +export * from "./types"; diff --git a/src/octto/state/persistence.ts b/src/octto/state/persistence.ts new file mode 100644 index 0000000..b3d0489 --- /dev/null +++ b/src/octto/state/persistence.ts @@ -0,0 +1,65 @@ +// src/octto/state/persistence.ts +import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +import { STATE_DIR } from "../constants"; +import type { BrainstormState } from "./types"; + +export interface StatePersistence { + save: (state: BrainstormState) => Promise; + load: (sessionId: string) => Promise; + delete: (sessionId: string) => Promise; + list: () => Promise; +} + +function validateSessionId(sessionId: string): void { + if (!/^[a-zA-Z0-9_-]+$/.test(sessionId)) { + throw new Error(`Invalid session ID: ${sessionId}`); + } +} + +export function createStatePersistence(baseDir = STATE_DIR): StatePersistence { + function getFilePath(sessionId: string): string { + validateSessionId(sessionId); + return join(baseDir, `${sessionId}.json`); + } + + function ensureDir(): void { + if (!existsSync(baseDir)) { + mkdirSync(baseDir, { recursive: true }); + } + } + + return { + async save(state: BrainstormState): Promise { + ensureDir(); + const filePath = getFilePath(state.session_id); + state.updated_at = Date.now(); + await Bun.write(filePath, JSON.stringify(state, null, 2)); + }, + + async load(sessionId: string): Promise { + const filePath = getFilePath(sessionId); + if (!existsSync(filePath)) { + return null; + } + const content = await Bun.file(filePath).text(); + return JSON.parse(content) as BrainstormState; + }, + + async delete(sessionId: string): Promise { + const filePath = getFilePath(sessionId); + if (existsSync(filePath)) { + rmSync(filePath); + } + }, + + async list(): Promise { + if (!existsSync(baseDir)) { + return []; + } + const files = readdirSync(baseDir); + return files.filter((f) => f.endsWith(".json")).map((f) => f.replace(".json", "")); + }, + }; +} diff --git a/src/octto/state/store.ts b/src/octto/state/store.ts new file mode 100644 index 0000000..b129d54 --- /dev/null +++ b/src/octto/state/store.ts @@ -0,0 +1,161 @@ +// src/octto/state/store.ts + +import { STATE_DIR } from "../constants"; +import type { Answer } from "../session"; +import { createStatePersistence } from "./persistence"; +import { + BRANCH_STATUSES, + type BrainstormState, + type Branch, + type BranchQuestion, + type CreateBranchInput, +} from "./types"; + +export interface StateStore { + createSession: (sessionId: string, request: string, branches: CreateBranchInput[]) => Promise; + getSession: (sessionId: string) => Promise; + setBrowserSessionId: (sessionId: string, browserSessionId: string) => Promise; + addQuestionToBranch: (sessionId: string, branchId: string, question: BranchQuestion) => Promise; + recordAnswer: (sessionId: string, questionId: string, answer: Answer) => Promise; + completeBranch: (sessionId: string, branchId: string, finding: string) => Promise; + getNextExploringBranch: (sessionId: string) => Promise; + isSessionComplete: (sessionId: string) => Promise; + deleteSession: (sessionId: string) => Promise; +} + +export function createStateStore(baseDir = STATE_DIR): StateStore { + const persistence = createStatePersistence(baseDir); + + // Operation queue per session to prevent concurrent read-modify-write races + const operationQueues = new Map>(); + + // Serialize operations for a given session + function withSessionLock(sessionId: string, operation: () => Promise): Promise { + const currentQueue = operationQueues.get(sessionId) ?? Promise.resolve(); + const newOperation = currentQueue.then(operation, operation); // Run even if previous failed + operationQueues.set( + sessionId, + newOperation.then( + () => {}, + () => {}, + ), + ); // Ignore result for queue + return newOperation; + } + + return { + async createSession( + sessionId: string, + request: string, + branchInputs: CreateBranchInput[], + ): Promise { + const branches: Record = {}; + const order: string[] = []; + + for (const input of branchInputs) { + branches[input.id] = { + id: input.id, + scope: input.scope, + status: BRANCH_STATUSES.EXPLORING, + questions: [], + finding: null, + }; + order.push(input.id); + } + + const state: BrainstormState = { + session_id: sessionId, + browser_session_id: null, + request, + created_at: Date.now(), + updated_at: Date.now(), + branches, + branch_order: order, + }; + + await persistence.save(state); + return state; + }, + + async getSession(sessionId: string): Promise { + return persistence.load(sessionId); + }, + + async setBrowserSessionId(sessionId: string, browserSessionId: string): Promise { + return withSessionLock(sessionId, async () => { + const state = await persistence.load(sessionId); + if (!state) throw new Error(`Session not found: ${sessionId}`); + state.browser_session_id = browserSessionId; + await persistence.save(state); + }); + }, + + async addQuestionToBranch(sessionId: string, branchId: string, question: BranchQuestion): Promise { + return withSessionLock(sessionId, async () => { + const state = await persistence.load(sessionId); + if (!state) throw new Error(`Session not found: ${sessionId}`); + if (!state.branches[branchId]) throw new Error(`Branch not found: ${branchId}`); + + state.branches[branchId].questions.push(question); + await persistence.save(state); + return question; + }); + }, + + async recordAnswer(sessionId: string, questionId: string, answer: Answer): Promise { + return withSessionLock(sessionId, async () => { + const state = await persistence.load(sessionId); + if (!state) throw new Error(`Session not found: ${sessionId}`); + + for (const branch of Object.values(state.branches)) { + const question = branch.questions.find((q) => q.id === questionId); + if (question) { + question.answer = answer; + question.answeredAt = Date.now(); + await persistence.save(state); + return; + } + } + throw new Error(`Question not found: ${questionId}`); + }); + }, + + async completeBranch(sessionId: string, branchId: string, finding: string): Promise { + return withSessionLock(sessionId, async () => { + const state = await persistence.load(sessionId); + if (!state) throw new Error(`Session not found: ${sessionId}`); + if (!state.branches[branchId]) throw new Error(`Branch not found: ${branchId}`); + + state.branches[branchId].status = BRANCH_STATUSES.DONE; + state.branches[branchId].finding = finding; + await persistence.save(state); + }); + }, + + async getNextExploringBranch(sessionId: string): Promise { + const state = await persistence.load(sessionId); + if (!state) return null; + + for (const branchId of state.branch_order) { + const branch = state.branches[branchId]; + if (branch.status === BRANCH_STATUSES.EXPLORING) { + return branch; + } + } + return null; + }, + + async isSessionComplete(sessionId: string): Promise { + const state = await persistence.load(sessionId); + if (!state) return false; + + return Object.values(state.branches).every((b) => b.status === BRANCH_STATUSES.DONE); + }, + + async deleteSession(sessionId: string): Promise { + await withSessionLock(sessionId, async () => { + await persistence.delete(sessionId); + }); + }, + }; +} diff --git a/src/octto/state/types.ts b/src/octto/state/types.ts new file mode 100644 index 0000000..9ec5970 --- /dev/null +++ b/src/octto/state/types.ts @@ -0,0 +1,51 @@ +// src/octto/state/types.ts +import type { Answer, BaseConfig, QuestionType } from "../session"; + +export const BRANCH_STATUSES = { + EXPLORING: "exploring", + DONE: "done", +} as const; + +export type BranchStatus = (typeof BRANCH_STATUSES)[keyof typeof BRANCH_STATUSES]; + +export interface BranchQuestion { + id: string; + type: QuestionType; + text: string; + config: BaseConfig; + answer?: Answer; + answeredAt?: number; +} + +export interface Branch { + id: string; + scope: string; + status: BranchStatus; + questions: BranchQuestion[]; + finding: string | null; +} + +export interface BrainstormState { + session_id: string; + browser_session_id: string | null; + request: string; + created_at: number; + updated_at: number; + branches: Record; + branch_order: string[]; +} + +export interface CreateBranchInput { + id: string; + scope: string; +} + +export interface BranchProbeResult { + done: boolean; + reason: string; + finding?: string; + question?: { + type: QuestionType; + config: BaseConfig; + }; +} diff --git a/src/octto/types.ts b/src/octto/types.ts new file mode 100644 index 0000000..239ed35 --- /dev/null +++ b/src/octto/types.ts @@ -0,0 +1,376 @@ +// src/octto/types.ts +// Common types for all interactive tools + +export interface BaseConfig { + /** Window title */ + title?: string; + /** Timeout in seconds (0 = no timeout) */ + timeout?: number; + /** Theme preference */ + theme?: "light" | "dark" | "auto"; +} + +export interface Option { + /** Unique identifier */ + id: string; + /** Display label */ + label: string; + /** Optional description */ + description?: string; +} + +export interface OptionWithPros extends Option { + /** Pros/advantages */ + pros?: string[]; + /** Cons/disadvantages */ + cons?: string[]; +} + +export interface RatedOption extends Option { + /** User's rating (filled after response) */ + rating?: number; +} + +export interface RankedOption extends Option { + /** User's rank position (filled after response) */ + rank?: number; +} + +// Tool-specific configs + +export interface PickOneConfig extends BaseConfig { + /** Question/prompt to display */ + question: string; + /** Available options */ + options: Option[]; + /** Recommended option id (highlighted) */ + recommended?: string; + /** Allow custom "other" input */ + allowOther?: boolean; +} + +export interface PickManyConfig extends BaseConfig { + /** Question/prompt to display */ + question: string; + /** Available options */ + options: Option[]; + /** Recommended option ids (highlighted) */ + recommended?: string[]; + /** Minimum selections required */ + min?: number; + /** Maximum selections allowed */ + max?: number; + /** Allow custom "other" input */ + allowOther?: boolean; +} + +export interface ConfirmConfig extends BaseConfig { + /** Question/prompt to display */ + question: string; + /** Context/details to show */ + context?: string; + /** Custom label for yes button */ + yesLabel?: string; + /** Custom label for no button */ + noLabel?: string; + /** Show cancel option */ + allowCancel?: boolean; +} + +export interface RankConfig extends BaseConfig { + /** Question/prompt to display */ + question: string; + /** Items to rank */ + options: Option[]; + /** Context/instructions */ + context?: string; +} + +export interface RateConfig extends BaseConfig { + /** Question/prompt to display */ + question: string; + /** Items to rate */ + options: Option[]; + /** Minimum rating value */ + min?: number; + /** Maximum rating value */ + max?: number; + /** Rating step (default 1) */ + step?: number; + /** Labels for min/max */ + labels?: { min?: string; max?: string }; +} + +export interface AskTextConfig extends BaseConfig { + /** Question/prompt to display */ + question: string; + /** Placeholder text */ + placeholder?: string; + /** Context/instructions */ + context?: string; + /** Multi-line input */ + multiline?: boolean; + /** Minimum length */ + minLength?: number; + /** Maximum length */ + maxLength?: number; +} + +export interface AskImageConfig extends BaseConfig { + /** Question/prompt to display */ + question: string; + /** Context/instructions */ + context?: string; + /** Allow multiple images */ + multiple?: boolean; + /** Maximum number of images */ + maxImages?: number; + /** Allowed mime types */ + accept?: string[]; +} + +export interface AskFileConfig extends BaseConfig { + /** Question/prompt to display */ + question: string; + /** Context/instructions */ + context?: string; + /** Allow multiple files */ + multiple?: boolean; + /** Maximum number of files */ + maxFiles?: number; + /** Allowed file extensions or mime types */ + accept?: string[]; + /** Maximum file size in bytes */ + maxSize?: number; +} + +export interface AskCodeConfig extends BaseConfig { + /** Question/prompt to display */ + question: string; + /** Context/instructions */ + context?: string; + /** Programming language for syntax highlighting */ + language?: string; + /** Placeholder code */ + placeholder?: string; +} + +export interface ShowDiffConfig extends BaseConfig { + /** Title/description of the change */ + question: string; + /** Original content */ + before: string; + /** Modified content */ + after: string; + /** File path (for context) */ + filePath?: string; + /** Language for syntax highlighting */ + language?: string; +} + +export interface PlanSection { + /** Section identifier */ + id: string; + /** Section title */ + title: string; + /** Section content (markdown) */ + content: string; +} + +export interface ShowPlanConfig extends BaseConfig { + /** Plan title */ + question: string; + /** Plan sections */ + sections?: PlanSection[]; + /** Full markdown (alternative to sections) */ + markdown?: string; +} + +export interface ShowOptionsConfig extends BaseConfig { + /** Question/prompt to display */ + question: string; + /** Options with pros/cons */ + options: OptionWithPros[]; + /** Recommended option id */ + recommended?: string; + /** Allow text feedback with selection */ + allowFeedback?: boolean; +} + +export interface ReviewSectionConfig extends BaseConfig { + /** Section title */ + question: string; + /** Section content (markdown) */ + content: string; + /** Context about what to review */ + context?: string; +} + +export interface ThumbsConfig extends BaseConfig { + /** Question/prompt to display */ + question: string; + /** Context to show */ + context?: string; +} + +export interface EmojiReactConfig extends BaseConfig { + /** Question/prompt to display */ + question: string; + /** Context to show */ + context?: string; + /** Available emoji options (default: common set) */ + emojis?: string[]; +} + +export interface SliderConfig extends BaseConfig { + /** Question/prompt to display */ + question: string; + /** Context/instructions */ + context?: string; + /** Minimum value */ + min: number; + /** Maximum value */ + max: number; + /** Step size */ + step?: number; + /** Default value */ + defaultValue?: number; + /** Labels for values */ + labels?: { min?: string; max?: string; mid?: string }; +} + +// Response types + +export interface BaseResponse { + /** Whether the interaction completed (false if cancelled/timeout) */ + completed: boolean; + /** Cancellation reason if not completed */ + cancelReason?: "timeout" | "cancelled" | "closed"; +} + +export interface PickOneResponse extends BaseResponse { + /** Selected option id */ + selected?: string; + /** Custom "other" value if provided */ + other?: string; +} + +export interface PickManyResponse extends BaseResponse { + /** Selected option ids */ + selected: string[]; + /** Custom "other" values if provided */ + other?: string[]; +} + +export interface ConfirmResponse extends BaseResponse { + /** User's choice */ + choice?: "yes" | "no" | "cancel"; +} + +export interface RankResponse extends BaseResponse { + /** Option ids in ranked order (first = highest) */ + ranking: string[]; +} + +export interface RateResponse extends BaseResponse { + /** Ratings by option id */ + ratings: Record; +} + +export interface AskTextResponse extends BaseResponse { + /** User's text input */ + text?: string; +} + +export interface AskImageResponse extends BaseResponse { + /** Image data */ + images: Array<{ + /** Original filename */ + filename: string; + /** Mime type */ + mimeType: string; + /** Base64 encoded data */ + data: string; + }>; + /** File paths (if provided instead of upload) */ + paths?: string[]; +} + +export interface AskFileResponse extends BaseResponse { + /** File data */ + files: Array<{ + /** Original filename */ + filename: string; + /** Mime type */ + mimeType: string; + /** Base64 encoded data */ + data: string; + }>; + /** File paths (if provided instead of upload) */ + paths?: string[]; +} + +export interface AskCodeResponse extends BaseResponse { + /** User's code input */ + code?: string; + /** Detected/selected language */ + language?: string; +} + +export interface ShowDiffResponse extends BaseResponse { + /** User's decision */ + decision?: "approve" | "reject" | "edit"; + /** User's edited version (if decision is "edit") */ + edited?: string; + /** Optional feedback */ + feedback?: string; +} + +export interface Annotation { + /** Annotation id */ + id: string; + /** Section id or line range */ + target: string; + /** Annotation type */ + type: "comment" | "suggest" | "delete" | "approve"; + /** Annotation content */ + content?: string; +} + +export interface ShowPlanResponse extends BaseResponse { + /** User's decision */ + decision?: "approve" | "reject" | "revise"; + /** User annotations */ + annotations: Annotation[]; + /** Overall feedback */ + feedback?: string; +} + +export interface ShowOptionsResponse extends BaseResponse { + /** Selected option id */ + selected?: string; + /** Optional feedback text */ + feedback?: string; +} + +export interface ReviewSectionResponse extends BaseResponse { + /** User's decision */ + decision?: "approve" | "revise"; + /** Inline feedback/suggestions */ + feedback?: string; +} + +export interface ThumbsResponse extends BaseResponse { + /** User's choice */ + choice?: "up" | "down"; +} + +export interface EmojiReactResponse extends BaseResponse { + /** Selected emoji */ + emoji?: string; +} + +export interface SliderResponse extends BaseResponse { + /** Selected value */ + value?: number; +} diff --git a/src/octto/ui/bundle.ts b/src/octto/ui/bundle.ts new file mode 100644 index 0000000..6fde3f8 --- /dev/null +++ b/src/octto/ui/bundle.ts @@ -0,0 +1,1650 @@ +// src/octto/ui/bundle.ts + +/** + * Returns the bundled HTML for the octto UI. + * Uses nof1 design system - IBM Plex Mono, terminal aesthetic. + */ +export function getHtmlBundle(): string { + return ` + + + + + Octto + + + + + + + +
+
+

Octto

+

Connecting to session...

+
+
+
+ + + +`; +} diff --git a/src/octto/ui/index.ts b/src/octto/ui/index.ts new file mode 100644 index 0000000..4687435 --- /dev/null +++ b/src/octto/ui/index.ts @@ -0,0 +1,2 @@ +// src/octto/ui/index.ts +export { getHtmlBundle } from "./bundle"; diff --git a/src/tools/artifact-index/index.ts b/src/tools/artifact-index/index.ts index 94de30f..f9cbe14 100644 --- a/src/tools/artifact-index/index.ts +++ b/src/tools/artifact-index/index.ts @@ -1,9 +1,8 @@ // src/tools/artifact-index/index.ts import { Database } from "bun:sqlite"; -import { readFileSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { mkdirSync, existsSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; +import { dirname, join } from "node:path"; const DEFAULT_DB_DIR = join(homedir(), ".config", "opencode", "artifact-index"); const DB_NAME = "context.db"; @@ -27,6 +26,16 @@ export interface LedgerRecord { filesModified?: string; } +export interface MilestoneArtifactRecord { + id: string; + milestoneId: string; + artifactType: string; + sourceSessionId?: string; + createdAt?: string; + tags?: string[]; + payload: string; +} + export interface SearchResult { type: "plan" | "ledger"; id: string; @@ -36,6 +45,18 @@ export interface SearchResult { score: number; } +export interface MilestoneArtifactSearchResult { + type: "milestone"; + id: string; + milestoneId: string; + artifactType: string; + sourceSessionId?: string; + createdAt?: string; + tags: string[]; + payload: string; + score: number; +} + export class ArtifactIndex { private db: Database | null = null; private dbPath: string; @@ -91,8 +112,26 @@ export class ArtifactIndex { created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + CREATE TABLE IF NOT EXISTS milestone_artifacts ( + id TEXT PRIMARY KEY, + milestone_id TEXT NOT NULL, + artifact_type TEXT NOT NULL, + source_session_id TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + tags TEXT, + payload TEXT NOT NULL, + indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); CREATE VIRTUAL TABLE IF NOT EXISTS plans_fts USING fts5(id, title, overview, approach); CREATE VIRTUAL TABLE IF NOT EXISTS ledgers_fts USING fts5(id, session_name, goal, state_now, key_decisions); + CREATE VIRTUAL TABLE IF NOT EXISTS milestone_artifacts_fts USING fts5( + id, + milestone_id, + artifact_type, + payload, + tags, + source_session_id + ); `; } @@ -239,6 +278,116 @@ export class ArtifactIndex { return results.slice(0, limit); } + async indexMilestoneArtifact(record: MilestoneArtifactRecord): Promise { + if (!this.db) throw new Error("Database not initialized"); + + const tags = JSON.stringify(record.tags ?? []); + const createdAt = record.createdAt ?? new Date().toISOString(); + const existing = this.db.query("SELECT id FROM milestone_artifacts WHERE id = ?").get(record.id) as + | { id: string } + | undefined; + + if (existing) { + this.db.run("DELETE FROM milestone_artifacts_fts WHERE id = ?", [existing.id]); + } + + this.db.run( + `INSERT INTO milestone_artifacts ( + id, + milestone_id, + artifact_type, + source_session_id, + created_at, + tags, + payload, + indexed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(id) DO UPDATE SET + milestone_id = excluded.milestone_id, + artifact_type = excluded.artifact_type, + source_session_id = excluded.source_session_id, + created_at = excluded.created_at, + tags = excluded.tags, + payload = excluded.payload, + indexed_at = CURRENT_TIMESTAMP`, + [ + record.id, + record.milestoneId, + record.artifactType, + record.sourceSessionId ?? null, + createdAt, + tags, + record.payload, + ], + ); + + this.db.run( + `INSERT INTO milestone_artifacts_fts ( + id, + milestone_id, + artifact_type, + payload, + tags, + source_session_id + ) VALUES (?, ?, ?, ?, ?, ?)`, + [record.id, record.milestoneId, record.artifactType, record.payload, tags, record.sourceSessionId ?? ""], + ); + } + + async searchMilestoneArtifacts( + query: string, + options: { milestoneId?: string; artifactType?: string; limit?: number } = {}, + ): Promise { + if (!this.db) throw new Error("Database not initialized"); + + const escapedQuery = this.escapeFtsQuery(query); + const milestoneId = options.milestoneId ?? null; + const artifactType = options.artifactType ?? null; + const limit = options.limit ?? 10; + + const rows = this.db + .query( + `SELECT + milestone_artifacts.id, + milestone_artifacts.milestone_id, + milestone_artifacts.artifact_type, + milestone_artifacts.source_session_id, + milestone_artifacts.created_at, + milestone_artifacts.tags, + milestone_artifacts.payload, + milestone_artifacts_fts.rank + FROM milestone_artifacts_fts + JOIN milestone_artifacts ON milestone_artifacts.id = milestone_artifacts_fts.id + WHERE milestone_artifacts_fts MATCH ? + AND (? IS NULL OR milestone_artifacts.milestone_id = ?) + AND (? IS NULL OR milestone_artifacts.artifact_type = ?) + ORDER BY milestone_artifacts_fts.rank + LIMIT ?`, + ) + .all(escapedQuery, milestoneId, milestoneId, artifactType, artifactType, limit) as Array<{ + id: string; + milestone_id: string; + artifact_type: string; + source_session_id: string | null; + created_at: string | null; + tags: string | null; + payload: string; + rank: number; + }>; + + return rows.map((row) => ({ + type: "milestone", + id: row.id, + milestoneId: row.milestone_id, + artifactType: row.artifact_type, + sourceSessionId: row.source_session_id ?? undefined, + createdAt: row.created_at ?? undefined, + tags: row.tags ? JSON.parse(row.tags) : [], + payload: row.payload, + score: -row.rank, + })); + } + private escapeFtsQuery(query: string): string { // Escape special FTS5 characters and wrap terms in quotes return query diff --git a/src/tools/artifact-index/schema.sql b/src/tools/artifact-index/schema.sql index 3e01735..2fa888f 100644 --- a/src/tools/artifact-index/schema.sql +++ b/src/tools/artifact-index/schema.sql @@ -27,6 +27,18 @@ CREATE TABLE IF NOT EXISTS ledgers ( indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- Milestone artifacts table +CREATE TABLE IF NOT EXISTS milestone_artifacts ( + id TEXT PRIMARY KEY, + milestone_id TEXT NOT NULL, + artifact_type TEXT NOT NULL, + source_session_id TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + tags TEXT, + payload TEXT NOT NULL, + indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + -- FTS5 virtual tables for full-text search (standalone, manually synced) CREATE VIRTUAL TABLE IF NOT EXISTS plans_fts USING fts5( id, @@ -42,3 +54,12 @@ CREATE VIRTUAL TABLE IF NOT EXISTS ledgers_fts USING fts5( state_now, key_decisions ); + +CREATE VIRTUAL TABLE IF NOT EXISTS milestone_artifacts_fts USING fts5( + id, + milestone_id, + artifact_type, + payload, + tags, + source_session_id +); diff --git a/src/tools/milestone-artifact-search.ts b/src/tools/milestone-artifact-search.ts new file mode 100644 index 0000000..6b1d0e5 --- /dev/null +++ b/src/tools/milestone-artifact-search.ts @@ -0,0 +1,48 @@ +import { tool } from "@opencode-ai/plugin/tool"; + +import { getArtifactIndex } from "./artifact-index"; + +const ARTIFACT_TYPES = ["feature", "decision", "session"] as const; + +export const milestone_artifact_search = tool({ + description: `Search milestone-driven artifacts stored in SQLite. +Use this to find feature, decision, or session artifacts for a specific milestone. +Returns ranked results filtered by milestone metadata.`, + args: { + query: tool.schema.string().describe("Search query for milestone artifacts"), + milestone_id: tool.schema.string().optional().describe("Optional milestone identifier to filter results"), + artifact_type: tool.schema.enum(ARTIFACT_TYPES).optional().describe("Optional artifact type to filter results"), + limit: tool.schema.number().optional().describe("Max results to return (default: 10)"), + }, + execute: async (args) => { + try { + const index = await getArtifactIndex(); + const results = await index.searchMilestoneArtifacts(args.query, { + milestoneId: args.milestone_id, + artifactType: args.artifact_type, + limit: args.limit, + }); + + if (results.length === 0) { + return "No milestone artifact results found for that query."; + } + + let output = `## Milestone Artifact Search Results\n\nFound ${results.length} result(s).\n\n`; + + for (const result of results) { + const tags = result.tags.length ? result.tags.join(", ") : "none"; + output += `### ${result.milestoneId} · ${result.artifactType}\n`; + output += `- ID: ${result.id}\n`; + output += `- Source Session: ${result.sourceSessionId ?? "unknown"}\n`; + output += `- Created: ${result.createdAt ?? "unknown"}\n`; + output += `- Tags: ${tags}\n`; + output += `- Payload: ${result.payload}\n`; + output += `- Score: ${result.score.toFixed(2)}\n\n`; + } + + return output; + } catch (error) { + return `Error searching milestone artifacts: ${error instanceof Error ? error.message : String(error)}`; + } + }, +}); diff --git a/src/tools/octto/brainstorm.ts b/src/tools/octto/brainstorm.ts new file mode 100644 index 0000000..fbdd426 --- /dev/null +++ b/src/tools/octto/brainstorm.ts @@ -0,0 +1,332 @@ +// src/tools/octto/brainstorm.ts +import { tool } from "@opencode-ai/plugin/tool"; + +import type { ReviewAnswer, SessionStore } from "../../octto/session"; +import { QUESTION_TYPES, QUESTIONS, STATUSES } from "../../octto/session"; +import { BRANCH_STATUSES, type BrainstormState, createStateStore, type StateStore } from "../../octto/state"; +import { config } from "../../utils/config"; +import { log } from "../../utils/logger"; +import { formatBranchStatus, formatFindings, formatFindingsList, formatQASummary } from "./formatters"; +import { processAnswer } from "./processor"; +import type { OcttoSessionTracker, OcttoTools, OpencodeClient } from "./types"; +import { generateSessionId } from "./utils"; + +// --- Extracted helper functions --- + +interface CollectionResult { + state: BrainstormState | null; + allComplete: boolean; +} + +async function collectAnswers( + stateStore: StateStore, + sessions: SessionStore, + sessionId: string, + browserSessionId: string, + client: OpencodeClient, +): Promise { + const pendingProcessing: Promise[] = []; + + for (let i = 0; i < config.octto.maxIterations; i++) { + if (await stateStore.isSessionComplete(sessionId)) break; + + const answer = await sessions.getNextAnswer({ + session_id: browserSessionId, + block: true, + timeout: config.octto.answerTimeoutMs, + }); + + if (!answer.completed) { + if (answer.status === STATUSES.NONE_PENDING) { + await Promise.all(pendingProcessing); + pendingProcessing.length = 0; + continue; + } + if (answer.status === STATUSES.TIMEOUT) break; + continue; + } + + const { question_id, response } = answer; + if (!question_id || response === undefined) continue; + + const processing = processAnswer( + stateStore, + sessions, + sessionId, + browserSessionId, + question_id, + response, + client, + ).catch((error) => { + log.error("octto", `Error processing answer ${question_id}`, error); + }); + pendingProcessing.push(processing); + } + + await Promise.all(pendingProcessing); + + const [state, allComplete] = await Promise.all([ + stateStore.getSession(sessionId), + stateStore.isSessionComplete(sessionId), + ]); + + return { state, allComplete }; +} + +interface ReviewSection { + id: string; + title: string; + content: string; +} + +function buildReviewSections(state: BrainstormState): ReviewSection[] { + return [ + { + id: "summary", + title: "Original Request", + content: state.request, + }, + ...state.branch_order.map((id) => { + const b = state.branches[id]; + const qaSummary = formatQASummary(b); + return { + id, + title: b.scope, + content: `**Finding:** ${b.finding || "No finding"}\n\n**Discussion:**\n${qaSummary || "(no questions answered)"}`, + }; + }), + ]; +} + +interface ReviewResult { + approved: boolean; + feedback: string; +} + +async function waitForReviewApproval(sessions: SessionStore, browserSessionId: string): Promise { + const result = await sessions.getNextAnswer({ + session_id: browserSessionId, + block: true, + timeout: config.octto.reviewTimeoutMs, + }); + + if (!result.completed || !result.response) { + return { approved: false, feedback: "" }; + } + + const response = result.response as ReviewAnswer; + return { + approved: response.decision === "approve", + feedback: response.feedback ?? "", + }; +} + +// --- Format functions --- + +function formatInProgressResult(state: BrainstormState): string { + const branches = state.branch_order.map((id) => formatBranchStatus(state.branches[id])).join("\n"); + return ` + ${state.request} + +${branches} + + Call await_brainstorm_complete again to continue +`; +} + +function formatSkippedReviewResult(state: BrainstormState): string { + return ` + ${state.request} + ${state.branch_order.length} + Browser session ended before review + ${formatFindings(state)} + Write the design document to thoughts/shared/designs/ +`; +} + +function formatCompletionResult(state: BrainstormState, approved: boolean, feedback: string): string { + const feedbackXml = feedback ? `\n ${feedback}` : ""; + const nextAction = approved + ? "Write the design document to thoughts/shared/designs/" + : "Review feedback and discuss with user before proceeding"; + return ` + ${state.request} + ${state.branch_order.length}${feedbackXml} + ${formatFindings(state)} + ${nextAction} +`; +} + +// --- Tool definitions --- + +export function createBrainstormTools( + sessions: SessionStore, + client: OpencodeClient, + tracker?: OcttoSessionTracker, +): OcttoTools { + const store = createStateStore(); + + const create_brainstorm = tool({ + description: "Create a new brainstorm session with exploration branches", + args: { + request: tool.schema.string().describe("The original user request"), + branches: tool.schema + .array( + tool.schema.object({ + id: tool.schema.string(), + scope: tool.schema.string(), + initial_question: tool.schema.object({ + type: tool.schema.enum(QUESTION_TYPES), + config: tool.schema.looseObject({ + question: tool.schema.string().optional(), + context: tool.schema.string().optional(), + }), + }), + }), + ) + .describe("Branches to explore"), + }, + execute: async (args, context) => { + const sessionId = generateSessionId(); + + await store.createSession( + sessionId, + args.request, + args.branches.map((b) => ({ id: b.id, scope: b.scope })), + ); + + const initialQuestions = args.branches.map((b) => { + const { type, config } = b.initial_question; + const context = `[${b.scope}] ${config.context ?? ""}`.trim(); + return { + type, + config: { ...config, context }, + }; + }); + + const browserSession = await sessions.startSession({ + title: "Brainstorming Session", + questions: initialQuestions, + }); + + tracker?.onCreated?.(context.sessionID, browserSession.session_id); + await store.setBrowserSessionId(sessionId, browserSession.session_id); + + for (const [i, branch] of args.branches.entries()) { + const questionId = browserSession.question_ids?.[i]; + if (!questionId) continue; + + const { type, config } = branch.initial_question; + await store.addQuestionToBranch(sessionId, branch.id, { + id: questionId, + type, + text: config.question ?? "Question", + config, + }); + } + + const branchesXml = args.branches.map((b) => ` ${b.scope}`).join("\n"); + return ` + ${sessionId} + ${browserSession.session_id} + ${browserSession.url} + +${branchesXml} + + Call get_next_answer(session_id="${browserSession.session_id}", block=true) +`; + }, + }); + + const get_session_summary = tool({ + description: "Get summary of all branches and their findings", + args: { + session_id: tool.schema.string().describe("Brainstorm session ID"), + }, + execute: async (args) => { + const state = await store.getSession(args.session_id); + if (!state) return `Session not found: ${args.session_id}`; + + const branches = state.branch_order.map((id) => formatBranchStatus(state.branches[id])).join("\n"); + const allDone = Object.values(state.branches).every((b) => b.status === BRANCH_STATUSES.DONE); + + return ` + ${state.request} + ${allDone ? "complete" : "in_progress"} + +${branches} + +`; + }, + }); + + const end_brainstorm = tool({ + description: "End a brainstorm session and get final summary", + args: { + session_id: tool.schema.string().describe("Brainstorm session ID"), + }, + execute: async (args, context) => { + const state = await store.getSession(args.session_id); + if (!state) return `Session not found: ${args.session_id}`; + + if (state.browser_session_id) { + const result = await sessions.endSession(state.browser_session_id); + if (result.ok) { + tracker?.onEnded?.(context.sessionID, state.browser_session_id); + } + } + + const findings = formatFindingsList(state); + await store.deleteSession(args.session_id); + + return ` + ${state.request} + ${findings} + Write the design document based on these findings to thoughts/shared/designs/ +`; + }, + }); + + const await_brainstorm_complete = tool({ + description: `Wait for brainstorm session to complete. Processes answers asynchronously as they arrive. +Returns when all branches are done with their findings. +This is the recommended way to run a brainstorm - just create_brainstorm then await_brainstorm_complete.`, + args: { + session_id: tool.schema.string().describe("Brainstorm session ID (state session)"), + browser_session_id: tool.schema.string().describe("Browser session ID (for collecting answers)"), + }, + execute: async (args) => { + const { state, allComplete } = await collectAnswers( + store, + sessions, + args.session_id, + args.browser_session_id, + client, + ); + + if (!state) return "Session lost"; + if (!allComplete) return formatInProgressResult(state); + + const sections = buildReviewSections(state); + + try { + sessions.pushQuestion(args.browser_session_id, QUESTIONS.SHOW_PLAN, { + question: "Review Design Plan", + sections, + }); + } catch { + return formatSkippedReviewResult(state); + } + + const { approved, feedback } = await waitForReviewApproval(sessions, args.browser_session_id); + return formatCompletionResult(state, approved, feedback); + }, + }); + + return { + create_brainstorm, + get_session_summary, + end_brainstorm, + await_brainstorm_complete, + }; +} diff --git a/src/tools/octto/extractor.ts b/src/tools/octto/extractor.ts new file mode 100644 index 0000000..ce0be1c --- /dev/null +++ b/src/tools/octto/extractor.ts @@ -0,0 +1,95 @@ +// src/tools/octto/extractor.ts +// Utility functions for extracting answer summaries + +import type { + Answer, + AskCodeAnswer, + AskTextAnswer, + ConfirmAnswer, + EmojiReactAnswer, + PickManyAnswer, + PickOneAnswer, + QuestionType, + RankAnswer, + RateAnswer, + ReviewAnswer, + ShowOptionsAnswer, + SliderAnswer, + ThumbsAnswer, +} from "../../octto/session"; +import { QUESTIONS } from "../../octto/session"; + +const MAX_TEXT_LENGTH = 100; + +function truncateText(text: string): string { + return text.length > MAX_TEXT_LENGTH ? `${text.substring(0, MAX_TEXT_LENGTH)}...` : text; +} + +export function extractAnswerSummary(type: QuestionType, answer: Answer): string { + switch (type) { + case QUESTIONS.PICK_ONE: + return (answer as PickOneAnswer).selected; + + case QUESTIONS.PICK_MANY: + return (answer as PickManyAnswer).selected.join(", "); + + case QUESTIONS.CONFIRM: + return (answer as ConfirmAnswer).choice; + + case QUESTIONS.THUMBS: + return (answer as ThumbsAnswer).choice; + + case QUESTIONS.EMOJI_REACT: + return (answer as EmojiReactAnswer).emoji; + + case QUESTIONS.ASK_TEXT: + return truncateText((answer as AskTextAnswer).text); + + case QUESTIONS.SLIDER: + return String((answer as SliderAnswer).value); + + case QUESTIONS.RANK: { + const rankAnswer = answer as RankAnswer; + const sorted = [...rankAnswer.ranking].sort((a, b) => a.rank - b.rank); + return sorted.map((r) => r.id).join(" → "); + } + + case QUESTIONS.RATE: { + const rateAnswer = answer as RateAnswer; + const entries = Object.entries(rateAnswer.ratings); + if (entries.length === 0) return "no ratings"; + const sorted = entries.sort((a, b) => b[1] - a[1]); + return sorted + .slice(0, 3) + .map(([k, v]) => `${k}: ${v}`) + .join(", "); + } + + case QUESTIONS.ASK_CODE: + return truncateText((answer as AskCodeAnswer).code); + + case QUESTIONS.ASK_IMAGE: + case QUESTIONS.ASK_FILE: + return "file(s) uploaded"; + + case QUESTIONS.SHOW_DIFF: + case QUESTIONS.SHOW_PLAN: + case QUESTIONS.REVIEW_SECTION: { + const reviewAnswer = answer as ReviewAnswer; + return reviewAnswer.feedback + ? `${reviewAnswer.decision}: ${truncateText(reviewAnswer.feedback)}` + : reviewAnswer.decision; + } + + case QUESTIONS.SHOW_OPTIONS: { + const optAnswer = answer as ShowOptionsAnswer; + return optAnswer.feedback ? `${optAnswer.selected}: ${truncateText(optAnswer.feedback)}` : optAnswer.selected; + } + + default: { + // Exhaustiveness check - if we get here, we missed a case + const _exhaustive: never = type; + return String(_exhaustive); + } + } +} diff --git a/src/tools/octto/factory.ts b/src/tools/octto/factory.ts new file mode 100644 index 0000000..0381f4b --- /dev/null +++ b/src/tools/octto/factory.ts @@ -0,0 +1,89 @@ +// src/tools/octto/factory.ts + +import { tool } from "@opencode-ai/plugin/tool"; + +import type { BaseConfig, QuestionType, SessionStore } from "../../octto/session"; +import type { OcttoTool, OcttoTools } from "./types"; + +type ArgsSchema = Parameters[0]["args"]; + +interface QuestionToolConfig { + type: QuestionType; + description: string; + args: ArgsSchema; + validate?: (args: T) => string | null; + toConfig: (args: T) => BaseConfig; +} + +export function createQuestionToolFactory(sessions: SessionStore) { + return function createQuestionTool(config: QuestionToolConfig): OcttoTool { + return tool({ + description: `${config.description} +Returns immediately with question_id. Use get_answer to retrieve response.`, + args: { + session_id: tool.schema.string().describe("Session ID from start_session"), + ...config.args, + }, + execute: async (args) => { + const validationError = config.validate?.(args as unknown as T); + if (validationError) return `Failed: ${validationError}`; + + try { + const questionConfig = config.toConfig(args as unknown as T); + const result = sessions.pushQuestion(args.session_id, config.type, questionConfig); + return `Question pushed: ${result.question_id}\nUse get_answer("${result.question_id}") to retrieve response.`; + } catch (error) { + return `Failed: ${error instanceof Error ? error.message : String(error)}`; + } + }, + }); + }; +} + +export function createPushQuestionTool(sessions: SessionStore): OcttoTools { + const push_question = tool({ + description: `Push a question to the session queue. This is the generic tool for adding any question type. +The question will appear in the browser for the user to answer.`, + args: { + session_id: tool.schema.string().describe("Session ID from start_session"), + type: tool.schema + .enum([ + "pick_one", + "pick_many", + "confirm", + "ask_text", + "ask_image", + "ask_file", + "ask_code", + "show_diff", + "show_plan", + "show_options", + "review_section", + "thumbs", + "slider", + "rank", + "rate", + "emoji_react", + ]) + .describe("Question type"), + config: tool.schema + .looseObject({ + question: tool.schema.string().optional(), + context: tool.schema.string().optional(), + }) + .describe("Question configuration (varies by type)"), + }, + execute: async (args) => { + try { + const result = sessions.pushQuestion(args.session_id, args.type, args.config); + return `Question pushed: ${result.question_id} +Type: ${args.type} +Use get_next_answer(session_id, block=true) to wait for the user's response.`; + } catch (error) { + return `Failed to push question: ${error instanceof Error ? error.message : String(error)}`; + } + }, + }); + + return { push_question }; +} diff --git a/src/tools/octto/formatters.ts b/src/tools/octto/formatters.ts new file mode 100644 index 0000000..c45a34d --- /dev/null +++ b/src/tools/octto/formatters.ts @@ -0,0 +1,63 @@ +// src/tools/octto/formatters.ts + +import type { Answer } from "../../octto/session"; +import type { BrainstormState, Branch, BranchQuestion } from "../../octto/state"; +import { extractAnswerSummary } from "./extractor"; + +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function formatBranchFinding(branch: Branch): string { + return ` + ${escapeXml(branch.scope)} + ${escapeXml(branch.finding || "no finding")} + `; +} + +export function formatBranchStatus(branch: Branch): string { + return ` + ${escapeXml(branch.scope)} + ${escapeXml(branch.finding || "pending")} + `; +} + +export function formatFindings(state: BrainstormState): string { + const branches = state.branch_order.map((id) => formatBranchFinding(state.branches[id])).join("\n"); + return `\n${branches}\n`; +} + +export function formatFindingsList(state: BrainstormState): string { + const items = state.branch_order + .map((id) => { + const b = state.branches[id]; + return ` ${escapeXml(b.finding || "no finding")}`; + }) + .join("\n"); + return `\n${items}\n`; +} + +export function formatQASummary(branch: Branch): string { + const answered = branch.questions.filter((q): q is BranchQuestion & { answer: Answer } => q.answer !== undefined); + + if (answered.length === 0) { + return "no questions answered"; + } + + const qas = answered + .map((q) => { + const answerText = extractAnswerSummary(q.type, q.answer); + return ` + ${escapeXml(q.text)} + ${escapeXml(answerText)} + `; + }) + .join("\n"); + + return qas; +} diff --git a/src/tools/octto/index.ts b/src/tools/octto/index.ts new file mode 100644 index 0000000..3562198 --- /dev/null +++ b/src/tools/octto/index.ts @@ -0,0 +1,27 @@ +// src/tools/octto/index.ts + +import type { SessionStore } from "../../octto/session"; +import { createBrainstormTools } from "./brainstorm"; +import { createPushQuestionTool } from "./factory"; +import { createQuestionTools } from "./questions"; +import { createResponseTools } from "./responses"; +import { createSessionTools } from "./session"; +import type { OcttoSessionTracker, OcttoTools, OpencodeClient } from "./types"; + +export type { SessionStore } from "../../octto/session"; +export { createSessionStore } from "../../octto/session"; +export type { OcttoSessionTracker, OcttoTools, OpencodeClient } from "./types"; + +export function createOcttoTools( + sessions: SessionStore, + client: OpencodeClient, + tracker?: OcttoSessionTracker, +): OcttoTools { + return { + ...createSessionTools(sessions, tracker), + ...createQuestionTools(sessions), + ...createResponseTools(sessions), + ...createPushQuestionTool(sessions), + ...createBrainstormTools(sessions, client, tracker), + }; +} diff --git a/src/tools/octto/processor.ts b/src/tools/octto/processor.ts new file mode 100644 index 0000000..cfe9a8f --- /dev/null +++ b/src/tools/octto/processor.ts @@ -0,0 +1,165 @@ +// src/tools/octto/processor.ts + +import type { Answer, QuestionType, SessionStore } from "../../octto/session"; +import { BRANCH_STATUSES, type BrainstormState, type StateStore } from "../../octto/state"; +import { log } from "../../utils/logger"; + +import type { OpencodeClient } from "./types"; + +// Agent name constant - matches the agent exported from src/agents/probe.ts +const PROBE_AGENT = "probe"; + +interface ProbeResult { + done: boolean; + finding?: string; + question?: { + type: QuestionType; + config: Record; + }; +} + +function formatBranchContext(state: BrainstormState, branchId: string): string { + const lines: string[] = []; + + lines.push(`${state.request}`); + lines.push(""); + lines.push(""); + + for (const [id, branch] of Object.entries(state.branches)) { + const isCurrent = id === branchId; + lines.push(``); + + for (const q of branch.questions) { + lines.push(` ${q.text}`); + if (q.answer) { + lines.push(` ${JSON.stringify(q.answer)}`); + } + } + + if (branch.status === BRANCH_STATUSES.DONE && branch.finding) { + lines.push(` ${branch.finding}`); + } + + lines.push(""); + } + + lines.push(""); + lines.push(""); + lines.push(`Evaluate the branch "${branchId}" and decide: ask another question or complete with a finding.`); + + return lines.join("\n"); +} + +async function runProbeAgent(client: OpencodeClient, state: BrainstormState, branchId: string): Promise { + const sessionResult = await client.session.create({ + body: { title: `probe-${branchId}` }, + }); + + if (!sessionResult.data) { + throw new Error("Failed to create probe session"); + } + + const probeSessionId = sessionResult.data.id; + + try { + const promptResult = await client.session.prompt({ + path: { id: probeSessionId }, + body: { + agent: PROBE_AGENT, + tools: {}, + parts: [{ type: "text", text: formatBranchContext(state, branchId) }], + }, + }); + + if (!promptResult.data) { + throw new Error("Failed to get probe response"); + } + + let responseText = ""; + for (const part of promptResult.data.parts) { + if (part.type === "text" && "text" in part) { + responseText += (part as { text: string }).text; + } + } + + const jsonMatch = responseText.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + return { done: true, finding: "Could not parse probe response" }; + } + + return JSON.parse(jsonMatch[0]) as ProbeResult; + } finally { + await client.session.delete({ path: { id: probeSessionId } }).catch(() => {}); + } +} + +export async function processAnswer( + stateStore: StateStore, + sessions: SessionStore, + sessionId: string, + browserSessionId: string, + questionId: string, + answer: Answer, + client: OpencodeClient, +): Promise { + const state = await stateStore.getSession(sessionId); + if (!state) return; + + // Find which branch this question belongs to + let branchId: string | null = null; + for (const [id, branch] of Object.entries(state.branches)) { + if (branch.questions.some((q) => q.id === questionId)) { + branchId = id; + break; + } + } + + if (!branchId) return; + if (state.branches[branchId].status === BRANCH_STATUSES.DONE) return; + + // Record the answer + try { + await stateStore.recordAnswer(sessionId, questionId, answer); + } catch (error) { + log.error("octto", `Failed to record answer for ${questionId}`, error); + throw error; + } + + // Get fresh state after recording + const updatedState = await stateStore.getSession(sessionId); + if (!updatedState) return; + + const branch = updatedState.branches[branchId]; + if (!branch || branch.status === BRANCH_STATUSES.DONE) return; + + // Evaluate branch using probe agent + const result = await runProbeAgent(client, updatedState, branchId); + + if (result.done) { + await stateStore.completeBranch(sessionId, branchId, result.finding || "No finding"); + return; + } + + if (result.question) { + const config = result.question.config as { question?: string; context?: string }; + const questionText = config.question ?? "Follow-up question"; + const existingContext = config.context ?? ""; + const configWithContext = { + ...config, + context: `[${branch.scope}] ${existingContext}`.trim(), + }; + + const { question_id: newQuestionId } = sessions.pushQuestion( + browserSessionId, + result.question.type, + configWithContext, + ); + + await stateStore.addQuestionToBranch(sessionId, branchId, { + id: newQuestionId, + type: result.question.type, + text: questionText, + config: configWithContext, + }); + } +} diff --git a/src/tools/octto/questions.ts b/src/tools/octto/questions.ts new file mode 100644 index 0000000..0711fe8 --- /dev/null +++ b/src/tools/octto/questions.ts @@ -0,0 +1,508 @@ +// src/tools/octto/questions.ts +import { tool } from "@opencode-ai/plugin/tool"; + +import type { SessionStore } from "../../octto/session"; +import type { ConfirmConfig, PickManyConfig, PickOneConfig, RankConfig, RateConfig } from "../../octto/types"; +import { createQuestionToolFactory } from "./factory"; +import type { OcttoTools } from "./types"; + +const optionsSchema = tool.schema + .array( + tool.schema.object({ + id: tool.schema.string().describe("Unique option identifier"), + label: tool.schema.string().describe("Display label"), + description: tool.schema.string().optional().describe("Optional description"), + }), + ) + .describe("Available options"); + +function requireOptions(args: { options?: unknown[] }): string | null { + if (!args.options || args.options.length === 0) return "options array must not be empty"; + return null; +} + +export function createQuestionTools(sessions: SessionStore): OcttoTools { + const createTool = createQuestionToolFactory(sessions); + + const pick_one = createTool({ + type: "pick_one", + description: `Ask user to select ONE option from a list. +Response format: { selected: string } where selected is the chosen option id.`, + args: { + question: tool.schema.string().describe("Question to display"), + options: optionsSchema, + recommended: tool.schema.string().optional().describe("Recommended option id (highlighted)"), + allowOther: tool.schema.boolean().optional().describe("Allow custom 'other' input"), + }, + validate: requireOptions, + toConfig: (args) => ({ + question: args.question, + options: args.options, + recommended: args.recommended, + allowOther: args.allowOther, + }), + }); + + const pick_many = createTool({ + type: "pick_many", + description: `Ask user to select MULTIPLE options from a list. +Response format: { selected: string[] } where selected is array of chosen option ids.`, + args: { + question: tool.schema.string().describe("Question to display"), + options: optionsSchema, + recommended: tool.schema.array(tool.schema.string()).optional().describe("Recommended option ids"), + min: tool.schema.number().optional().describe("Minimum selections required"), + max: tool.schema.number().optional().describe("Maximum selections allowed"), + allowOther: tool.schema.boolean().optional().describe("Allow custom 'other' input"), + }, + validate: (args) => { + if (!args.options || args.options.length === 0) return "options array must not be empty"; + if (args.min !== undefined && args.max !== undefined && args.min > args.max) { + return `min (${args.min}) cannot be greater than max (${args.max})`; + } + return null; + }, + toConfig: (args) => ({ + question: args.question, + options: args.options, + recommended: args.recommended, + min: args.min, + max: args.max, + allowOther: args.allowOther, + }), + }); + + const confirm = createTool({ + type: "confirm", + description: `Ask user for Yes/No confirmation. +Response format: { choice: "yes" | "no" | "cancel" }`, + args: { + question: tool.schema.string().describe("Question to display"), + context: tool.schema.string().optional().describe("Additional context/details"), + yesLabel: tool.schema.string().optional().describe("Custom label for yes button"), + noLabel: tool.schema.string().optional().describe("Custom label for no button"), + allowCancel: tool.schema.boolean().optional().describe("Show cancel option"), + }, + toConfig: (args) => ({ + question: args.question, + context: args.context, + yesLabel: args.yesLabel, + noLabel: args.noLabel, + allowCancel: args.allowCancel, + }), + }); + + const rank = createTool({ + type: "rank", + description: `Ask user to rank/order items by dragging. +Response format: { ranked: string[] } where ranked is array of option ids in user's order (first = highest).`, + args: { + question: tool.schema.string().describe("Question to display"), + options: optionsSchema.describe("Items to rank"), + context: tool.schema.string().optional().describe("Instructions/context"), + }, + validate: requireOptions, + toConfig: (args) => ({ + question: args.question, + options: args.options, + context: args.context, + }), + }); + + const rate = createTool({ + type: "rate", + description: `Ask user to rate items on a numeric scale. +Response format: { ratings: Record } where key is option id, value is rating.`, + args: { + question: tool.schema.string().describe("Question to display"), + options: optionsSchema.describe("Items to rate"), + min: tool.schema.number().optional().describe("Minimum rating value (default: 1)"), + max: tool.schema.number().optional().describe("Maximum rating value (default: 5)"), + step: tool.schema.number().optional().describe("Rating step (default: 1)"), + labels: tool.schema + .object({ + min: tool.schema.string().optional().describe("Label for minimum value"), + max: tool.schema.string().optional().describe("Label for maximum value"), + }) + .optional() + .describe("Optional labels for min/max"), + }, + validate: (args) => { + if (!args.options || args.options.length === 0) return "options array must not be empty"; + const min = args.min ?? 1; + const max = args.max ?? 5; + if (min >= max) return `min (${min}) must be less than max (${max})`; + return null; + }, + toConfig: (args) => ({ + question: args.question, + options: args.options, + min: args.min ?? 1, + max: args.max ?? 5, + step: args.step, + labels: args.labels, + }), + }); + + // Import remaining tools from other files + const inputTools = createInputTools(sessions); + const presentationTools = createPresentationTools(sessions); + const quickTools = createQuickTools(sessions); + + return { + pick_one, + pick_many, + confirm, + rank, + rate, + ...inputTools, + ...presentationTools, + ...quickTools, + }; +} + +// Input tools using factory +function createInputTools(sessions: SessionStore): OcttoTools { + const createTool = createQuestionToolFactory(sessions); + + interface TextConfig { + session_id: string; + question: string; + placeholder?: string; + context?: string; + multiline?: boolean; + minLength?: number; + maxLength?: number; + } + + const ask_text = createTool({ + type: "ask_text", + description: `Ask user for text input (single or multi-line). +Response format: { text: string }`, + args: { + question: tool.schema.string().describe("Question to display"), + placeholder: tool.schema.string().optional().describe("Placeholder text"), + context: tool.schema.string().optional().describe("Instructions/context"), + multiline: tool.schema.boolean().optional().describe("Multi-line input (default: false)"), + minLength: tool.schema.number().optional().describe("Minimum text length"), + maxLength: tool.schema.number().optional().describe("Maximum text length"), + }, + toConfig: (args) => ({ + question: args.question, + placeholder: args.placeholder, + context: args.context, + multiline: args.multiline, + minLength: args.minLength, + maxLength: args.maxLength, + }), + }); + + interface ImageConfig { + session_id: string; + question: string; + context?: string; + multiple?: boolean; + maxImages?: number; + accept?: string[]; + } + + const ask_image = createTool({ + type: "ask_image", + description: "Ask user to upload/paste image(s).", + args: { + question: tool.schema.string().describe("Question to display"), + context: tool.schema.string().optional().describe("Instructions/context"), + multiple: tool.schema.boolean().optional().describe("Allow multiple images"), + maxImages: tool.schema.number().optional().describe("Maximum number of images"), + accept: tool.schema.array(tool.schema.string()).optional().describe("Allowed image types"), + }, + toConfig: (args) => ({ + question: args.question, + context: args.context, + multiple: args.multiple, + maxImages: args.maxImages, + accept: args.accept, + }), + }); + + interface FileConfig { + session_id: string; + question: string; + context?: string; + multiple?: boolean; + maxFiles?: number; + accept?: string[]; + maxSize?: number; + } + + const ask_file = createTool({ + type: "ask_file", + description: "Ask user to upload file(s).", + args: { + question: tool.schema.string().describe("Question to display"), + context: tool.schema.string().optional().describe("Instructions/context"), + multiple: tool.schema.boolean().optional().describe("Allow multiple files"), + maxFiles: tool.schema.number().optional().describe("Maximum number of files"), + accept: tool.schema.array(tool.schema.string()).optional().describe("Allowed file types"), + maxSize: tool.schema.number().optional().describe("Maximum file size in bytes"), + }, + toConfig: (args) => ({ + question: args.question, + context: args.context, + multiple: args.multiple, + maxFiles: args.maxFiles, + accept: args.accept, + maxSize: args.maxSize, + }), + }); + + interface CodeConfig { + session_id: string; + question: string; + context?: string; + language?: string; + placeholder?: string; + } + + const ask_code = createTool({ + type: "ask_code", + description: "Ask user for code input with syntax highlighting.", + args: { + question: tool.schema.string().describe("Question to display"), + context: tool.schema.string().optional().describe("Instructions/context"), + language: tool.schema.string().optional().describe("Programming language for highlighting"), + placeholder: tool.schema.string().optional().describe("Placeholder code"), + }, + toConfig: (args) => ({ + question: args.question, + context: args.context, + language: args.language, + placeholder: args.placeholder, + }), + }); + + return { ask_text, ask_image, ask_file, ask_code }; +} + +// Presentation tools using factory +function createPresentationTools(sessions: SessionStore): OcttoTools { + const createTool = createQuestionToolFactory(sessions); + + interface DiffConfig { + session_id: string; + question: string; + before: string; + after: string; + filePath?: string; + language?: string; + } + + const show_diff = createTool({ + type: "show_diff", + description: "Show a diff and ask user to approve/reject/edit.", + args: { + question: tool.schema.string().describe("Title/description of the change"), + before: tool.schema.string().describe("Original content"), + after: tool.schema.string().describe("Modified content"), + filePath: tool.schema.string().optional().describe("File path for context"), + language: tool.schema.string().optional().describe("Language for syntax highlighting"), + }, + toConfig: (args) => ({ + question: args.question, + before: args.before, + after: args.after, + filePath: args.filePath, + language: args.language, + }), + }); + + const sectionSchema = tool.schema.array( + tool.schema.object({ + id: tool.schema.string().describe("Section identifier"), + title: tool.schema.string().describe("Section title"), + content: tool.schema.string().describe("Section content (markdown)"), + }), + ); + + interface PlanConfig { + session_id: string; + question: string; + sections?: Array<{ id: string; title: string; content: string }>; + markdown?: string; + } + + const show_plan = createTool({ + type: "show_plan", + description: `Show a plan/document for user review with annotations. +Response format: { approved: boolean, annotations?: Record }`, + args: { + question: tool.schema.string().describe("Plan title"), + sections: sectionSchema.optional().describe("Plan sections"), + markdown: tool.schema.string().optional().describe("Full markdown (alternative to sections)"), + }, + toConfig: (args) => ({ + question: args.question, + sections: args.sections, + markdown: args.markdown, + }), + }); + + const prosConsOptionSchema = tool.schema.array( + tool.schema.object({ + id: tool.schema.string().describe("Unique option identifier"), + label: tool.schema.string().describe("Display label"), + description: tool.schema.string().optional().describe("Optional description"), + pros: tool.schema.array(tool.schema.string()).optional().describe("Advantages"), + cons: tool.schema.array(tool.schema.string()).optional().describe("Disadvantages"), + }), + ); + + interface ShowOptionsConfig { + session_id: string; + question: string; + options: Array<{ id: string; label: string; description?: string; pros?: string[]; cons?: string[] }>; + recommended?: string; + allowFeedback?: boolean; + } + + const show_options = createTool({ + type: "show_options", + description: `Show options with pros/cons for user to select. +Response format: { selected: string, feedback?: string } where selected is the chosen option id.`, + args: { + question: tool.schema.string().describe("Question to display"), + options: prosConsOptionSchema.describe("Options with pros/cons"), + recommended: tool.schema.string().optional().describe("Recommended option id"), + allowFeedback: tool.schema.boolean().optional().describe("Allow text feedback with selection"), + }, + validate: (args) => { + if (!args.options || args.options.length === 0) return "options array must not be empty"; + return null; + }, + toConfig: (args) => ({ + question: args.question, + options: args.options, + recommended: args.recommended, + allowFeedback: args.allowFeedback, + }), + }); + + interface ReviewConfig { + session_id: string; + question: string; + content: string; + context?: string; + } + + const review_section = createTool({ + type: "review_section", + description: "Show content section for user review with inline feedback.", + args: { + question: tool.schema.string().describe("Section title"), + content: tool.schema.string().describe("Section content (markdown)"), + context: tool.schema.string().optional().describe("Context about what to review"), + }, + toConfig: (args) => ({ + question: args.question, + content: args.content, + context: args.context, + }), + }); + + return { show_diff, show_plan, show_options, review_section }; +} + +// Quick tools using factory +function createQuickTools(sessions: SessionStore): OcttoTools { + const createTool = createQuestionToolFactory(sessions); + + interface ThumbsConfig { + session_id: string; + question: string; + context?: string; + } + + const thumbs = createTool({ + type: "thumbs", + description: `Ask user for quick thumbs up/down feedback. +Response format: { choice: "up" | "down" }`, + args: { + question: tool.schema.string().describe("Question to display"), + context: tool.schema.string().optional().describe("Context to show"), + }, + toConfig: (args) => ({ + question: args.question, + context: args.context, + }), + }); + + interface EmojiConfig { + session_id: string; + question: string; + context?: string; + emojis?: string[]; + } + + const emoji_react = createTool({ + type: "emoji_react", + description: "Ask user to react with an emoji.", + args: { + question: tool.schema.string().describe("Question to display"), + context: tool.schema.string().optional().describe("Context to show"), + emojis: tool.schema.array(tool.schema.string()).optional().describe("Available emoji options"), + }, + toConfig: (args) => ({ + question: args.question, + context: args.context, + emojis: args.emojis, + }), + }); + + interface SliderConfig { + session_id: string; + question: string; + min: number; + max: number; + step?: number; + defaultValue?: number; + context?: string; + labels?: { min?: string; max?: string; mid?: string }; + } + + const slider = createTool({ + type: "slider", + description: `Ask user to select a value on a numeric slider. +Response format: { value: number }`, + args: { + question: tool.schema.string().describe("Question to display"), + min: tool.schema.number().describe("Minimum value"), + max: tool.schema.number().describe("Maximum value"), + step: tool.schema.number().optional().describe("Step size (default: 1)"), + defaultValue: tool.schema.number().optional().describe("Default value"), + context: tool.schema.string().optional().describe("Instructions/context"), + labels: tool.schema + .object({ + min: tool.schema.string().optional().describe("Label for minimum value"), + max: tool.schema.string().optional().describe("Label for maximum value"), + mid: tool.schema.string().optional().describe("Label for middle value"), + }) + .optional() + .describe("Optional labels for the slider"), + }, + validate: (args) => { + if (args.min >= args.max) return `min (${args.min}) must be less than max (${args.max})`; + return null; + }, + toConfig: (args) => ({ + question: args.question, + min: args.min, + max: args.max, + step: args.step, + defaultValue: args.defaultValue, + context: args.context, + labels: args.labels, + }), + }); + + return { thumbs, emoji_react, slider }; +} diff --git a/src/tools/octto/responses.ts b/src/tools/octto/responses.ts new file mode 100644 index 0000000..ffea057 --- /dev/null +++ b/src/tools/octto/responses.ts @@ -0,0 +1,135 @@ +// src/tools/octto/responses.ts +import { tool } from "@opencode-ai/plugin/tool"; + +import { type SessionStore, STATUSES } from "../../octto/session"; + +import type { OcttoTools } from "./types"; + +export function createResponseTools(sessions: SessionStore): OcttoTools { + const get_answer = tool({ + description: `Get the answer to a SPECIFIC question. +By default returns immediately with current status. +Set block=true to wait for user response (with optional timeout). +NOTE: Prefer get_next_answer for better flow - it returns whichever question user answers first.`, + args: { + question_id: tool.schema.string().describe("Question ID from a question tool"), + block: tool.schema.boolean().optional().describe("Wait for response (default: false)"), + timeout: tool.schema + .number() + .optional() + .describe("Max milliseconds to wait if blocking (default: 300000 = 5 min)"), + }, + execute: async (args) => { + const result = await sessions.getAnswer({ + question_id: args.question_id, + block: args.block, + timeout: args.timeout, + }); + + if (result.completed) { + return `## Answer Received + +**Status:** ${result.status} + +**Response:** +\`\`\`json +${JSON.stringify(result.response, null, 2)} +\`\`\``; + } + + return `## Waiting for Answer + +**Status:** ${result.status} +**Reason:** ${result.reason} + +${result.status === STATUSES.PENDING ? "User has not answered yet. Call again with block=true to wait." : ""}`; + }, + }); + + const get_next_answer = tool({ + description: `Wait for ANY question to be answered. Returns whichever question the user answers first. +This is the PREFERRED way to get answers - lets user answer in any order. +Push multiple questions, then call this repeatedly to get answers as they come.`, + args: { + session_id: tool.schema.string().describe("Session ID from start_session"), + block: tool.schema.boolean().optional().describe("Wait for response (default: false)"), + timeout: tool.schema + .number() + .optional() + .describe("Max milliseconds to wait if blocking (default: 300000 = 5 min)"), + }, + execute: async (args) => { + const result = await sessions.getNextAnswer({ + session_id: args.session_id, + block: args.block, + timeout: args.timeout, + }); + + if (result.completed) { + return `## Answer Received + +**Question ID:** ${result.question_id} +**Question Type:** ${result.question_type} +**Status:** ${result.status} + +**Response:** +\`\`\`json +${JSON.stringify(result.response, null, 2)} +\`\`\``; + } + + if (result.status === STATUSES.NONE_PENDING) { + return `## No Pending Questions + +All questions have been answered or there are no questions in the queue. +Push more questions or end the session.`; + } + + return `## Waiting for Answer + +**Status:** ${result.status} +${result.reason === STATUSES.TIMEOUT ? "Timed out waiting for response." : "No answer yet."}`; + }, + }); + + const list_questions = tool({ + description: `List all questions and their status for a session.`, + args: { + session_id: tool.schema.string().optional().describe("Session ID (omit for all sessions)"), + }, + execute: async (args) => { + const result = sessions.listQuestions(args.session_id); + + if (result.questions.length === 0) { + return "No questions found."; + } + + let output = "## Questions\n\n"; + output += "| ID | Type | Status | Created | Answered |\n"; + output += "|----|------|--------|---------|----------|\n"; + + for (const q of result.questions) { + output += `| ${q.id} | ${q.type} | ${q.status} | ${q.createdAt} | ${q.answeredAt || "-"} |\n`; + } + + return output; + }, + }); + + const cancel_question = tool({ + description: `Cancel a pending question. +The question will be removed from the user's queue.`, + args: { + question_id: tool.schema.string().describe("Question ID to cancel"), + }, + execute: async (args) => { + const result = sessions.cancelQuestion(args.question_id); + if (result.ok) { + return `Question ${args.question_id} cancelled.`; + } + return `Could not cancel question ${args.question_id}. It may already be answered or not exist.`; + }, + }); + + return { get_answer, get_next_answer, list_questions, cancel_question }; +} diff --git a/src/tools/octto/session.ts b/src/tools/octto/session.ts new file mode 100644 index 0000000..c34a517 --- /dev/null +++ b/src/tools/octto/session.ts @@ -0,0 +1,114 @@ +// src/tools/octto/session.ts +import { tool } from "@opencode-ai/plugin/tool"; + +import type { SessionStore } from "../../octto/session"; +import type { OcttoSessionTracker, OcttoTools } from "./types"; + +export function createSessionTools(sessions: SessionStore, tracker?: OcttoSessionTracker): OcttoTools { + const start_session = tool({ + description: `Start an interactive octto session with initial questions. +Opens a browser window with questions already displayed - no waiting. +REQUIRED: You MUST provide at least 1 question. Will fail without questions.`, + args: { + title: tool.schema.string().optional().describe("Session title (shown in browser)"), + questions: tool.schema + .array( + tool.schema.object({ + type: tool.schema + .enum([ + "pick_one", + "pick_many", + "confirm", + "ask_text", + "ask_image", + "ask_file", + "ask_code", + "show_diff", + "show_plan", + "show_options", + "review_section", + "thumbs", + "slider", + "rank", + "rate", + "emoji_react", + ]) + .describe("Question type"), + + config: tool.schema + .looseObject({ + question: tool.schema.string().optional(), + context: tool.schema.string().optional(), + }) + .describe("Question config (varies by type)"), + }), + ) + .describe("REQUIRED: Initial questions to display when browser opens. Must have at least 1."), + }, + execute: async (args, context) => { + // ENFORCE: questions are required + if (!args.questions || args.questions.length === 0) { + return `## ERROR: questions parameter is REQUIRED + +start_session MUST include questions. Browser should open with questions ready. + +Example: +\`\`\` +start_session( + title="Design Session", + questions=[ + {type: "pick_one", config: {question: "What language?", options: [{id: "go", label: "Go"}]}}, + {type: "ask_text", config: {question: "Any constraints?"}} + ] +) +\`\`\` + +Please call start_session again WITH your prepared questions.`; + } + + try { + const result = await sessions.startSession({ title: args.title, questions: args.questions }); + tracker?.onCreated?.(context.sessionID, result.session_id); + + let output = `## Session Started + +| Field | Value | +|-------|-------| +| Session ID | ${result.session_id} | +| URL | ${result.url} | +`; + + if (result.question_ids && result.question_ids.length > 0) { + output += `| Questions | ${result.question_ids.length} loaded |\n\n`; + output += `**Question IDs:** ${result.question_ids.join(", ")}\n\n`; + output += `Browser opened with ${result.question_ids.length} questions ready.\n`; + output += `Use get_next_answer(session_id, block=true) to get answers as user responds.`; + } else { + output += `\nBrowser opened. Use question tools to push questions.`; + } + + return output; + } catch (error) { + return `Failed to start session: ${error instanceof Error ? error.message : String(error)}`; + } + }, + }); + + const end_session = tool({ + description: `End an interactive octto session. +Closes the browser window and cleans up resources.`, + args: { + session_id: tool.schema.string().describe("Session ID to end"), + }, + execute: async (args, context) => { + const result = await sessions.endSession(args.session_id); + if (result.ok) { + tracker?.onEnded?.(context.sessionID, args.session_id); + return `Session ${args.session_id} ended successfully.`; + } + return `Failed to end session ${args.session_id}. It may not exist.`; + }, + }); + + return { start_session, end_session }; +} diff --git a/src/tools/octto/types.ts b/src/tools/octto/types.ts new file mode 100644 index 0000000..5d5c252 --- /dev/null +++ b/src/tools/octto/types.ts @@ -0,0 +1,21 @@ +// src/tools/octto/types.ts + +import type { ToolContext } from "@opencode-ai/plugin/tool"; +import type { createOpencodeClient } from "@opencode-ai/sdk"; + +// Using `any` to avoid exposing zod types in declaration files. +// The actual tools are typesafe via zod schemas. +export interface OcttoTool { + description: string; + args: any; + execute: (args: any, context: ToolContext) => Promise; +} + +export type OcttoTools = Record; + +export type OpencodeClient = ReturnType; + +export interface OcttoSessionTracker { + onCreated?: (parentSessionId: string, octtoSessionId: string) => void; + onEnded?: (parentSessionId: string, octtoSessionId: string) => void; +} diff --git a/src/tools/octto/utils.ts b/src/tools/octto/utils.ts new file mode 100644 index 0000000..fb181a6 --- /dev/null +++ b/src/tools/octto/utils.ts @@ -0,0 +1,4 @@ +// src/tools/octto/utils.ts +// Re-export ID generation utilities from octto/session for convenience + +export { generateBrainstormId, generateQuestionId, generateSessionId } from "../../octto/session/utils"; diff --git a/src/tools/pty/manager.ts b/src/tools/pty/manager.ts index ec74ac4..ef82f2c 100644 --- a/src/tools/pty/manager.ts +++ b/src/tools/pty/manager.ts @@ -20,13 +20,19 @@ export class PTYManager { const env = { ...process.env, ...opts.env } as Record; const title = opts.title ?? (`${opts.command} ${args.join(" ")}`.trim() || `Terminal ${id.slice(-4)}`); - const ptyProcess: IPty = spawn(opts.command, args, { - name: "xterm-256color", - cols: 120, - rows: 40, - cwd: workdir, - env, - }); + let ptyProcess: IPty; + try { + ptyProcess = spawn(opts.command, args, { + name: "xterm-256color", + cols: 120, + rows: 40, + cwd: workdir, + env, + }); + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + throw new Error(`Failed to spawn PTY for command "${opts.command}": ${errorMsg}`); + } const buffer = new RingBuffer(); const session: PTYSession = { diff --git a/src/tools/spawn-agent.ts b/src/tools/spawn-agent.ts index 68bf2d9..adada0f 100644 --- a/src/tools/spawn-agent.ts +++ b/src/tools/spawn-agent.ts @@ -63,9 +63,7 @@ For parallel execution, call spawn_agent multiple times in ONE message.`, // Find the last assistant message const messages = messagesResp.data || []; - const lastAssistant = messages - .filter((m) => m.info?.role === "assistant") - .pop(); + const lastAssistant = messages.filter((m) => m.info?.role === "assistant").pop(); const result = lastAssistant?.parts diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..b87b823 --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,123 @@ +// src/utils/config.ts +// Centralized configuration constants +// Organized by domain for easy discovery and maintenance + +/** + * Application configuration constants. + * All values are compile-time constants - no runtime configuration. + */ +export const config = { + /** + * Auto-compaction settings + */ + compaction: { + /** Trigger compaction when context usage exceeds this ratio */ + threshold: 0.5, + /** Minimum time between compaction attempts (ms) */ + cooldownMs: 30_000, + /** Maximum time to wait for compaction to complete (ms) */ + timeoutMs: 120_000, + }, + + /** + * Context window monitoring settings + */ + contextWindow: { + /** Show warning when context usage exceeds this ratio */ + warningThreshold: 0.7, + /** Show critical warning when context usage exceeds this ratio */ + criticalThreshold: 0.85, + /** Minimum time between warning toasts (ms) */ + warningCooldownMs: 120_000, + }, + + /** + * Token estimation settings + */ + tokens: { + /** Characters per token for estimation */ + charsPerToken: 4, + /** Default context window limit (tokens) */ + defaultContextLimit: 200_000, + /** Default max output tokens */ + defaultMaxOutputTokens: 50_000, + /** Safety margin for output (ratio of remaining context) */ + safetyMargin: 0.5, + /** Lines to preserve when truncating output */ + preserveHeaderLines: 3, + }, + + /** + * File path patterns and directories + */ + paths: { + /** Directory for ledger files */ + ledgerDir: "thoughts/ledgers", + /** Prefix for ledger filenames */ + ledgerPrefix: "CONTINUITY_", + /** Context files to inject from project root */ + rootContextFiles: ["ARCHITECTURE.md", "CODE_STYLE.md", "README.md"] as readonly string[], + /** Context files to collect when walking up directories */ + dirContextFiles: ["README.md"] as readonly string[], + /** Pattern to match plan files */ + planPattern: /thoughts\/shared\/plans\/.*\.md$/, + /** Pattern to match ledger files */ + ledgerPattern: /thoughts\/ledgers\/CONTINUITY_.*\.md$/, + }, + + /** + * Timeout settings + */ + timeouts: { + /** BTCA command timeout (ms) */ + btcaMs: 120_000, + /** Success toast duration (ms) */ + toastSuccessMs: 3000, + /** Warning toast duration (ms) */ + toastWarningMs: 4000, + /** Error toast duration (ms) */ + toastErrorMs: 5000, + }, + + /** + * Various limits + */ + limits: { + /** File size threshold for triggering extraction (bytes) */ + largeFileBytes: 100 * 1024, + /** Max lines to return without extraction */ + maxLinesNoExtract: 200, + /** Max lines in PTY buffer */ + ptyMaxBufferLines: 50_000, + /** Default read limit for PTY */ + ptyDefaultReadLimit: 500, + /** Max line length for PTY output */ + ptyMaxLineLength: 2000, + /** Max matches to show from ast-grep */ + astGrepMaxMatches: 100, + /** Context cache TTL (ms) */ + contextCacheTtlMs: 30_000, + /** Max entries in context cache */ + contextCacheMaxSize: 100, + }, + + /** + * Octto (browser-based brainstorming) settings + */ + octto: { + /** Answer timeout (ms) - 5 minutes */ + answerTimeoutMs: 5 * 60 * 1000, + /** Review timeout (ms) - 10 minutes */ + reviewTimeoutMs: 10 * 60 * 1000, + /** Max iterations in brainstorm loop */ + maxIterations: 50, + /** Max follow-up questions per branch */ + maxQuestions: 15, + /** State directory for brainstorm sessions */ + stateDir: "thoughts/brainstorms", + /** Bind address for brainstorm server */ + bindAddress: "127.0.0.1", + /** Allow overriding bind address for remote access */ + allowRemoteBind: false, + }, +} as const; diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..49c042e --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,57 @@ +// src/utils/errors.ts +// Unified error handling utilities +// Used by tools and hooks for consistent error formatting and logging + +/** + * Safely extract error message from unknown error type. + * Handles Error instances, strings, and other types. + */ +export function extractErrorMessage(e: unknown): string { + if (e instanceof Error) { + return e.message; + } + return String(e); +} + +/** + * Format error message for tool responses (LLM-facing). + * @param message - The error message + * @param context - Optional context about what operation failed + */ +export function formatToolError(message: string, context?: string): string { + if (context && context.trim()) { + return `Error (${context}): ${message}`; + } + return `Error: ${message}`; +} + +/** + * Execute a function and log any errors without throwing. + * Use for non-critical operations that shouldn't fail the main flow. + * @param module - Module name for log prefix + * @param fn - Function to execute + * @returns Result or undefined if error occurred + */ +export function catchAndLog(module: string, fn: () => T): T | undefined { + try { + return fn(); + } catch (e) { + console.error(`[${module}] ${extractErrorMessage(e)}`); + return undefined; + } +} + +/** + * Async version of catchAndLog. + * @param module - Module name for log prefix + * @param fn - Async function to execute + * @returns Result or undefined if error occurred + */ +export async function catchAndLogAsync(module: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (e) { + console.error(`[${module}] ${extractErrorMessage(e)}`); + return undefined; + } +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..7907f47 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,50 @@ +// src/utils/logger.ts +// Standardized logging with module prefixes +// Used across hooks and tools for consistent log formatting + +/** + * Logger with standardized [module] prefix format. + * + * Usage: + * log.info("my-hook", "Processing file"); + * log.error("my-tool", "Failed to read", error); + * log.debug("my-module", "Verbose info"); // Only when DEBUG env set + */ +export const log = { + /** + * Debug level - only outputs when DEBUG environment variable is set. + */ + debug(module: string, message: string): void { + if (process.env.DEBUG) { + console.log(`[${module}] ${message}`); + } + }, + + /** + * Info level - general informational messages. + */ + info(module: string, message: string): void { + console.log(`[${module}] ${message}`); + }, + + /** + * Warning level - non-fatal issues. + */ + warn(module: string, message: string): void { + console.warn(`[${module}] ${message}`); + }, + + /** + * Error level - errors that were caught and handled. + * @param module - Module name for prefix + * @param message - Error description + * @param error - Optional error object for additional context + */ + error(module: string, message: string, error?: unknown): void { + if (error !== undefined) { + console.error(`[${module}] ${message}`, error); + } else { + console.error(`[${module}] ${message}`); + } + }, +}; diff --git a/tests/config-loader-integration.test.ts b/tests/config-loader-integration.test.ts index ebd9512..f6b2b65 100644 --- a/tests/config-loader-integration.test.ts +++ b/tests/config-loader-integration.test.ts @@ -1,7 +1,8 @@ // tests/config-loader-integration.test.ts -import { describe, it, expect } from "bun:test"; -import { loadMicodeConfig, mergeAgentConfigs } from "../src/config-loader"; +import { describe, expect, it } from "bun:test"; + import { agents } from "../src/agents"; +import { mergeAgentConfigs } from "../src/config-loader"; describe("config-loader integration", () => { it("should have all agents defined in agents/index.ts", () => { @@ -35,7 +36,9 @@ describe("config-loader integration", () => { }, }; - const merged = mergeAgentConfigs(agents, userConfig); + const availableModels = new Set(["openai/gpt-4o", "openai/gpt-5.2-codex"]); + + const merged = mergeAgentConfigs(agents, userConfig, availableModels); // Check project-initializer was merged correctly expect(merged["project-initializer"]).toBeDefined(); @@ -44,7 +47,7 @@ describe("config-loader integration", () => { expect(merged["project-initializer"].prompt).toBeDefined(); // Check other agents still have defaults - expect(merged["commander"].model).toBe("openai/gpt-5.2-codex"); + expect(merged.commander.model).toBe("openai/gpt-5.2-codex"); }); it("should preserve all agent properties when merging", () => { @@ -54,7 +57,9 @@ describe("config-loader integration", () => { }, }; - const merged = mergeAgentConfigs(agents, userConfig); + const availableModels = new Set(["openai/gpt-4o", "openai/gpt-5.2-codex"]); + + const merged = mergeAgentConfigs(agents, userConfig, availableModels); const pi = merged["project-initializer"]; expect(pi.model).toBe("openai/gpt-4o"); diff --git a/tests/config-loader.test.ts b/tests/config-loader.test.ts index 6168046..ab32e2f 100644 --- a/tests/config-loader.test.ts +++ b/tests/config-loader.test.ts @@ -1,8 +1,9 @@ // tests/config-loader.test.ts -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { loadMicodeConfig, mergeAgentConfigs } from "../src/config-loader"; describe("config-loader", () => { @@ -149,8 +150,9 @@ describe("mergeAgentConfigs", () => { commander: { model: "openai/gpt-4o", temperature: 0.5 }, }, }; + const availableModels = new Set(["openai/gpt-4o", "anthropic/claude-opus-4-5"]); - const merged = mergeAgentConfigs(pluginAgents, userConfig); + const merged = mergeAgentConfigs(pluginAgents, userConfig, availableModels); expect(merged.commander.model).toBe("openai/gpt-4o"); expect(merged.commander.temperature).toBe(0.5); @@ -176,8 +178,9 @@ describe("mergeAgentConfigs", () => { commander: { model: "openai/gpt-4o" }, }, }; + const availableModels = new Set(["openai/gpt-4o", "anthropic/claude-opus-4-5"]); - const merged = mergeAgentConfigs(pluginAgents, userConfig); + const merged = mergeAgentConfigs(pluginAgents, userConfig, availableModels); expect(merged.commander.model).toBe("openai/gpt-4o"); expect(merged.brainstormer.model).toBe("anthropic/claude-opus-4-5"); diff --git a/tests/hooks/context-injector.test.ts b/tests/hooks/context-injector.test.ts new file mode 100644 index 0000000..6e4f737 --- /dev/null +++ b/tests/hooks/context-injector.test.ts @@ -0,0 +1,76 @@ +// tests/hooks/context-injector.test.ts +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Mock PluginInput +function createMockCtx(directory: string) { + return { + directory, + client: { + session: {}, + tui: {}, + }, + }; +} + +describe("context-injector", () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "context-injector-test-")); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + describe("tool.execute.after hook", () => { + it("should extract filePath from tool args using camelCase", async () => { + // Create a README.md in a subdirectory + const subDir = join(testDir, "src", "components"); + mkdirSync(subDir, { recursive: true }); + writeFileSync(join(subDir, "README.md"), "# Components\n\nComponent documentation."); + + // Create a file to "read" + const targetFile = join(subDir, "Button.tsx"); + writeFileSync(targetFile, "export const Button = () =>