From 6dc73c47f6a4b17c13fc71bcbd180ec57fbca59a Mon Sep 17 00:00:00 2001 From: DevOps Bot Date: Mon, 27 Apr 2026 11:04:18 +0000 Subject: [PATCH 01/12] feat: apply local changes on top of main --- .github/workflows/codeql.yml | 86 +++--- .gitig | 1 + apps/web/app/api/github/branches/route.ts | 13 + apps/web/git | 0 apps/web/lib/github/api.ts | 7 +- apps/web/lib/sandbox/lifecycle.ts | 3 +- bun.lock | 22 +- packages/agent/kilo/client.ts | 159 ++++++++++++ packages/agent/open-agent.ts | 4 + packages/agent/tools/delegate.ts | 176 +++++++++++++ packages/agent/tools/index.ts | 6 + packages/agent/tools/kilo.ts | 59 +++++ packages/sandbox/vercel/sandbox.ts | 11 +- renovate.json | 7 +- todo.md | 303 ++++++++++++++++++++++ 15 files changed, 797 insertions(+), 60 deletions(-) create mode 100644 .gitig create mode 100644 apps/web/git create mode 100644 packages/agent/kilo/client.ts create mode 100644 packages/agent/tools/delegate.ts create mode 100644 packages/agent/tools/kilo.ts create mode 100644 todo.md diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 201f19f0..60ad9244 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,11 +13,11 @@ name: "CodeQL Advanced" on: push: - branches: [ "b/devcontainer-setup" ] + branches: ["b/devcontainer-setup"] pull_request: - branches: [ "b/devcontainer-setup" ] + branches: ["b/devcontainer-setup"] schedule: - - cron: '16 2 * * 6' + - cron: "16 2 * * 6" jobs: analyze: @@ -43,8 +43,8 @@ jobs: fail-fast: false matrix: include: - - language: javascript-typescript - build-mode: none + - language: javascript-typescript + build-mode: none # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' # Use `c-cpp` to analyze code written in C, C++ or both # Use 'java-kotlin' to analyze code written in Java, Kotlin or both @@ -54,46 +54,46 @@ jobs: # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - # Add any setup steps before running the `github/codeql-action/init` action. - # This includes steps like installing compilers or runtimes (`actions/setup-node` - # or others). This is typically only required for manual builds. - # - name: Setup runtime (example) - # uses: actions/setup-example@v1 + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - name: Run manual build steps - if: matrix.build-mode == 'manual' - shell: bash - run: | - echo 'If you are using a "manual" build mode for one or more of the' \ - 'languages you are analyzing, replace this with the commands to build' \ - 'your code, for example:' - echo ' make bootstrap' - echo ' make release' - exit 1 + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - name: Run manual build steps + if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.gitig b/.gitig new file mode 100644 index 00000000..b9f40a7b --- /dev/null +++ b/.gitig @@ -0,0 +1 @@ +ore diff --git a/apps/web/app/api/github/branches/route.ts b/apps/web/app/api/github/branches/route.ts index 9e6e21b8..db57da91 100644 --- a/apps/web/app/api/github/branches/route.ts +++ b/apps/web/app/api/github/branches/route.ts @@ -17,6 +17,12 @@ function normalizeGitHubLimit(limit: number | undefined): number | undefined { : undefined; } +function isValidGitHubPathSegment(value: string): boolean { + // Conservative allow-list for owner/repo used in URL path segments. + // Allows alphanumerics plus GitHub-common separators. + return /^[A-Za-z0-9._-]{1,100}$/.test(value); +} + function parseRepoInfo(value: unknown): RepoInfo | null { if (!value || typeof value !== "object") { return null; @@ -286,6 +292,13 @@ export async function GET(request: NextRequest) { ); } + if (!isValidGitHubPathSegment(owner) || !isValidGitHubPathSegment(repo)) { + return NextResponse.json( + { error: "Invalid owner or repo parameter" }, + { status: 400 }, + ); + } + const token = await getUserGitHubToken(session.user.id); try { diff --git a/apps/web/git b/apps/web/git new file mode 100644 index 00000000..e69de29b diff --git a/apps/web/lib/github/api.ts b/apps/web/lib/github/api.ts index 2b4317d3..00627d3e 100644 --- a/apps/web/lib/github/api.ts +++ b/apps/web/lib/github/api.ts @@ -71,9 +71,12 @@ export async function fetchGitHubBranches( repo: string, limit?: number, ) { + const encodedOwner = encodeURIComponent(owner); + const encodedRepo = encodeURIComponent(repo); + // Fetch repo info for default branch const repoInfo = await fetchGitHubAPI( - `/repos/${owner}/${repo}`, + `/repos/${encodedOwner}/${encodedRepo}`, token, ); if (!repoInfo) return null; @@ -89,7 +92,7 @@ export async function fetchGitHubBranches( while (page <= maxPages) { const branches = await fetchGitHubAPI( - `/repos/${owner}/${repo}/branches?per_page=${perPage}&page=${page}`, + `/repos/${encodedOwner}/${encodedRepo}/branches?per_page=${perPage}&page=${page}`, token, ); diff --git a/apps/web/lib/sandbox/lifecycle.ts b/apps/web/lib/sandbox/lifecycle.ts index 234a4ee1..59fdcbea 100644 --- a/apps/web/lib/sandbox/lifecycle.ts +++ b/apps/web/lib/sandbox/lifecycle.ts @@ -259,7 +259,8 @@ export async function evaluateSandboxLifecycle( lifecycleError: message, }); console.error( - `[Lifecycle] Failed to evaluate sandbox lifecycle for session ${sessionId}:`, + "[Lifecycle] Failed to evaluate sandbox lifecycle for session %s:", + sessionId, error, ); return { action: "failed", reason: message }; diff --git a/bun.lock b/bun.lock index 115f9720..c3cd439b 100644 --- a/bun.lock +++ b/bun.lock @@ -52,7 +52,7 @@ "jose": "^6.1.3", "lucide-react": "^0.562.0", "nanoid": "^5.1.6", - "next": "16.2.1", + "next": "16.2.3", "postgres": "^3.4.8", "react": "19.2.3", "react-day-picker": "^9.14.0", @@ -445,23 +445,23 @@ "@nestjs/core": ["@nestjs/core@11.1.19", "", { "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@nestjs/websockets": "^11.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/platform-express", "@nestjs/websockets"] }, "sha512-6nJkWa2efrYi+XlU686J9y5L7OvxpLVjT0T/sxRKE7Jvpffiihelup4WSvLvRhdHDjj/5SuoWEwqReXAaaeHmw=="], - "@next/env": ["@next/env@16.2.1", "", {}, "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg=="], + "@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="], "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], @@ -1767,7 +1767,7 @@ "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], - "next": ["next@16.2.1", "", { "dependencies": { "@next/env": "16.2.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.1", "@next/swc-darwin-x64": "16.2.1", "@next/swc-linux-arm64-gnu": "16.2.1", "@next/swc-linux-arm64-musl": "16.2.1", "@next/swc-linux-x64-gnu": "16.2.1", "@next/swc-linux-x64-musl": "16.2.1", "@next/swc-win32-arm64-msvc": "16.2.1", "@next/swc-win32-x64-msvc": "16.2.1", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q=="], + "next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="], "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="], diff --git a/packages/agent/kilo/client.ts b/packages/agent/kilo/client.ts new file mode 100644 index 00000000..e46a60c0 --- /dev/null +++ b/packages/agent/kilo/client.ts @@ -0,0 +1,159 @@ +export type ChatCompletionRequest = { + model: string; + messages: Message[]; + stream?: boolean; + max_tokens?: number; + temperature?: number; + top_p?: number; + stop?: string | string[]; + frequency_penalty?: number; + presence_penalty?: number; + tools?: Tool[]; + tool_choice?: ToolChoice; + response_format?: ResponseFormat; + user?: string; + seed?: number; +}; + +export type Message = + | { role: "system"; content: string } + | { role: "user"; content: string | ContentPart[] } + | { role: "assistant"; content: string | null; tool_calls?: ToolCall[] } + | { role: "tool"; content: string; tool_call_id: string }; + +export type ContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string; detail?: string } }; + +export type Tool = { + type: "function"; + function: { + name: string; + description?: string; + parameters: object; + }; +}; + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export type ResponseFormat = { + type: "text" | "json_object"; +}; + +export type ToolCall = { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +}; + +export type ChatCompletionResponse = { + id: string; + object: "chat.completion"; + created: number; + model: string; + choices: Array<{ + index: number; + message: { + role: "assistant"; + content: string | null; + tool_calls?: ToolCall[]; + }; + finish_reason: "stop" | "length" | "tool_calls" | "content_filter"; + }>; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +}; + +export type FIMRequest = { + model: string; + prompt: string; + suffix?: string; + max_tokens?: number; + temperature?: number; + stop?: string[]; + stream?: boolean; +}; + +export type Model = { + id: string; + object: "model"; + created: number; + owned_by: string; + name: string; + context_length: number; + pricing: { + prompt: string; + completion: string; + }; +}; + +export class KiloClient { + private apiKey: string; + private baseUrl = "https://api.kilo.ai"; + + constructor(apiKey?: string) { + this.apiKey = apiKey || process.env.KILO_API_KEY || ""; + if (!this.apiKey) { + throw new Error("Kilo API key is required. Set KILO_API_KEY environment variable."); + } + } + + private async request(path: string, options?: RequestInit): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + ...options, + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + ...options?.headers, + }, + }); + + if (!response.ok) { + let errorData: any; + try { + errorData = await response.json(); + } catch (e) { + throw new Error(`Kilo API Error ${response.status}: ${response.statusText}`); + } + throw new Error( + `Kilo API Error ${response.status}: ${ + errorData?.error?.message || response.statusText + }` + ); + } + + return response.json() as Promise; + } + + async chatCompletions(body: ChatCompletionRequest): Promise { + return this.request("/api/gateway/chat/completions", { + method: "POST", + body: JSON.stringify(body), + }); + } + + async fimCompletions(body: FIMRequest): Promise { + return this.request("/api/fim/completions", { + method: "POST", + body: JSON.stringify(body), + }); + } + + async listModels(): Promise<{ data: Model[] }> { + return this.request<{ data: Model[] }>("/api/gateway/models"); + } + + async listProviders(): Promise { + return this.request("/api/gateway/providers"); + } +} diff --git a/packages/agent/open-agent.ts b/packages/agent/open-agent.ts index ceb7d151..56a961fb 100644 --- a/packages/agent/open-agent.ts +++ b/packages/agent/open-agent.ts @@ -19,9 +19,11 @@ import { readFileTool, skillTool, taskTool, + delegateTaskTool, todoWriteTool, webFetchTool, writeFileTool, + kiloTool, } from "./tools"; export interface AgentModelSelection { @@ -71,9 +73,11 @@ const tools = { glob: globTool(), bash: bashTool(), task: taskTool, + delegate_task: delegateTaskTool, ask_user_question: askUserQuestionTool, skill: skillTool, web_fetch: webFetchTool, + kilo: kiloTool, } satisfies ToolSet; export const openAgent = new ToolLoopAgent({ diff --git a/packages/agent/tools/delegate.ts b/packages/agent/tools/delegate.ts new file mode 100644 index 00000000..bdd8abf4 --- /dev/null +++ b/packages/agent/tools/delegate.ts @@ -0,0 +1,176 @@ +import { + type LanguageModelUsage, + type ModelMessage, + tool, + type UIToolInvocation, +} from "ai"; +import { z } from "zod"; +import { + buildSubagentSummaryLines, + SUBAGENT_REGISTRY, + SUBAGENT_TYPES, +} from "../subagents/registry"; +import { sumLanguageModelUsage } from "../usage"; +import { getSandboxContext, getSubagentModel } from "./utils"; + +const subagentTypeSchema = z.enum(SUBAGENT_TYPES); +const subagentSummaryLines = buildSubagentSummaryLines(); + +const delegateTaskSchema = z.object({ + subagentType: subagentTypeSchema.describe( + `Subagent to launch. Available options:\n${subagentSummaryLines}`, + ), + task: z.string().describe("Short description of the task (displayed to user)"), + instructions: z.string().describe( + `Detailed instructions for the subagent. Include: +- Goal and deliverables +- Step-by-step procedure +- Constraints and patterns to follow +- How to verify the work`, + ), +}); + +export const delegateTaskOutputSchema = z.object({ + results: z.array( + z.object({ + task: z.string(), + modelId: z.string().optional(), + toolCallCount: z.number(), + final: z.custom().optional(), + usage: z.custom().optional(), + }) + ), + totalToolCallCount: z.number().int().nonnegative().optional(), + startedAt: z.number().int().nonnegative().optional(), +}); + +export type DelegateTaskToolOutput = z.infer; + +export const delegateTaskTool = tool({ + needsApproval: false, + description: `Spawn multiple subagents to work on tasks in isolated contexts simultaneously. +Each subagent gets its own workspace context. Only the final summary is returned. + +AVAILABLE SUBAGENTS: +${subagentSummaryLines} + +WHEN TO USE delegate_task: +- Parallel independent workstreams (e.g. researching different topics simultaneously) +- Reasoning-heavy subtasks that can be separated and executed concurrently + +WHEN NOT TO USE (use existing tools instead): +- Single sequential task -> just use the 'task' tool directly +- Tasks needing your direct interaction or memory -> do it yourself +- Simple, single-file or single-change edits -> do it yourself + +HOW TO USE: +- Provide an array of 'tasks'. All run concurrently and results are returned together. +- Choose the appropriate subagentType for each task. +- Be explicit and concrete - subagents cannot ask clarifying questions.`, + inputSchema: z.object({ + tasks: z.array(delegateTaskSchema).min(1).max(5).describe( + "Tasks to run in parallel. Each gets its own subagent." + ), + }), + outputSchema: delegateTaskOutputSchema, + execute: async function* ( + { tasks }, + { experimental_context, abortSignal }, + ) { + const sandboxContext = getSandboxContext(experimental_context, "delegateTask"); + const model = getSubagentModel(experimental_context, "delegateTask"); + const subagentModelId = typeof model === "string" ? model : model.modelId; + const startedAt = Date.now(); + + // Initialize state + const taskStates = tasks.map((t) => ({ + task: t.task, + modelId: subagentModelId, + toolCallCount: 0, + usage: undefined as LanguageModelUsage | undefined, + final: undefined as ModelMessage[] | undefined, + })); + + yield { + results: taskStates, + totalToolCallCount: 0, + startedAt, + }; + + const runTask = async (taskInput: typeof tasks[0], index: number) => { + const state = taskStates[index]; + if (!state) return; + const subagent = SUBAGENT_REGISTRY[taskInput.subagentType].agent; + const result = await subagent.stream({ + prompt: "Complete this task and provide a summary of what you accomplished.", + options: { + task: taskInput.task, + instructions: taskInput.instructions, + sandbox: sandboxContext.sandbox, + model, + }, + abortSignal, + }); + + for await (const part of result.fullStream) { + if (part.type === "tool-call") { + state.toolCallCount += 1; + } else if (part.type === "finish-step") { + state.usage = sumLanguageModelUsage(state.usage, part.usage); + } + } + + const response = await result.response; + const finalUsage = state.usage ?? (await result.usage); + + state.final = response.messages; + state.usage = finalUsage; + + return state; + }; + + // Run all tasks in parallel + const promises = tasks.map((t, i) => runTask(t, i)); + await Promise.all(promises); + + const totalToolCallCount = taskStates.reduce((acc, t) => acc + t.toolCallCount, 0); + + yield { + results: taskStates, + totalToolCallCount, + startedAt, + }; + }, + toModelOutput: ({ output: { results } }) => { + if (!results || results.length === 0) { + return { type: "text", value: "All tasks completed." }; + } + + const summaries = results.map((result, i) => { + const messages = result.final; + let contentValue = "Task completed without detailed summary."; + + if (messages) { + const lastAssistantMessage = messages.findLast((p) => p.role === "assistant"); + const content = lastAssistantMessage?.content; + + if (content) { + if (typeof content === "string") { + contentValue = content; + } else { + const lastTextPart = content.findLast((p) => p.type === "text"); + if (lastTextPart) { + contentValue = lastTextPart.text; + } + } + } + } + + return `=== Result for Task ${i + 1}: ${result.task} ===\n${contentValue}`; + }); + + return { type: "text", value: summaries.join("\n\n") }; + }, +}); + +export type DelegateTaskToolUIPart = UIToolInvocation; diff --git a/packages/agent/tools/index.ts b/packages/agent/tools/index.ts index bd81875f..5423d521 100644 --- a/packages/agent/tools/index.ts +++ b/packages/agent/tools/index.ts @@ -10,6 +10,11 @@ export { type TaskToolOutput, type TaskToolUIPart, } from "./task"; +export { + delegateTaskTool, + type DelegateTaskToolOutput, + type DelegateTaskToolUIPart, +} from "./delegate"; export { askUserQuestionTool, type AskUserQuestionToolUIPart, @@ -17,3 +22,4 @@ export { } from "./ask-user-question"; export { skillTool, type SkillToolInput } from "./skill"; export { webFetchTool } from "./fetch"; +export { kiloTool } from "./kilo"; diff --git a/packages/agent/tools/kilo.ts b/packages/agent/tools/kilo.ts new file mode 100644 index 00000000..5401ec7c --- /dev/null +++ b/packages/agent/tools/kilo.ts @@ -0,0 +1,59 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { KiloClient } from "../kilo/client"; + +const kiloInputSchema = z.object({ + action: z.enum(["list_models", "list_providers", "chat_completions", "fim_completions"]), + model: z.string().optional().describe("Model ID (e.g., 'anthropic/claude-sonnet-4.5'). Required for completions."), + messages: z.array(z.any()).optional().describe("Array of conversation messages. Required for chat_completions."), + prompt: z.string().optional().describe("Code before the cursor. Required for fim_completions."), + suffix: z.string().optional().describe("Code after the cursor. Optional for fim_completions."), + max_tokens: z.number().optional(), + temperature: z.number().optional(), +}); + +export const kiloTool = tool({ + description: `Interact with the Kilo AI Gateway. +Supports: +- list_models: Retrieve the list of available models. +- list_providers: Retrieve the list of available providers. +- chat_completions: Create a chat completion using any supported model. +- fim_completions: Fill-in-the-middle completions for code generation (Mistral models only).`, + inputSchema: kiloInputSchema, + execute: async (input) => { + try { + const client = new KiloClient(); + switch (input.action) { + case "list_models": + return await client.listModels(); + case "list_providers": + return await client.listProviders(); + case "chat_completions": + if (!input.model || !input.messages) { + return { error: "model and messages are required for chat_completions" }; + } + return await client.chatCompletions({ + model: input.model, + messages: input.messages, + max_tokens: input.max_tokens, + temperature: input.temperature, + }); + case "fim_completions": + if (!input.model || !input.prompt) { + return { error: "model and prompt are required for fim_completions" }; + } + return await client.fimCompletions({ + model: input.model, + prompt: input.prompt, + suffix: input.suffix, + max_tokens: input.max_tokens, + temperature: input.temperature, + }); + default: + return { error: "Unknown action" }; + } + } catch (error: any) { + return { error: error.message || String(error) }; + } + }, +}); diff --git a/packages/sandbox/vercel/sandbox.ts b/packages/sandbox/vercel/sandbox.ts index a3469286..c018ef0c 100644 --- a/packages/sandbox/vercel/sandbox.ts +++ b/packages/sandbox/vercel/sandbox.ts @@ -111,15 +111,22 @@ function buildAuthenticatedGitHubUrl( repoUrl: string, token: string, ): string | null { + // Match GitHub URL patterns to avoid REDOS vulnerability + // Matches: github.com/:owner/:repo or github.com/:owner/:repo.git const githubUrlMatch = repoUrl.match( - /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/, + /^.*?github\.com[/:]([^/]+?)\/([^/]+?)(?:\.git)?$/, ); - if (!githubUrlMatch) { return null; } const [, owner, repo] = githubUrlMatch; + + // Validate that owner and repo are non-empty + if (!owner || !repo) { + return null; + } + return `https://x-access-token:${token}@github.com/${owner}/${repo}.git`; } diff --git a/renovate.json b/renovate.json index cbc6661c..1ccd67a8 100644 --- a/renovate.json +++ b/renovate.json @@ -26,7 +26,12 @@ }, { "description": "Group React and React DOM — must stay in sync", - "matchPackageNames": ["react", "react-dom", "@types/react", "@types/react-dom"], + "matchPackageNames": [ + "react", + "react-dom", + "@types/react", + "@types/react-dom" + ], "groupName": "React", "automerge": false }, diff --git a/todo.md b/todo.md new file mode 100644 index 00000000..6ace1a2d --- /dev/null +++ b/todo.md @@ -0,0 +1,303 @@ +--- +name: todo +created: 2026-04-26T22:50:23Z +updated: 2026-04-26T22:50:23Z +author: billlzzz26 +project: Open-Agent (AI-Powered Code Generation Platform) +description: v0-like code generation agent for GitHub repositories with AI-driven development +--- + +# Open-Agent Development Roadmap + +> Open-Agent enables AI-driven development for GitHub repositories. Users can connect their repos, have conversations with AI about their code, and generate changes via an integrated sandbox environment. This roadmap tracks implementation progress across 14 development phases. + +## Phase 1: Environment Setup & Configuration (Core Infrastructure) + +**Status:** ~80% Complete | **Priority:** Critical | **Files:** `turbo.json`, `.env`, `apps/web/`, `lib/`, `packages/` + +- [x] Setup Monorepo structure with Turbo (`turbo.json` configured) +- [x] Initialize Next.js 16 web app (`apps/web/`) +- [x] Configure BetterAuth for authentication (`lib/auth/config.ts`, `lib/auth/client.ts`) +- [x] Setup PostgreSQL database schema (`lib/db/schema.ts`, migrations) +- [x] Install core dependencies (AI SDK 6, Radix UI, Drizzle ORM, etc.) +- [ ] **CRITICAL:** Add `BETTER_AUTH_SECRET` env var (required for auth) +- [ ] **CRITICAL:** Add `POSTGRES_URL` env var (required for database) +- [ ] Verify build outputs in `turbo.json` (currently warns about missing outputs) +- [ ] Update `.env.example` with all required environment variables +- [ ] Document setup instructions for new developers + +**Blocked By:** Environment variables not configured in Vercel project + +--- + +## Phase 2: GitHub Integration & Repository Management + +**Status:** ~90% Complete | **Priority:** Critical | **Files:** `api/github/*`, `lib/github/*` + +- [x] GitHub OAuth App setup and configuration +- [x] User GitHub accounts linking via BetterAuth +- [x] Repository listing from authenticated user/orgs (`api/github/orgs`, `api/github/installations/repos`) +- [x] Repository metadata retrieval (name, description, branches) +- [x] Branch listing and selection (`api/github/branches`) +- [x] Current user info retrieval (`api/github/user`) +- [x] Organization listing (`api/github/orgs`) +- [ ] GitHub App installation status tracking (`api/github/orgs/install-status`) +- [ ] Handle multiple GitHub accounts per user +- [ ] Graceful error handling for revoked app access +- [ ] Support for private repositories access verification +- [ ] Repository webhook setup for CI/CD integration + +**In Progress:** GitHub app installation flow improvements + +--- + +## Phase 3: AI-Powered Code Generation & Chat + +**Status:** ~85% Complete | **Priority:** Critical | **Files:** `api/chat/*`, `lib/chat/*`, `components/`, `hooks/` + +- [x] Chat interface with streaming responses (`api/chat/[chatId]/stream`) +- [x] Vercel AI SDK 6 integration with multiple models support +- [x] Tool/skill execution system for code generation +- [x] Chat message persistence to database +- [x] Stream recovery and error handling (`lib/chat/create-cancelable-readable-stream.ts`) +- [x] Auto-commit functionality (`lib/chat/auto-commit-direct.ts`) +- [x] Auto-PR generation (`lib/chat/auto-pr-direct.ts`) +- [ ] Context window optimization for large files (>10KB) +- [ ] Token usage tracking and display +- [ ] Chat history and context management improvements +- [ ] Implement prompt templates for common tasks +- [ ] Add conversation export (markdown, JSON) +- [ ] Fine-tune model selection per task type + +**Files to Review:** `lib/chat/`, `api/chat/`, chat streaming implementation + +--- + +## Phase 4: Workspace & Sandbox Environment + +**Status:** ~80% Complete | **Priority:** Critical | **Files:** `api/sandbox/*`, `lib/sandbox/*`, `packages/sandbox` + +- [x] Vercel Sandbox integration for code execution +- [x] Dev server startup and management (`api/sandbox/status`) +- [x] File system navigation and code editor (`api/sessions/[sessionId]/files`) +- [x] Live preview with hot reload +- [x] Sandbox snapshot and restore capability (`api/sandbox/snapshot`) +- [x] Sandbox reconnection logic (`api/sandbox/reconnect`) +- [ ] Error recovery when sandbox crashes +- [ ] Sandbox timeout handling and extension (`api/sandbox/extend`) +- [ ] Performance optimization for large projects +- [ ] Memory usage monitoring +- [ ] Custom environment setup (env vars, dependencies) +- [ ] Support for different project types (Next.js, React, etc.) + +**Ongoing:** Stability improvements based on user feedback + +--- + +## Phase 5: Git Operations & Version Control + +**Status:** ~90% Complete | **Priority:** Critical | **Files:** `lib/git/*`, `api/sessions/[sessionId]/diff*`, `api/pr` + +- [x] Git diff viewing (`api/sessions/[sessionId]/diff/cached`) +- [x] File change tracking and visualization +- [x] Commit message generation (`api/sessions/[sessionId]/generate-commit-message`) +- [x] Commit and push operations to GitHub +- [x] PR generation and management (`api/generate-pr`, `api/check-pr`) +- [x] Git status checking (`api/git-status`) +- [ ] Merge conflict resolution UI +- [ ] Interactive branch management dashboard +- [ ] Git history visualization (recent commits, branches) +- [ ] Support for git cherry-pick and rebase +- [ ] Commit amending and force push warnings + +**Files to Review:** `lib/git/`, diff visualization logic + +--- + +## Phase 6: Workflow System & Task Automation + +**Status:** ~75% Complete | **Priority:** High | **Files:** `.well-known/workflow/*`, workflow integration + +- [x] Vercel Workflow SDK integration (basic setup) +- [x] Multi-step workflow support and execution +- [x] Webhook handling (`api/github/webhook`) +- [x] Workflow manifest generation (25 steps, 2 workflows) +- [ ] Workflow state persistence across failures +- [ ] Error handling and retry logic +- [ ] Workflow UI for monitoring and control +- [ ] Conditional branching in workflows +- [ ] Workflow scheduling (cron jobs) +- [ ] Workflow logging and debugging tools + +**Status Check:** Run `bun run build` to see workflow manifest details + +--- + +## Phase 7: Session & Chat Management + +**Status:** ~85% Complete | **Priority:** High | **Files:** `api/sessions/*`, `lib/session/*`, database schema + +- [x] Session creation and persistence (`api/sessions`) +- [x] Chat creation within sessions (`api/sessions/[sessionId]/chats/[chatId]`) +- [x] Chat history and message retrieval +- [x] Chat forking (`api/sessions/[sessionId]/chats/[chatId]/fork`) +- [x] Chat sharing and public links (`api/sessions/[sessionId]/chats/[chatId]/share`) +- [ ] Session state synchronization across tabs +- [ ] Chat export/import (Markdown, JSON) +- [ ] Session archival and cleanup +- [ ] Collaborative sessions (multiple users) +- [ ] Real-time message sync with WebSockets + +**Database Tables:** Sessions, Chats, ChatMessages (verify in schema) + +--- + +## Phase 8: User Profiles & Account Management + +**Status:** ~70% Complete | **Priority:** High | **Files:** `settings/`, `api/settings/*`, `lib/auth/*` + +- [x] User authentication via BetterAuth +- [x] GitHub account linking +- [x] Basic profile management +- [x] Settings pages (profile, preferences, accounts, connections) +- [ ] Account settings UI polish +- [ ] API key management for extensions +- [ ] Usage analytics and statistics (`api/usage`, `api/usage/rank`) +- [ ] Team/organization management +- [ ] Role-based access control (admin, user) +- [ ] Account deletion and data export + +**Settings Pages:** `settings/profile`, `settings/preferences`, `settings/connections`, `settings/admin` + +--- + +## Phase 9: Code Quality & Testing Infrastructure + +**Status:** ~40% Complete | **Priority:** Medium | **Files:** `*.test.ts`, test utilities + +- [x] Unit tests for chat streaming (`lib/chat-streaming-state.test.ts`) +- [x] Stream recovery tests (`lib/assistant-file-links.test.ts`) +- [x] Auto-commit tests (`lib/chat/auto-commit-direct.test.ts`) +- [x] Auto-PR tests (`lib/chat/auto-pr-direct.test.ts`) +- [ ] Expand test coverage to all API routes +- [ ] Add integration tests for GitHub workflows +- [ ] Add sandbox environment tests +- [ ] Performance benchmarking suite +- [ ] End-to-end tests with Playwright/Cypress +- [ ] Load testing for sandbox and chat APIs + +**Test Files to Review:** `lib/*.test.ts`, `lib/chat/*.test.ts` + +--- + +## Phase 10: Documentation & Developer Experience + +**Status:** ~20% Complete | **Priority:** Medium | **Files:** `README.md`, docs/, comments + +- [ ] Architecture documentation (monorepo, data flow, API) +- [ ] Setup guide for local development +- [ ] API documentation (endpoints, schemas) +- [ ] Skill/tool development guide +- [ ] Database schema documentation +- [ ] GitHub integration setup instructions +- [ ] Environment variables reference +- [ ] Troubleshooting guide +- [ ] TypeScript type safety improvements +- [ ] Error message improvements with suggestions + +**Current README:** Minimal setup info only + +--- + +## Phase 11: Performance & Scalability + +**Status:** ~30% Complete | **Priority:** Medium-High | **Files:** API routes, database, middleware + +- [ ] Database query optimization (add indexes) +- [ ] API response time monitoring +- [ ] Implement Redis caching layer (if needed) +- [ ] Rate limiting per user/IP (`api/middleware`) +- [ ] Query pagination for large datasets +- [ ] Database connection pooling +- [ ] CDN setup for static assets +- [ ] Image optimization +- [ ] Database migration strategy for scale +- [ ] Monitoring and alerting setup + +**Current Bottlenecks:** Large file handling, chat context window limits + +--- + +## Phase 12: Advanced Features & Integrations + +**Status:** ~40% Complete | **Priority:** Low-Medium | **Files:** Various + +- [x] GitHub integration (partial) +- [x] Vercel Workflow integration (partial) +- [ ] Vercel environment variables API integration (`api/vercel/*`) +- [ ] Vercel deployments management +- [ ] GitLab support +- [ ] Bitbucket support +- [ ] Slack/Discord notifications +- [ ] Advanced AI features (image generation, code analysis) +- [ ] CLI tool for local development +- [ ] Browser extension +- [ ] Mobile app or responsive improvements + +**In Scope:** `api/vercel/` endpoints exist but incomplete + +--- + +## Phase 13: Security & Compliance + +**Status:** ~50% Complete | **Priority:** High | **Files:** Middleware, auth, validation + +- [ ] Input validation on all API routes +- [ ] CORS configuration hardening +- [ ] CSRF token implementation +- [ ] API rate limiting and quotas +- [ ] DDoS protection +- [ ] Data encryption at rest +- [ ] Secrets rotation policy +- [ ] GDPR compliance (data export, deletion) +- [ ] Privacy policy and terms of service +- [ ] Security audit and penetration testing +- [ ] OAuth token security best practices + +**Auth System:** BetterAuth handles OAuth, need to audit other security layers + +--- + +## Phase 14: Production & Deployment + +**Status:** ~60% Complete | **Priority:** Critical | **Files:** Build config, deployment scripts + +- [x] Vercel deployment configuration +- [ ] Database migration strategy for production +- [ ] Environment variable management on Vercel +- [ ] Health checks and uptime monitoring +- [ ] Rollback procedures and automation +- [ ] Database backup strategy +- [ ] Log aggregation and analysis +- [ ] Error tracking (Sentry integration) +- [ ] Performance monitoring (Vercel Analytics) +- [ ] Deployment documentation +- [ ] Release notes automation + +**Current Status:** Builds successfully, but missing env vars block functionality + +--- + +## Critical Blockers + +1. **BETTER_AUTH_SECRET** - Required for authentication to work +2. **POSTGRES_URL** - Required for database operations +3. **Turbo output configuration** - Warning about missing build outputs + +## Quick Links + +- **API Routes:** `apps/web/app/api/` +- **Libraries:** `apps/web/lib/` +- **Database:** `apps/web/lib/db/schema.ts` +- **Auth Config:** `apps/web/lib/auth/` +- **Package Config:** `turbo.json`, `package.json` From 43b888ccebff15ebd01c5509bcf03dfbf302ad8c Mon Sep 17 00:00:00 2001 From: billlzzz26 Date: Mon, 27 Apr 2026 18:04:35 +0700 Subject: [PATCH 02/12] feat: apply local changes on top of main (#6) Co-authored-by: DevOps Bot --- .github/workflows/codeql.yml | 86 +++--- .gitig | 1 + apps/web/app/api/github/branches/route.ts | 13 + apps/web/git | 0 apps/web/lib/github/api.ts | 7 +- apps/web/lib/sandbox/lifecycle.ts | 3 +- bun.lock | 22 +- packages/agent/kilo/client.ts | 159 ++++++++++++ packages/agent/open-agent.ts | 4 + packages/agent/tools/delegate.ts | 176 +++++++++++++ packages/agent/tools/index.ts | 6 + packages/agent/tools/kilo.ts | 59 +++++ packages/sandbox/vercel/sandbox.ts | 11 +- renovate.json | 7 +- todo.md | 303 ++++++++++++++++++++++ 15 files changed, 797 insertions(+), 60 deletions(-) create mode 100644 .gitig create mode 100644 apps/web/git create mode 100644 packages/agent/kilo/client.ts create mode 100644 packages/agent/tools/delegate.ts create mode 100644 packages/agent/tools/kilo.ts create mode 100644 todo.md diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 201f19f0..60ad9244 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,11 +13,11 @@ name: "CodeQL Advanced" on: push: - branches: [ "b/devcontainer-setup" ] + branches: ["b/devcontainer-setup"] pull_request: - branches: [ "b/devcontainer-setup" ] + branches: ["b/devcontainer-setup"] schedule: - - cron: '16 2 * * 6' + - cron: "16 2 * * 6" jobs: analyze: @@ -43,8 +43,8 @@ jobs: fail-fast: false matrix: include: - - language: javascript-typescript - build-mode: none + - language: javascript-typescript + build-mode: none # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' # Use `c-cpp` to analyze code written in C, C++ or both # Use 'java-kotlin' to analyze code written in Java, Kotlin or both @@ -54,46 +54,46 @@ jobs: # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - # Add any setup steps before running the `github/codeql-action/init` action. - # This includes steps like installing compilers or runtimes (`actions/setup-node` - # or others). This is typically only required for manual builds. - # - name: Setup runtime (example) - # uses: actions/setup-example@v1 + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - name: Run manual build steps - if: matrix.build-mode == 'manual' - shell: bash - run: | - echo 'If you are using a "manual" build mode for one or more of the' \ - 'languages you are analyzing, replace this with the commands to build' \ - 'your code, for example:' - echo ' make bootstrap' - echo ' make release' - exit 1 + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - name: Run manual build steps + if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.gitig b/.gitig new file mode 100644 index 00000000..b9f40a7b --- /dev/null +++ b/.gitig @@ -0,0 +1 @@ +ore diff --git a/apps/web/app/api/github/branches/route.ts b/apps/web/app/api/github/branches/route.ts index 9e6e21b8..db57da91 100644 --- a/apps/web/app/api/github/branches/route.ts +++ b/apps/web/app/api/github/branches/route.ts @@ -17,6 +17,12 @@ function normalizeGitHubLimit(limit: number | undefined): number | undefined { : undefined; } +function isValidGitHubPathSegment(value: string): boolean { + // Conservative allow-list for owner/repo used in URL path segments. + // Allows alphanumerics plus GitHub-common separators. + return /^[A-Za-z0-9._-]{1,100}$/.test(value); +} + function parseRepoInfo(value: unknown): RepoInfo | null { if (!value || typeof value !== "object") { return null; @@ -286,6 +292,13 @@ export async function GET(request: NextRequest) { ); } + if (!isValidGitHubPathSegment(owner) || !isValidGitHubPathSegment(repo)) { + return NextResponse.json( + { error: "Invalid owner or repo parameter" }, + { status: 400 }, + ); + } + const token = await getUserGitHubToken(session.user.id); try { diff --git a/apps/web/git b/apps/web/git new file mode 100644 index 00000000..e69de29b diff --git a/apps/web/lib/github/api.ts b/apps/web/lib/github/api.ts index 2b4317d3..00627d3e 100644 --- a/apps/web/lib/github/api.ts +++ b/apps/web/lib/github/api.ts @@ -71,9 +71,12 @@ export async function fetchGitHubBranches( repo: string, limit?: number, ) { + const encodedOwner = encodeURIComponent(owner); + const encodedRepo = encodeURIComponent(repo); + // Fetch repo info for default branch const repoInfo = await fetchGitHubAPI( - `/repos/${owner}/${repo}`, + `/repos/${encodedOwner}/${encodedRepo}`, token, ); if (!repoInfo) return null; @@ -89,7 +92,7 @@ export async function fetchGitHubBranches( while (page <= maxPages) { const branches = await fetchGitHubAPI( - `/repos/${owner}/${repo}/branches?per_page=${perPage}&page=${page}`, + `/repos/${encodedOwner}/${encodedRepo}/branches?per_page=${perPage}&page=${page}`, token, ); diff --git a/apps/web/lib/sandbox/lifecycle.ts b/apps/web/lib/sandbox/lifecycle.ts index 234a4ee1..59fdcbea 100644 --- a/apps/web/lib/sandbox/lifecycle.ts +++ b/apps/web/lib/sandbox/lifecycle.ts @@ -259,7 +259,8 @@ export async function evaluateSandboxLifecycle( lifecycleError: message, }); console.error( - `[Lifecycle] Failed to evaluate sandbox lifecycle for session ${sessionId}:`, + "[Lifecycle] Failed to evaluate sandbox lifecycle for session %s:", + sessionId, error, ); return { action: "failed", reason: message }; diff --git a/bun.lock b/bun.lock index 115f9720..c3cd439b 100644 --- a/bun.lock +++ b/bun.lock @@ -52,7 +52,7 @@ "jose": "^6.1.3", "lucide-react": "^0.562.0", "nanoid": "^5.1.6", - "next": "16.2.1", + "next": "16.2.3", "postgres": "^3.4.8", "react": "19.2.3", "react-day-picker": "^9.14.0", @@ -445,23 +445,23 @@ "@nestjs/core": ["@nestjs/core@11.1.19", "", { "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@nestjs/websockets": "^11.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/platform-express", "@nestjs/websockets"] }, "sha512-6nJkWa2efrYi+XlU686J9y5L7OvxpLVjT0T/sxRKE7Jvpffiihelup4WSvLvRhdHDjj/5SuoWEwqReXAaaeHmw=="], - "@next/env": ["@next/env@16.2.1", "", {}, "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg=="], + "@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="], "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], @@ -1767,7 +1767,7 @@ "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], - "next": ["next@16.2.1", "", { "dependencies": { "@next/env": "16.2.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.1", "@next/swc-darwin-x64": "16.2.1", "@next/swc-linux-arm64-gnu": "16.2.1", "@next/swc-linux-arm64-musl": "16.2.1", "@next/swc-linux-x64-gnu": "16.2.1", "@next/swc-linux-x64-musl": "16.2.1", "@next/swc-win32-arm64-msvc": "16.2.1", "@next/swc-win32-x64-msvc": "16.2.1", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q=="], + "next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="], "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="], diff --git a/packages/agent/kilo/client.ts b/packages/agent/kilo/client.ts new file mode 100644 index 00000000..e46a60c0 --- /dev/null +++ b/packages/agent/kilo/client.ts @@ -0,0 +1,159 @@ +export type ChatCompletionRequest = { + model: string; + messages: Message[]; + stream?: boolean; + max_tokens?: number; + temperature?: number; + top_p?: number; + stop?: string | string[]; + frequency_penalty?: number; + presence_penalty?: number; + tools?: Tool[]; + tool_choice?: ToolChoice; + response_format?: ResponseFormat; + user?: string; + seed?: number; +}; + +export type Message = + | { role: "system"; content: string } + | { role: "user"; content: string | ContentPart[] } + | { role: "assistant"; content: string | null; tool_calls?: ToolCall[] } + | { role: "tool"; content: string; tool_call_id: string }; + +export type ContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string; detail?: string } }; + +export type Tool = { + type: "function"; + function: { + name: string; + description?: string; + parameters: object; + }; +}; + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export type ResponseFormat = { + type: "text" | "json_object"; +}; + +export type ToolCall = { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +}; + +export type ChatCompletionResponse = { + id: string; + object: "chat.completion"; + created: number; + model: string; + choices: Array<{ + index: number; + message: { + role: "assistant"; + content: string | null; + tool_calls?: ToolCall[]; + }; + finish_reason: "stop" | "length" | "tool_calls" | "content_filter"; + }>; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +}; + +export type FIMRequest = { + model: string; + prompt: string; + suffix?: string; + max_tokens?: number; + temperature?: number; + stop?: string[]; + stream?: boolean; +}; + +export type Model = { + id: string; + object: "model"; + created: number; + owned_by: string; + name: string; + context_length: number; + pricing: { + prompt: string; + completion: string; + }; +}; + +export class KiloClient { + private apiKey: string; + private baseUrl = "https://api.kilo.ai"; + + constructor(apiKey?: string) { + this.apiKey = apiKey || process.env.KILO_API_KEY || ""; + if (!this.apiKey) { + throw new Error("Kilo API key is required. Set KILO_API_KEY environment variable."); + } + } + + private async request(path: string, options?: RequestInit): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + ...options, + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + ...options?.headers, + }, + }); + + if (!response.ok) { + let errorData: any; + try { + errorData = await response.json(); + } catch (e) { + throw new Error(`Kilo API Error ${response.status}: ${response.statusText}`); + } + throw new Error( + `Kilo API Error ${response.status}: ${ + errorData?.error?.message || response.statusText + }` + ); + } + + return response.json() as Promise; + } + + async chatCompletions(body: ChatCompletionRequest): Promise { + return this.request("/api/gateway/chat/completions", { + method: "POST", + body: JSON.stringify(body), + }); + } + + async fimCompletions(body: FIMRequest): Promise { + return this.request("/api/fim/completions", { + method: "POST", + body: JSON.stringify(body), + }); + } + + async listModels(): Promise<{ data: Model[] }> { + return this.request<{ data: Model[] }>("/api/gateway/models"); + } + + async listProviders(): Promise { + return this.request("/api/gateway/providers"); + } +} diff --git a/packages/agent/open-agent.ts b/packages/agent/open-agent.ts index ceb7d151..56a961fb 100644 --- a/packages/agent/open-agent.ts +++ b/packages/agent/open-agent.ts @@ -19,9 +19,11 @@ import { readFileTool, skillTool, taskTool, + delegateTaskTool, todoWriteTool, webFetchTool, writeFileTool, + kiloTool, } from "./tools"; export interface AgentModelSelection { @@ -71,9 +73,11 @@ const tools = { glob: globTool(), bash: bashTool(), task: taskTool, + delegate_task: delegateTaskTool, ask_user_question: askUserQuestionTool, skill: skillTool, web_fetch: webFetchTool, + kilo: kiloTool, } satisfies ToolSet; export const openAgent = new ToolLoopAgent({ diff --git a/packages/agent/tools/delegate.ts b/packages/agent/tools/delegate.ts new file mode 100644 index 00000000..bdd8abf4 --- /dev/null +++ b/packages/agent/tools/delegate.ts @@ -0,0 +1,176 @@ +import { + type LanguageModelUsage, + type ModelMessage, + tool, + type UIToolInvocation, +} from "ai"; +import { z } from "zod"; +import { + buildSubagentSummaryLines, + SUBAGENT_REGISTRY, + SUBAGENT_TYPES, +} from "../subagents/registry"; +import { sumLanguageModelUsage } from "../usage"; +import { getSandboxContext, getSubagentModel } from "./utils"; + +const subagentTypeSchema = z.enum(SUBAGENT_TYPES); +const subagentSummaryLines = buildSubagentSummaryLines(); + +const delegateTaskSchema = z.object({ + subagentType: subagentTypeSchema.describe( + `Subagent to launch. Available options:\n${subagentSummaryLines}`, + ), + task: z.string().describe("Short description of the task (displayed to user)"), + instructions: z.string().describe( + `Detailed instructions for the subagent. Include: +- Goal and deliverables +- Step-by-step procedure +- Constraints and patterns to follow +- How to verify the work`, + ), +}); + +export const delegateTaskOutputSchema = z.object({ + results: z.array( + z.object({ + task: z.string(), + modelId: z.string().optional(), + toolCallCount: z.number(), + final: z.custom().optional(), + usage: z.custom().optional(), + }) + ), + totalToolCallCount: z.number().int().nonnegative().optional(), + startedAt: z.number().int().nonnegative().optional(), +}); + +export type DelegateTaskToolOutput = z.infer; + +export const delegateTaskTool = tool({ + needsApproval: false, + description: `Spawn multiple subagents to work on tasks in isolated contexts simultaneously. +Each subagent gets its own workspace context. Only the final summary is returned. + +AVAILABLE SUBAGENTS: +${subagentSummaryLines} + +WHEN TO USE delegate_task: +- Parallel independent workstreams (e.g. researching different topics simultaneously) +- Reasoning-heavy subtasks that can be separated and executed concurrently + +WHEN NOT TO USE (use existing tools instead): +- Single sequential task -> just use the 'task' tool directly +- Tasks needing your direct interaction or memory -> do it yourself +- Simple, single-file or single-change edits -> do it yourself + +HOW TO USE: +- Provide an array of 'tasks'. All run concurrently and results are returned together. +- Choose the appropriate subagentType for each task. +- Be explicit and concrete - subagents cannot ask clarifying questions.`, + inputSchema: z.object({ + tasks: z.array(delegateTaskSchema).min(1).max(5).describe( + "Tasks to run in parallel. Each gets its own subagent." + ), + }), + outputSchema: delegateTaskOutputSchema, + execute: async function* ( + { tasks }, + { experimental_context, abortSignal }, + ) { + const sandboxContext = getSandboxContext(experimental_context, "delegateTask"); + const model = getSubagentModel(experimental_context, "delegateTask"); + const subagentModelId = typeof model === "string" ? model : model.modelId; + const startedAt = Date.now(); + + // Initialize state + const taskStates = tasks.map((t) => ({ + task: t.task, + modelId: subagentModelId, + toolCallCount: 0, + usage: undefined as LanguageModelUsage | undefined, + final: undefined as ModelMessage[] | undefined, + })); + + yield { + results: taskStates, + totalToolCallCount: 0, + startedAt, + }; + + const runTask = async (taskInput: typeof tasks[0], index: number) => { + const state = taskStates[index]; + if (!state) return; + const subagent = SUBAGENT_REGISTRY[taskInput.subagentType].agent; + const result = await subagent.stream({ + prompt: "Complete this task and provide a summary of what you accomplished.", + options: { + task: taskInput.task, + instructions: taskInput.instructions, + sandbox: sandboxContext.sandbox, + model, + }, + abortSignal, + }); + + for await (const part of result.fullStream) { + if (part.type === "tool-call") { + state.toolCallCount += 1; + } else if (part.type === "finish-step") { + state.usage = sumLanguageModelUsage(state.usage, part.usage); + } + } + + const response = await result.response; + const finalUsage = state.usage ?? (await result.usage); + + state.final = response.messages; + state.usage = finalUsage; + + return state; + }; + + // Run all tasks in parallel + const promises = tasks.map((t, i) => runTask(t, i)); + await Promise.all(promises); + + const totalToolCallCount = taskStates.reduce((acc, t) => acc + t.toolCallCount, 0); + + yield { + results: taskStates, + totalToolCallCount, + startedAt, + }; + }, + toModelOutput: ({ output: { results } }) => { + if (!results || results.length === 0) { + return { type: "text", value: "All tasks completed." }; + } + + const summaries = results.map((result, i) => { + const messages = result.final; + let contentValue = "Task completed without detailed summary."; + + if (messages) { + const lastAssistantMessage = messages.findLast((p) => p.role === "assistant"); + const content = lastAssistantMessage?.content; + + if (content) { + if (typeof content === "string") { + contentValue = content; + } else { + const lastTextPart = content.findLast((p) => p.type === "text"); + if (lastTextPart) { + contentValue = lastTextPart.text; + } + } + } + } + + return `=== Result for Task ${i + 1}: ${result.task} ===\n${contentValue}`; + }); + + return { type: "text", value: summaries.join("\n\n") }; + }, +}); + +export type DelegateTaskToolUIPart = UIToolInvocation; diff --git a/packages/agent/tools/index.ts b/packages/agent/tools/index.ts index bd81875f..5423d521 100644 --- a/packages/agent/tools/index.ts +++ b/packages/agent/tools/index.ts @@ -10,6 +10,11 @@ export { type TaskToolOutput, type TaskToolUIPart, } from "./task"; +export { + delegateTaskTool, + type DelegateTaskToolOutput, + type DelegateTaskToolUIPart, +} from "./delegate"; export { askUserQuestionTool, type AskUserQuestionToolUIPart, @@ -17,3 +22,4 @@ export { } from "./ask-user-question"; export { skillTool, type SkillToolInput } from "./skill"; export { webFetchTool } from "./fetch"; +export { kiloTool } from "./kilo"; diff --git a/packages/agent/tools/kilo.ts b/packages/agent/tools/kilo.ts new file mode 100644 index 00000000..5401ec7c --- /dev/null +++ b/packages/agent/tools/kilo.ts @@ -0,0 +1,59 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { KiloClient } from "../kilo/client"; + +const kiloInputSchema = z.object({ + action: z.enum(["list_models", "list_providers", "chat_completions", "fim_completions"]), + model: z.string().optional().describe("Model ID (e.g., 'anthropic/claude-sonnet-4.5'). Required for completions."), + messages: z.array(z.any()).optional().describe("Array of conversation messages. Required for chat_completions."), + prompt: z.string().optional().describe("Code before the cursor. Required for fim_completions."), + suffix: z.string().optional().describe("Code after the cursor. Optional for fim_completions."), + max_tokens: z.number().optional(), + temperature: z.number().optional(), +}); + +export const kiloTool = tool({ + description: `Interact with the Kilo AI Gateway. +Supports: +- list_models: Retrieve the list of available models. +- list_providers: Retrieve the list of available providers. +- chat_completions: Create a chat completion using any supported model. +- fim_completions: Fill-in-the-middle completions for code generation (Mistral models only).`, + inputSchema: kiloInputSchema, + execute: async (input) => { + try { + const client = new KiloClient(); + switch (input.action) { + case "list_models": + return await client.listModels(); + case "list_providers": + return await client.listProviders(); + case "chat_completions": + if (!input.model || !input.messages) { + return { error: "model and messages are required for chat_completions" }; + } + return await client.chatCompletions({ + model: input.model, + messages: input.messages, + max_tokens: input.max_tokens, + temperature: input.temperature, + }); + case "fim_completions": + if (!input.model || !input.prompt) { + return { error: "model and prompt are required for fim_completions" }; + } + return await client.fimCompletions({ + model: input.model, + prompt: input.prompt, + suffix: input.suffix, + max_tokens: input.max_tokens, + temperature: input.temperature, + }); + default: + return { error: "Unknown action" }; + } + } catch (error: any) { + return { error: error.message || String(error) }; + } + }, +}); diff --git a/packages/sandbox/vercel/sandbox.ts b/packages/sandbox/vercel/sandbox.ts index a3469286..c018ef0c 100644 --- a/packages/sandbox/vercel/sandbox.ts +++ b/packages/sandbox/vercel/sandbox.ts @@ -111,15 +111,22 @@ function buildAuthenticatedGitHubUrl( repoUrl: string, token: string, ): string | null { + // Match GitHub URL patterns to avoid REDOS vulnerability + // Matches: github.com/:owner/:repo or github.com/:owner/:repo.git const githubUrlMatch = repoUrl.match( - /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/, + /^.*?github\.com[/:]([^/]+?)\/([^/]+?)(?:\.git)?$/, ); - if (!githubUrlMatch) { return null; } const [, owner, repo] = githubUrlMatch; + + // Validate that owner and repo are non-empty + if (!owner || !repo) { + return null; + } + return `https://x-access-token:${token}@github.com/${owner}/${repo}.git`; } diff --git a/renovate.json b/renovate.json index cbc6661c..1ccd67a8 100644 --- a/renovate.json +++ b/renovate.json @@ -26,7 +26,12 @@ }, { "description": "Group React and React DOM — must stay in sync", - "matchPackageNames": ["react", "react-dom", "@types/react", "@types/react-dom"], + "matchPackageNames": [ + "react", + "react-dom", + "@types/react", + "@types/react-dom" + ], "groupName": "React", "automerge": false }, diff --git a/todo.md b/todo.md new file mode 100644 index 00000000..6ace1a2d --- /dev/null +++ b/todo.md @@ -0,0 +1,303 @@ +--- +name: todo +created: 2026-04-26T22:50:23Z +updated: 2026-04-26T22:50:23Z +author: billlzzz26 +project: Open-Agent (AI-Powered Code Generation Platform) +description: v0-like code generation agent for GitHub repositories with AI-driven development +--- + +# Open-Agent Development Roadmap + +> Open-Agent enables AI-driven development for GitHub repositories. Users can connect their repos, have conversations with AI about their code, and generate changes via an integrated sandbox environment. This roadmap tracks implementation progress across 14 development phases. + +## Phase 1: Environment Setup & Configuration (Core Infrastructure) + +**Status:** ~80% Complete | **Priority:** Critical | **Files:** `turbo.json`, `.env`, `apps/web/`, `lib/`, `packages/` + +- [x] Setup Monorepo structure with Turbo (`turbo.json` configured) +- [x] Initialize Next.js 16 web app (`apps/web/`) +- [x] Configure BetterAuth for authentication (`lib/auth/config.ts`, `lib/auth/client.ts`) +- [x] Setup PostgreSQL database schema (`lib/db/schema.ts`, migrations) +- [x] Install core dependencies (AI SDK 6, Radix UI, Drizzle ORM, etc.) +- [ ] **CRITICAL:** Add `BETTER_AUTH_SECRET` env var (required for auth) +- [ ] **CRITICAL:** Add `POSTGRES_URL` env var (required for database) +- [ ] Verify build outputs in `turbo.json` (currently warns about missing outputs) +- [ ] Update `.env.example` with all required environment variables +- [ ] Document setup instructions for new developers + +**Blocked By:** Environment variables not configured in Vercel project + +--- + +## Phase 2: GitHub Integration & Repository Management + +**Status:** ~90% Complete | **Priority:** Critical | **Files:** `api/github/*`, `lib/github/*` + +- [x] GitHub OAuth App setup and configuration +- [x] User GitHub accounts linking via BetterAuth +- [x] Repository listing from authenticated user/orgs (`api/github/orgs`, `api/github/installations/repos`) +- [x] Repository metadata retrieval (name, description, branches) +- [x] Branch listing and selection (`api/github/branches`) +- [x] Current user info retrieval (`api/github/user`) +- [x] Organization listing (`api/github/orgs`) +- [ ] GitHub App installation status tracking (`api/github/orgs/install-status`) +- [ ] Handle multiple GitHub accounts per user +- [ ] Graceful error handling for revoked app access +- [ ] Support for private repositories access verification +- [ ] Repository webhook setup for CI/CD integration + +**In Progress:** GitHub app installation flow improvements + +--- + +## Phase 3: AI-Powered Code Generation & Chat + +**Status:** ~85% Complete | **Priority:** Critical | **Files:** `api/chat/*`, `lib/chat/*`, `components/`, `hooks/` + +- [x] Chat interface with streaming responses (`api/chat/[chatId]/stream`) +- [x] Vercel AI SDK 6 integration with multiple models support +- [x] Tool/skill execution system for code generation +- [x] Chat message persistence to database +- [x] Stream recovery and error handling (`lib/chat/create-cancelable-readable-stream.ts`) +- [x] Auto-commit functionality (`lib/chat/auto-commit-direct.ts`) +- [x] Auto-PR generation (`lib/chat/auto-pr-direct.ts`) +- [ ] Context window optimization for large files (>10KB) +- [ ] Token usage tracking and display +- [ ] Chat history and context management improvements +- [ ] Implement prompt templates for common tasks +- [ ] Add conversation export (markdown, JSON) +- [ ] Fine-tune model selection per task type + +**Files to Review:** `lib/chat/`, `api/chat/`, chat streaming implementation + +--- + +## Phase 4: Workspace & Sandbox Environment + +**Status:** ~80% Complete | **Priority:** Critical | **Files:** `api/sandbox/*`, `lib/sandbox/*`, `packages/sandbox` + +- [x] Vercel Sandbox integration for code execution +- [x] Dev server startup and management (`api/sandbox/status`) +- [x] File system navigation and code editor (`api/sessions/[sessionId]/files`) +- [x] Live preview with hot reload +- [x] Sandbox snapshot and restore capability (`api/sandbox/snapshot`) +- [x] Sandbox reconnection logic (`api/sandbox/reconnect`) +- [ ] Error recovery when sandbox crashes +- [ ] Sandbox timeout handling and extension (`api/sandbox/extend`) +- [ ] Performance optimization for large projects +- [ ] Memory usage monitoring +- [ ] Custom environment setup (env vars, dependencies) +- [ ] Support for different project types (Next.js, React, etc.) + +**Ongoing:** Stability improvements based on user feedback + +--- + +## Phase 5: Git Operations & Version Control + +**Status:** ~90% Complete | **Priority:** Critical | **Files:** `lib/git/*`, `api/sessions/[sessionId]/diff*`, `api/pr` + +- [x] Git diff viewing (`api/sessions/[sessionId]/diff/cached`) +- [x] File change tracking and visualization +- [x] Commit message generation (`api/sessions/[sessionId]/generate-commit-message`) +- [x] Commit and push operations to GitHub +- [x] PR generation and management (`api/generate-pr`, `api/check-pr`) +- [x] Git status checking (`api/git-status`) +- [ ] Merge conflict resolution UI +- [ ] Interactive branch management dashboard +- [ ] Git history visualization (recent commits, branches) +- [ ] Support for git cherry-pick and rebase +- [ ] Commit amending and force push warnings + +**Files to Review:** `lib/git/`, diff visualization logic + +--- + +## Phase 6: Workflow System & Task Automation + +**Status:** ~75% Complete | **Priority:** High | **Files:** `.well-known/workflow/*`, workflow integration + +- [x] Vercel Workflow SDK integration (basic setup) +- [x] Multi-step workflow support and execution +- [x] Webhook handling (`api/github/webhook`) +- [x] Workflow manifest generation (25 steps, 2 workflows) +- [ ] Workflow state persistence across failures +- [ ] Error handling and retry logic +- [ ] Workflow UI for monitoring and control +- [ ] Conditional branching in workflows +- [ ] Workflow scheduling (cron jobs) +- [ ] Workflow logging and debugging tools + +**Status Check:** Run `bun run build` to see workflow manifest details + +--- + +## Phase 7: Session & Chat Management + +**Status:** ~85% Complete | **Priority:** High | **Files:** `api/sessions/*`, `lib/session/*`, database schema + +- [x] Session creation and persistence (`api/sessions`) +- [x] Chat creation within sessions (`api/sessions/[sessionId]/chats/[chatId]`) +- [x] Chat history and message retrieval +- [x] Chat forking (`api/sessions/[sessionId]/chats/[chatId]/fork`) +- [x] Chat sharing and public links (`api/sessions/[sessionId]/chats/[chatId]/share`) +- [ ] Session state synchronization across tabs +- [ ] Chat export/import (Markdown, JSON) +- [ ] Session archival and cleanup +- [ ] Collaborative sessions (multiple users) +- [ ] Real-time message sync with WebSockets + +**Database Tables:** Sessions, Chats, ChatMessages (verify in schema) + +--- + +## Phase 8: User Profiles & Account Management + +**Status:** ~70% Complete | **Priority:** High | **Files:** `settings/`, `api/settings/*`, `lib/auth/*` + +- [x] User authentication via BetterAuth +- [x] GitHub account linking +- [x] Basic profile management +- [x] Settings pages (profile, preferences, accounts, connections) +- [ ] Account settings UI polish +- [ ] API key management for extensions +- [ ] Usage analytics and statistics (`api/usage`, `api/usage/rank`) +- [ ] Team/organization management +- [ ] Role-based access control (admin, user) +- [ ] Account deletion and data export + +**Settings Pages:** `settings/profile`, `settings/preferences`, `settings/connections`, `settings/admin` + +--- + +## Phase 9: Code Quality & Testing Infrastructure + +**Status:** ~40% Complete | **Priority:** Medium | **Files:** `*.test.ts`, test utilities + +- [x] Unit tests for chat streaming (`lib/chat-streaming-state.test.ts`) +- [x] Stream recovery tests (`lib/assistant-file-links.test.ts`) +- [x] Auto-commit tests (`lib/chat/auto-commit-direct.test.ts`) +- [x] Auto-PR tests (`lib/chat/auto-pr-direct.test.ts`) +- [ ] Expand test coverage to all API routes +- [ ] Add integration tests for GitHub workflows +- [ ] Add sandbox environment tests +- [ ] Performance benchmarking suite +- [ ] End-to-end tests with Playwright/Cypress +- [ ] Load testing for sandbox and chat APIs + +**Test Files to Review:** `lib/*.test.ts`, `lib/chat/*.test.ts` + +--- + +## Phase 10: Documentation & Developer Experience + +**Status:** ~20% Complete | **Priority:** Medium | **Files:** `README.md`, docs/, comments + +- [ ] Architecture documentation (monorepo, data flow, API) +- [ ] Setup guide for local development +- [ ] API documentation (endpoints, schemas) +- [ ] Skill/tool development guide +- [ ] Database schema documentation +- [ ] GitHub integration setup instructions +- [ ] Environment variables reference +- [ ] Troubleshooting guide +- [ ] TypeScript type safety improvements +- [ ] Error message improvements with suggestions + +**Current README:** Minimal setup info only + +--- + +## Phase 11: Performance & Scalability + +**Status:** ~30% Complete | **Priority:** Medium-High | **Files:** API routes, database, middleware + +- [ ] Database query optimization (add indexes) +- [ ] API response time monitoring +- [ ] Implement Redis caching layer (if needed) +- [ ] Rate limiting per user/IP (`api/middleware`) +- [ ] Query pagination for large datasets +- [ ] Database connection pooling +- [ ] CDN setup for static assets +- [ ] Image optimization +- [ ] Database migration strategy for scale +- [ ] Monitoring and alerting setup + +**Current Bottlenecks:** Large file handling, chat context window limits + +--- + +## Phase 12: Advanced Features & Integrations + +**Status:** ~40% Complete | **Priority:** Low-Medium | **Files:** Various + +- [x] GitHub integration (partial) +- [x] Vercel Workflow integration (partial) +- [ ] Vercel environment variables API integration (`api/vercel/*`) +- [ ] Vercel deployments management +- [ ] GitLab support +- [ ] Bitbucket support +- [ ] Slack/Discord notifications +- [ ] Advanced AI features (image generation, code analysis) +- [ ] CLI tool for local development +- [ ] Browser extension +- [ ] Mobile app or responsive improvements + +**In Scope:** `api/vercel/` endpoints exist but incomplete + +--- + +## Phase 13: Security & Compliance + +**Status:** ~50% Complete | **Priority:** High | **Files:** Middleware, auth, validation + +- [ ] Input validation on all API routes +- [ ] CORS configuration hardening +- [ ] CSRF token implementation +- [ ] API rate limiting and quotas +- [ ] DDoS protection +- [ ] Data encryption at rest +- [ ] Secrets rotation policy +- [ ] GDPR compliance (data export, deletion) +- [ ] Privacy policy and terms of service +- [ ] Security audit and penetration testing +- [ ] OAuth token security best practices + +**Auth System:** BetterAuth handles OAuth, need to audit other security layers + +--- + +## Phase 14: Production & Deployment + +**Status:** ~60% Complete | **Priority:** Critical | **Files:** Build config, deployment scripts + +- [x] Vercel deployment configuration +- [ ] Database migration strategy for production +- [ ] Environment variable management on Vercel +- [ ] Health checks and uptime monitoring +- [ ] Rollback procedures and automation +- [ ] Database backup strategy +- [ ] Log aggregation and analysis +- [ ] Error tracking (Sentry integration) +- [ ] Performance monitoring (Vercel Analytics) +- [ ] Deployment documentation +- [ ] Release notes automation + +**Current Status:** Builds successfully, but missing env vars block functionality + +--- + +## Critical Blockers + +1. **BETTER_AUTH_SECRET** - Required for authentication to work +2. **POSTGRES_URL** - Required for database operations +3. **Turbo output configuration** - Warning about missing build outputs + +## Quick Links + +- **API Routes:** `apps/web/app/api/` +- **Libraries:** `apps/web/lib/` +- **Database:** `apps/web/lib/db/schema.ts` +- **Auth Config:** `apps/web/lib/auth/` +- **Package Config:** `turbo.json`, `package.json` From c5d24a1b114e363a0a3aee43c84f6c39bfeeb6ba Mon Sep 17 00:00:00 2001 From: DevOps Bot Date: Mon, 27 Apr 2026 11:54:29 +0000 Subject: [PATCH 03/12] feat: update UI components and add AI elements --- .../web/components/ai-elements/code-block.tsx | 562 ++++++++++++++++++ apps/web/components/ui/button.tsx | 30 +- apps/web/components/ui/select.tsx | 56 +- apps/web/package.json | 4 +- bun.lock | 357 ++++++++++- package.json | 4 +- scripts/clean-workspace.sh | 23 + 7 files changed, 974 insertions(+), 62 deletions(-) create mode 100644 apps/web/components/ai-elements/code-block.tsx create mode 100755 scripts/clean-workspace.sh diff --git a/apps/web/components/ai-elements/code-block.tsx b/apps/web/components/ai-elements/code-block.tsx new file mode 100644 index 00000000..bc1441a4 --- /dev/null +++ b/apps/web/components/ai-elements/code-block.tsx @@ -0,0 +1,562 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import type { ComponentProps, CSSProperties, HTMLAttributes } from "react"; +import { + createContext, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { + BundledLanguage, + BundledTheme, + HighlighterGeneric, + ThemedToken, +} from "shiki"; +import { createHighlighter } from "shiki"; + +// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline +// oxlint-disable-next-line eslint(no-bitwise) +const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1; +// oxlint-disable-next-line eslint(no-bitwise) +const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2; +const isUnderline = (fontStyle: number | undefined) => + // oxlint-disable-next-line eslint(no-bitwise) + fontStyle && fontStyle & 4; + +// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint +interface KeyedToken { + token: ThemedToken; + key: string; +} +interface KeyedLine { + tokens: KeyedToken[]; + key: string; +} + +const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] => + lines.map((line, lineIdx) => ({ + key: `line-${lineIdx}`, + tokens: line.map((token, tokenIdx) => ({ + key: `line-${lineIdx}-${tokenIdx}`, + token, + })), + })); + +// Token rendering component +const TokenSpan = ({ token }: { token: ThemedToken }) => ( + + {token.content} + +); + +// Line number styles using CSS counters +const LINE_NUMBER_CLASSES = cn( + "block", + "before:content-[counter(line)]", + "before:inline-block", + "before:[counter-increment:line]", + "before:w-8", + "before:mr-4", + "before:text-right", + "before:text-muted-foreground/50", + "before:font-mono", + "before:select-none" +); + +// Line rendering component +const LineSpan = ({ + keyedLine, + showLineNumbers, +}: { + keyedLine: KeyedLine; + showLineNumbers: boolean; +}) => ( + + {keyedLine.tokens.length === 0 + ? "\n" + : keyedLine.tokens.map(({ token, key }) => ( + + ))} + +); + +// Types +type CodeBlockProps = HTMLAttributes & { + code: string; + language: BundledLanguage; + showLineNumbers?: boolean; +}; + +interface TokenizedCode { + tokens: ThemedToken[][]; + fg: string; + bg: string; +} + +interface CodeBlockContextType { + code: string; +} + +// Context +const CodeBlockContext = createContext({ + code: "", +}); + +// Highlighter cache (singleton per language) +const highlighterCache = new Map< + string, + Promise> +>(); + +// Token cache +const tokensCache = new Map(); + +// Subscribers for async token updates +const subscribers = new Map void>>(); + +const getTokensCacheKey = (code: string, language: BundledLanguage) => { + const start = code.slice(0, 100); + const end = code.length > 100 ? code.slice(-100) : ""; + return `${language}:${code.length}:${start}:${end}`; +}; + +const getHighlighter = ( + language: BundledLanguage +): Promise> => { + const cached = highlighterCache.get(language); + if (cached) { + return cached; + } + + const highlighterPromise = createHighlighter({ + langs: [language], + themes: ["github-light", "github-dark"], + }); + + highlighterCache.set(language, highlighterPromise); + return highlighterPromise; +}; + +// Create raw tokens for immediate display while highlighting loads +const createRawTokens = (code: string): TokenizedCode => ({ + bg: "transparent", + fg: "inherit", + tokens: code.split("\n").map((line) => + line === "" + ? [] + : [ + { + color: "inherit", + content: line, + } as ThemedToken, + ] + ), +}); + +// Synchronous highlight with callback for async results +export const highlightCode = ( + code: string, + language: BundledLanguage, + // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks) + callback?: (result: TokenizedCode) => void +): TokenizedCode | null => { + const tokensCacheKey = getTokensCacheKey(code, language); + + // Return cached result if available + const cached = tokensCache.get(tokensCacheKey); + if (cached) { + return cached; + } + + // Subscribe callback if provided + if (callback) { + if (!subscribers.has(tokensCacheKey)) { + subscribers.set(tokensCacheKey, new Set()); + } + subscribers.get(tokensCacheKey)?.add(callback); + } + + // Start highlighting in background - fire-and-forget async pattern + getHighlighter(language) + // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then) + .then((highlighter) => { + const availableLangs = highlighter.getLoadedLanguages(); + const langToUse = availableLangs.includes(language) ? language : "text"; + + const result = highlighter.codeToTokens(code, { + lang: langToUse, + themes: { + dark: "github-dark", + light: "github-light", + }, + }); + + const tokenized: TokenizedCode = { + bg: result.bg ?? "transparent", + fg: result.fg ?? "inherit", + tokens: result.tokens, + }; + + // Cache the result + tokensCache.set(tokensCacheKey, tokenized); + + // Notify all subscribers + const subs = subscribers.get(tokensCacheKey); + if (subs) { + for (const sub of subs) { + sub(tokenized); + } + subscribers.delete(tokensCacheKey); + } + }) + // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks) + .catch((error) => { + console.error("Failed to highlight code:", error); + subscribers.delete(tokensCacheKey); + }); + + return null; +}; + +const CodeBlockBody = memo( + ({ + tokenized, + showLineNumbers, + className, + }: { + tokenized: TokenizedCode; + showLineNumbers: boolean; + className?: string; + }) => { + const preStyle = useMemo( + () => ({ + backgroundColor: tokenized.bg, + color: tokenized.fg, + }), + [tokenized.bg, tokenized.fg] + ); + + const keyedLines = useMemo( + () => addKeysToTokens(tokenized.tokens), + [tokenized.tokens] + ); + + return ( +
+        
+          {keyedLines.map((keyedLine) => (
+            
+          ))}
+        
+      
+ ); + }, + (prevProps, nextProps) => + prevProps.tokenized === nextProps.tokenized && + prevProps.showLineNumbers === nextProps.showLineNumbers && + prevProps.className === nextProps.className +); + +CodeBlockBody.displayName = "CodeBlockBody"; + +export const CodeBlockContainer = ({ + className, + language, + style, + ...props +}: HTMLAttributes & { language: string }) => ( +
+); + +export const CodeBlockHeader = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +export const CodeBlockTitle = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +export const CodeBlockFilename = ({ + children, + className, + ...props +}: HTMLAttributes) => ( + + {children} + +); + +export const CodeBlockActions = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +export const CodeBlockContent = ({ + code, + language, + showLineNumbers = false, +}: { + code: string; + language: BundledLanguage; + showLineNumbers?: boolean; +}) => { + // Memoized raw tokens for immediate display + const rawTokens = useMemo(() => createRawTokens(code), [code]); + + // Synchronous cache lookup — avoids setState in effect for cached results + const syncTokens = useMemo( + () => highlightCode(code, language) ?? rawTokens, + [code, language, rawTokens] + ); + + // Async highlighting result (populated after shiki loads) + const [asyncTokens, setAsyncTokens] = useState(null); + const asyncKeyRef = useRef({ code, language }); + + // Invalidate stale async tokens synchronously during render + if ( + asyncKeyRef.current.code !== code || + asyncKeyRef.current.language !== language + ) { + asyncKeyRef.current = { code, language }; + setAsyncTokens(null); + } + + useEffect(() => { + let cancelled = false; + + highlightCode(code, language, (result) => { + if (!cancelled) { + setAsyncTokens(result); + } + }); + + return () => { + cancelled = true; + }; + }, [code, language]); + + const tokenized = asyncTokens ?? syncTokens; + + return ( +
+ +
+ ); +}; + +export const CodeBlock = ({ + code, + language, + showLineNumbers = false, + className, + children, + ...props +}: CodeBlockProps) => { + const contextValue = useMemo(() => ({ code }), [code]); + + return ( + + + {children} + + + + ); +}; + +export type CodeBlockCopyButtonProps = ComponentProps & { + onCopy?: () => void; + onError?: (error: Error) => void; + timeout?: number; +}; + +export const CodeBlockCopyButton = ({ + onCopy, + onError, + timeout = 2000, + children, + className, + ...props +}: CodeBlockCopyButtonProps) => { + const [isCopied, setIsCopied] = useState(false); + const timeoutRef = useRef(0); + const { code } = useContext(CodeBlockContext); + + const copyToClipboard = useCallback(async () => { + if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { + onError?.(new Error("Clipboard API not available")); + return; + } + + try { + if (!isCopied) { + await navigator.clipboard.writeText(code); + setIsCopied(true); + onCopy?.(); + timeoutRef.current = window.setTimeout( + () => setIsCopied(false), + timeout + ); + } + } catch (error) { + onError?.(error as Error); + } + }, [code, onCopy, onError, timeout, isCopied]); + + useEffect( + () => () => { + window.clearTimeout(timeoutRef.current); + }, + [] + ); + + const Icon = isCopied ? CheckIcon : CopyIcon; + + return ( + + ); +}; + +export type CodeBlockLanguageSelectorProps = ComponentProps; + +export const CodeBlockLanguageSelector = ( + props: CodeBlockLanguageSelectorProps +) => ; export type CodeBlockLanguageSelectorTriggerProps = ComponentProps< @@ -527,7 +528,7 @@ export const CodeBlockLanguageSelectorTrigger = ({ ; export const CodeBlockLanguageSelectorValue = ( - props: CodeBlockLanguageSelectorValueProps + props: CodeBlockLanguageSelectorValueProps, ) => ; export type CodeBlockLanguageSelectorContentProps = ComponentProps< @@ -558,5 +559,5 @@ export type CodeBlockLanguageSelectorItemProps = ComponentProps< >; export const CodeBlockLanguageSelectorItem = ( - props: CodeBlockLanguageSelectorItemProps + props: CodeBlockLanguageSelectorItemProps, ) => ; diff --git a/package.json b/package.json index a0bbafd1..55ee6a15 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,9 @@ "test:verbose": "bun test --reporter=junit --reporter-outfile /dev/stdout", "clean:workspace": "bash scripts/clean-workspace.sh" }, + "dependencies": { + "@vercel/analytics": "^2.0.1" + }, "devDependencies": { "@shelve/cli": "^5.0.1", "oxfmt": "^0.43.0", @@ -38,8 +41,5 @@ "overrides": { "zod": "^4.3.6" }, - "packageManager": "bun@1.2.14", - "dependencies": { - "@vercel/analytics": "^2.0.1" - } + "packageManager": "bun@1.2.14" } diff --git a/packages/agent/kilo/client.ts b/packages/agent/kilo/client.ts index e46a60c0..b4ef3b05 100644 --- a/packages/agent/kilo/client.ts +++ b/packages/agent/kilo/client.ts @@ -104,7 +104,9 @@ export class KiloClient { constructor(apiKey?: string) { this.apiKey = apiKey || process.env.KILO_API_KEY || ""; if (!this.apiKey) { - throw new Error("Kilo API key is required. Set KILO_API_KEY environment variable."); + throw new Error( + "Kilo API key is required. Set KILO_API_KEY environment variable.", + ); } } @@ -112,38 +114,46 @@ export class KiloClient { const response = await fetch(`${this.baseUrl}${path}`, { ...options, headers: { - "Authorization": `Bearer ${this.apiKey}`, + Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json", ...options?.headers, }, }); if (!response.ok) { - let errorData: any; + let errorData: unknown; try { errorData = await response.json(); - } catch (e) { - throw new Error(`Kilo API Error ${response.status}: ${response.statusText}`); + } catch { + throw new Error( + `Kilo API Error ${response.status}: ${response.statusText}`, + { cause: e }, + ); } throw new Error( `Kilo API Error ${response.status}: ${ errorData?.error?.message || response.statusText - }` + }`, ); } return response.json() as Promise; } - async chatCompletions(body: ChatCompletionRequest): Promise { - return this.request("/api/gateway/chat/completions", { - method: "POST", - body: JSON.stringify(body), - }); + async chatCompletions( + body: ChatCompletionRequest, + ): Promise { + return this.request( + "/api/gateway/chat/completions", + { + method: "POST", + body: JSON.stringify(body), + }, + ); } - async fimCompletions(body: FIMRequest): Promise { - return this.request("/api/fim/completions", { + async fimCompletions(body: FIMRequest): Promise { + return this.request("/api/fim/completions", { method: "POST", body: JSON.stringify(body), }); @@ -153,7 +163,7 @@ export class KiloClient { return this.request<{ data: Model[] }>("/api/gateway/models"); } - async listProviders(): Promise { - return this.request("/api/gateway/providers"); + async listProviders(): Promise { + return this.request("/api/gateway/providers"); } } diff --git a/packages/agent/tools/delegate.ts b/packages/agent/tools/delegate.ts index bdd8abf4..af81f402 100644 --- a/packages/agent/tools/delegate.ts +++ b/packages/agent/tools/delegate.ts @@ -20,7 +20,9 @@ const delegateTaskSchema = z.object({ subagentType: subagentTypeSchema.describe( `Subagent to launch. Available options:\n${subagentSummaryLines}`, ), - task: z.string().describe("Short description of the task (displayed to user)"), + task: z + .string() + .describe("Short description of the task (displayed to user)"), instructions: z.string().describe( `Detailed instructions for the subagent. Include: - Goal and deliverables @@ -38,7 +40,7 @@ export const delegateTaskOutputSchema = z.object({ toolCallCount: z.number(), final: z.custom().optional(), usage: z.custom().optional(), - }) + }), ), totalToolCallCount: z.number().int().nonnegative().optional(), startedAt: z.number().int().nonnegative().optional(), @@ -68,16 +70,18 @@ HOW TO USE: - Choose the appropriate subagentType for each task. - Be explicit and concrete - subagents cannot ask clarifying questions.`, inputSchema: z.object({ - tasks: z.array(delegateTaskSchema).min(1).max(5).describe( - "Tasks to run in parallel. Each gets its own subagent." - ), + tasks: z + .array(delegateTaskSchema) + .min(1) + .max(5) + .describe("Tasks to run in parallel. Each gets its own subagent."), }), outputSchema: delegateTaskOutputSchema, - execute: async function* ( - { tasks }, - { experimental_context, abortSignal }, - ) { - const sandboxContext = getSandboxContext(experimental_context, "delegateTask"); + execute: async function* ({ tasks }, { experimental_context, abortSignal }) { + const sandboxContext = getSandboxContext( + experimental_context, + "delegateTask", + ); const model = getSubagentModel(experimental_context, "delegateTask"); const subagentModelId = typeof model === "string" ? model : model.modelId; const startedAt = Date.now(); @@ -97,12 +101,13 @@ HOW TO USE: startedAt, }; - const runTask = async (taskInput: typeof tasks[0], index: number) => { + const runTask = async (taskInput: (typeof tasks)[0], index: number) => { const state = taskStates[index]; if (!state) return; const subagent = SUBAGENT_REGISTRY[taskInput.subagentType].agent; const result = await subagent.stream({ - prompt: "Complete this task and provide a summary of what you accomplished.", + prompt: + "Complete this task and provide a summary of what you accomplished.", options: { task: taskInput.task, instructions: taskInput.instructions, @@ -133,7 +138,10 @@ HOW TO USE: const promises = tasks.map((t, i) => runTask(t, i)); await Promise.all(promises); - const totalToolCallCount = taskStates.reduce((acc, t) => acc + t.toolCallCount, 0); + const totalToolCallCount = taskStates.reduce( + (acc, t) => acc + t.toolCallCount, + 0, + ); yield { results: taskStates, @@ -151,7 +159,9 @@ HOW TO USE: let contentValue = "Task completed without detailed summary."; if (messages) { - const lastAssistantMessage = messages.findLast((p) => p.role === "assistant"); + const lastAssistantMessage = messages.findLast( + (p) => p.role === "assistant", + ); const content = lastAssistantMessage?.content; if (content) { diff --git a/packages/agent/tools/github.ts b/packages/agent/tools/github.ts index 18fab21c..34c4018e 100644 --- a/packages/agent/tools/github.ts +++ b/packages/agent/tools/github.ts @@ -2,7 +2,8 @@ import { tool } from "ai"; import { z } from "zod"; import { getSandbox, shellEscape } from "./utils"; -const TIMEOUT_MS = 60_000; +// หมดเวลาในการดำเนินการคำสั่ง git และ gh +export const TIMEOUT_MS = 60_000; export const commitAndPrTool = tool({ description: `Commit all current changes, push to a new branch, and create a Pull Request using the GitHub CLI. @@ -18,15 +19,21 @@ USAGE: - Pushes the branch to origin - Uses the 'gh' CLI to create the PR`, inputSchema: z.object({ - needsApproval: true, - branchName: z.string().describe("The name of the new branch to create and push"), + needsApproval: z.literal(true), + branchName: z + .string() + .describe("The name of the new branch to create and push"), commitMessage: z.string().describe("The commit message"), prTitle: z.string().describe("The title of the pull request"), - prBody: z.string().optional().describe("The body/description of the pull request"), + prBody: z + .string() + .optional() + .describe("The body/description of the pull request"), }), + needsApproval: () => true, execute: async ( { branchName, commitMessage, prTitle, prBody }, - { experimental_context, abortSignal } + { experimental_context, abortSignal }, ) => { const sandbox = await getSandbox(experimental_context, "commitAndPr"); const cwd = sandbox.workingDirectory; @@ -37,26 +44,42 @@ USAGE: `git checkout -b ${shellEscape(branchName)}`, cwd, TIMEOUT_MS, - { signal: abortSignal } + { signal: abortSignal }, ); - if (!branchRes.success && !branchRes.stderr.includes("already exists") && !branchRes.stderr.includes("already a branch")) { - return { success: false, error: `Failed to create branch: ${branchRes.stderr}` }; + if ( + !branchRes.success && + !branchRes.stderr.includes("already exists") && + !branchRes.stderr.includes("already a branch") + ) { + return { + success: false, + error: `Failed to create branch: ${branchRes.stderr}`, + }; } // 2. Stage all changes - await sandbox.exec("git add -A", cwd, TIMEOUT_MS, { signal: abortSignal }); + await sandbox.exec("git add -A", cwd, TIMEOUT_MS, { + signal: abortSignal, + }); // 3. Commit changes const commitRes = await sandbox.exec( `git commit -m ${shellEscape(commitMessage)}`, cwd, TIMEOUT_MS, - { signal: abortSignal } + { signal: abortSignal }, ); - if (!commitRes.success && !commitRes.stdout.includes("nothing to commit") && !commitRes.stderr.includes("nothing to commit")) { - return { success: false, error: `Failed to commit: ${commitRes.stdout} ${commitRes.stderr}` }; + if ( + !commitRes.success && + !commitRes.stdout.includes("nothing to commit") && + !commitRes.stderr.includes("nothing to commit") + ) { + return { + success: false, + error: `Failed to commit: ${commitRes.stdout} ${commitRes.stderr}`, + }; } // 4. Push branch @@ -64,11 +87,14 @@ USAGE: `git push -u origin ${shellEscape(branchName)}`, cwd, TIMEOUT_MS, - { signal: abortSignal } + { signal: abortSignal }, ); if (!pushRes.success) { - return { success: false, error: `Failed to push: ${pushRes.stdout} ${pushRes.stderr}` }; + return { + success: false, + error: `Failed to push: ${pushRes.stdout} ${pushRes.stderr}`, + }; } // 5. Create PR via gh cli @@ -77,25 +103,28 @@ USAGE: `gh pr create --title ${shellEscape(prTitle)} ${bodyArg}`, cwd, TIMEOUT_MS, - { signal: abortSignal } + { signal: abortSignal }, ); if (prRes.success) { return { success: true, message: "Successfully committed, pushed, and created PR.", - prUrl: prRes.stdout.trim() + prUrl: prRes.stdout.trim(), }; } else { return { success: true, message: `Successfully committed and pushed to branch ${branchName}. Note: Could not create PR automatically via gh CLI. Please create it manually.`, - errorDetails: prRes.stderr + errorDetails: prRes.stderr, }; } } catch (error) { const message = error instanceof Error ? error.message : String(error); - return { success: false, error: `Error during commit and PR process: ${message}` }; + return { + success: false, + error: `Error during commit and PR process: ${message}`, + }; } }, }); diff --git a/packages/agent/tools/kilo.test.ts b/packages/agent/tools/kilo.test.ts index a204062d..ddb1889f 100644 --- a/packages/agent/tools/kilo.test.ts +++ b/packages/agent/tools/kilo.test.ts @@ -1,197 +1,214 @@ -import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test'; +import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; // Mock node-fetch -const fetchMock = mock(async (url: string, options: RequestInit) => { - return new Response(JSON.stringify({ jsonrpc: '2.0', result: { success: true }, id: 1 }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); +const fetchMock = mock(async (_url: string, _options: RequestInit) => { + return new Response( + JSON.stringify({ jsonrpc: "2.0", result: { success: true }, id: 1 }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); }); -mock.module('node-fetch', () => ({ - __esModule: true, - default: fetchMock, +mock.module("node-fetch", () => ({ + __esModule: true, + default: fetchMock, })); // Mock process.env const originalEnv = process.env; beforeEach(() => { - process.env = { ...originalEnv }; + process.env = { ...originalEnv }; }); afterEach(() => { - process.env = originalEnv; + process.env = originalEnv; }); // Now import the class to be tested -const { Context7Client } = await import('./kilo'); +const { Context7Client } = await import("./kilo"); + +describe("Context7Client", () => { + beforeEach(() => { + fetchMock.mockClear(); + }); + + describe("constructor", () => { + test("should use provided API key", () => { + const client = new Context7Client("test-api-key"); + expect(client).toBeDefined(); + }); + + test("should use environment variable if no key provided", () => { + process.env.CONTEXT7_API_KEY = "env-api-key"; + const client = new Context7Client(); + expect(client).toBeDefined(); + }); + + test("should throw error if no API key available", () => { + delete process.env.CONTEXT7_API_KEY; + expect(() => new Context7Client()).toThrow( + "Context7 API key is required. Provide it as parameter or set CONTEXT7_API_KEY environment variable.", + ); + }); + }); + + describe("call method", () => { + let client: Context7Client; -describe('Context7Client', () => { beforeEach(() => { - fetchMock.mockClear(); + process.env.CONTEXT7_API_KEY = "test-api-key"; + client = new Context7Client(); + }); + + test("should make POST request with correct headers and payload", async () => { + await client.call("test.method", { param: "value" }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, options] = fetchMock.mock.calls[0]; + + expect(url).toBe("https://mcp.context7.com/mcp"); + expect(options?.method).toBe("POST"); + expect(options?.headers).toEqual({ + CONTEXT7_API_KEY: "test-api-key", + "Content-Type": "application/json", + }); + + const body = JSON.parse(options?.body as string); + expect(body).toEqual({ + jsonrpc: "2.0", + method: "test.method", + id: 1, + params: { param: "value" }, + }); + }); + + test("should increment request ID for multiple calls", async () => { + await client.call("method1"); + await client.call("method2"); + await client.call("method3"); + + expect(fetchMock).toHaveBeenCalledTimes(3); + + const bodies = fetchMock.mock.calls.map(([_, options]) => + JSON.parse((options as RequestInit)?.body as string), + ); + + expect(bodies[0].id).toBe(1); + expect(bodies[1].id).toBe(2); + expect(bodies[2].id).toBe(3); + }); + + test("should handle empty params", async () => { + await client.call("test.method"); + + const [_, options] = fetchMock.mock.calls[0]; + const body = JSON.parse((options as RequestInit)?.body as string); + expect(body.params).toEqual({}); }); - describe('constructor', () => { - test('should use provided API key', () => { - const client = new Context7Client('test-api-key'); - expect(client).toBeDefined(); - }); - - test('should use environment variable if no key provided', () => { - process.env.CONTEXT7_API_KEY = 'env-api-key'; - const client = new Context7Client(); - expect(client).toBeDefined(); - }); - - test('should throw error if no API key available', () => { - delete process.env.CONTEXT7_API_KEY; - expect(() => new Context7Client()).toThrow('Context7 API key is required. Provide it as parameter or set CONTEXT7_API_KEY environment variable.'); - }); + test("should return parsed JSON response", async () => { + const mockResponse = { + jsonrpc: "2.0", + result: { tools: ["tool1", "tool2"] }, + id: 1, + }; + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(mockResponse)), + ); + + const result = await client.call("tools/list"); + expect(result).toEqual(mockResponse); }); - describe('call method', () => { - let client: Context7Client; - - beforeEach(() => { - process.env.CONTEXT7_API_KEY = 'test-api-key'; - client = new Context7Client(); - }); - - test('should make POST request with correct headers and payload', async () => { - await client.call('test.method', { param: 'value' }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, options] = fetchMock.mock.calls[0]; - - expect(url).toBe('https://mcp.context7.com/mcp'); - expect(options?.method).toBe('POST'); - expect(options?.headers).toEqual({ - 'CONTEXT7_API_KEY': 'test-api-key', - 'Content-Type': 'application/json', - }); - - const body = JSON.parse(options?.body as string); - expect(body).toEqual({ - jsonrpc: '2.0', - method: 'test.method', - id: 1, - params: { param: 'value' }, - }); - }); - - test('should increment request ID for multiple calls', async () => { - await client.call('method1'); - await client.call('method2'); - await client.call('method3'); - - expect(fetchMock).toHaveBeenCalledTimes(3); - - const bodies = fetchMock.mock.calls.map(([_, options]) => - JSON.parse((options as RequestInit)?.body as string) - ); - - expect(bodies[0].id).toBe(1); - expect(bodies[1].id).toBe(2); - expect(bodies[2].id).toBe(3); - }); - - test('should handle empty params', async () => { - await client.call('test.method'); - - const [_, options] = fetchMock.mock.calls[0]; - const body = JSON.parse((options as RequestInit)?.body as string); - expect(body.params).toEqual({}); - }); - - test('should return parsed JSON response', async () => { - const mockResponse = { - jsonrpc: '2.0', - result: { tools: ['tool1', 'tool2'] }, - id: 1 - }; - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockResponse))); - - const result = await client.call('tools/list'); - expect(result).toEqual(mockResponse); - }); - - test('should handle error responses', async () => { - const errorResponse = { - jsonrpc: '2.0', - error: { code: -32601, message: 'Method not found' }, - id: 1 - }; - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(errorResponse))); - - const result = await client.call('invalid.method'); - expect(result).toEqual(errorResponse); - }); - - test('should throw on HTTP errors', async () => { - fetchMock.mockResolvedValueOnce(new Response('Forbidden', { status: 403 })); - - await expect(client.call('tools/list')).rejects.toThrow( - 'Context7 API call failed with status 403: Forbidden' - ); - }); - - test('should handle network errors', async () => { - fetchMock.mockRejectedValueOnce(new Error('Network timeout')); - - await expect(client.call('tools/list')).rejects.toThrow('Network timeout'); - }); - - test('should handle malformed JSON response', async () => { - fetchMock.mockResolvedValueOnce(new Response('invalid json', { - status: 200, - headers: { 'Content-Type': 'application/json' } - })); - - await expect(client.call('tools/list')).rejects.toThrow(); - }); + test("should handle error responses", async () => { + const errorResponse = { + jsonrpc: "2.0", + error: { code: -32601, message: "Method not found" }, + id: 1, + }; + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(errorResponse)), + ); + + const result = await client.call("invalid.method"); + expect(result).toEqual(errorResponse); }); - describe('integration scenarios', () => { - let client: Context7Client; - - beforeEach(() => { - process.env.CONTEXT7_API_KEY = 'test-api-key'; - client = new Context7Client(); - }); - - test('should handle tools/list call', async () => { - const mockResponse = { - jsonrpc: '2.0', - result: { - tools: [ - { name: 'query-docs', description: 'Query documentation' }, - { name: 'resolve-library-id', description: 'Resolve library ID' } - ] - }, - id: 1 - }; - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockResponse))); - - const result = await client.call('tools/list'); - expect(result.result.tools).toHaveLength(2); - expect(result.result.tools[0].name).toBe('query-docs'); - }); - - test('should handle query-docs call with complex params', async () => { - const params = { - libraryId: '/reactjs/react.dev', - query: 'how to use useState hook' - }; - - await client.call('tools/call', { - name: 'query-docs', - arguments: params - }); - - const [_, options] = fetchMock.mock.calls[0]; - const body = JSON.parse((options as RequestInit)?.body as string); - - expect(body.params.name).toBe('query-docs'); - expect(body.params.arguments.libraryId).toBe('/reactjs/react.dev'); - }); + test("should throw on HTTP errors", async () => { + fetchMock.mockResolvedValueOnce( + new Response("Forbidden", { status: 403 }), + ); + + await expect(client.call("tools/list")).rejects.toThrow( + "Context7 API call failed with status 403: Forbidden", + ); + }); + + test("should handle network errors", async () => { + fetchMock.mockRejectedValueOnce(new Error("Network timeout")); + + await expect(client.call("tools/list")).rejects.toThrow( + "Network timeout", + ); + }); + + test("should handle malformed JSON response", async () => { + fetchMock.mockResolvedValueOnce( + new Response("invalid json", { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect(client.call("tools/list")).rejects.toThrow(); + }); + }); + + describe("integration scenarios", () => { + let client: Context7Client; + + beforeEach(() => { + process.env.CONTEXT7_API_KEY = "test-api-key"; + client = new Context7Client(); + }); + + test("should handle tools/list call", async () => { + const mockResponse = { + jsonrpc: "2.0", + result: { + tools: [ + { name: "query-docs", description: "Query documentation" }, + { name: "resolve-library-id", description: "Resolve library ID" }, + ], + }, + id: 1, + }; + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(mockResponse)), + ); + + const result = await client.call("tools/list"); + expect(result.result.tools).toHaveLength(2); + expect(result.result.tools[0].name).toBe("query-docs"); + }); + + test("should handle query-docs call with complex params", async () => { + const params = { + libraryId: "/reactjs/react.dev", + query: "how to use useState hook", + }; + + await client.call("tools/call", { + name: "query-docs", + arguments: params, + }); + + const [_, options] = fetchMock.mock.calls[0]; + const body = JSON.parse((options as RequestInit)?.body as string); + + expect(body.params.name).toBe("query-docs"); + expect(body.params.arguments.libraryId).toBe("/reactjs/react.dev"); }); + }); }); diff --git a/packages/agent/tools/kilo.ts b/packages/agent/tools/kilo.ts index 42838f38..36a73857 100644 --- a/packages/agent/tools/kilo.ts +++ b/packages/agent/tools/kilo.ts @@ -1,43 +1,50 @@ -import fetch from 'node-fetch'; +import fetch from "node-fetch"; class Context7Client { - private url: string = 'https://mcp.context7.com/mcp'; - private headers: Record; - private id: number = 1; - - constructor(apiKey?: string) { - const key = apiKey || process.env.CONTEXT7_API_KEY; - if (!key) { - throw new Error('Context7 API key is required. Provide it as parameter or set CONTEXT7_API_KEY environment variable.'); - } - this.headers = { - 'CONTEXT7_API_KEY': key, - 'Content-Type': 'application/json' - }; + private url: string = "https://mcp.context7.com/mcp"; + private headers: Record; + private id: number = 1; + + constructor(apiKey?: string) { + const key = apiKey || process.env.CONTEXT7_API_KEY; + if (!key) { + throw new Error( + "Context7 API key is required. Provide it as parameter or set CONTEXT7_API_KEY environment variable.", + ); } - - async call(method: string, params: Record = {}): Promise { - const payload = { - jsonrpc: '2.0', - method, - id: this.id, - params - }; - this.id++; - - const response = await fetch(this.url, { - method: 'POST', - headers: this.headers, - body: JSON.stringify(payload) - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Context7 API call failed with status ${response.status}: ${errorText}`); - } - - return await response.json(); + this.headers = { + CONTEXT7_API_KEY: key, + "Content-Type": "application/json", + }; + } + + async call( + method: string, + params: Record = {}, + ): Promise { + const payload = { + jsonrpc: "2.0", + method, + id: this.id, + params, + }; + this.id++; + + const response = await fetch(this.url, { + method: "POST", + headers: this.headers, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Context7 API call failed with status ${response.status}: ${errorText}`, + ); } + + return await response.json(); + } } // Usage example diff --git a/packages/agent/tools/lsp.test.ts b/packages/agent/tools/lsp.test.ts index 9a3de129..afb4c302 100644 --- a/packages/agent/tools/lsp.test.ts +++ b/packages/agent/tools/lsp.test.ts @@ -1,346 +1,447 @@ -import { describe, test, expect, mock, beforeEach } from 'bun:test'; +import { describe, test, expect, mock, beforeEach } from "bun:test"; // Mock LSP dependencies const mockLspClientManager = { - runWithClientLease: mock(async (filePath: string, fn: any) => { - const mockClient = { - hover: mock(async () => ({ - contents: [{ kind: 'markdown', value: 'Test hover content' }], - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } } - })), - definition: mock(async () => ([{ - uri: 'file:///test/file.ts', - range: { start: { line: 5, character: 0 }, end: { line: 5, character: 15 } } - }])), - references: mock(async () => ([{ - uri: 'file:///test/file.ts', - range: { start: { line: 10, character: 0 }, end: { line: 10, character: 10 } } - }])), - documentSymbols: mock(async () => ([{ - name: 'testFunction', - kind: 12, // Function - range: { start: { line: 5, character: 0 }, end: { line: 5, character: 20 } }, - selectionRange: { start: { line: 5, character: 9 }, end: { line: 5, character: 21 } } - }])), - workspaceSymbols: mock(async () => ([{ - name: 'TestClass', - kind: 5, // Class - location: { - uri: 'file:///test/class.ts', - range: { start: { line: 1, character: 0 }, end: { line: 1, character: 10 } } - } - }])), - openDocument: mock(async () => {}), - waitForDiagnostics: mock(async () => {}), - getDiagnostics: mock(() => ([{ - range: { start: { line: 1, character: 5 }, end: { line: 1, character: 10 } }, - severity: 1, - message: 'Test error', - source: 'test' - }])), - prepareRename: mock(async () => ({ + runWithClientLease: mock(async (filePath: string, fn: unknown) => { + const mockClient = { + hover: mock(async () => ({ + contents: [{ kind: "markdown", value: "Test hover content" }], + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + })), + definition: mock(async () => [ + { + uri: "file:///test/file.ts", + range: { + start: { line: 5, character: 0 }, + end: { line: 5, character: 15 }, + }, + }, + ]), + references: mock(async () => [ + { + uri: "file:///test/file.ts", + range: { + start: { line: 10, character: 0 }, + end: { line: 10, character: 10 }, + }, + }, + ]), + documentSymbols: mock(async () => [ + { + name: "testFunction", + kind: 12, // Function + range: { + start: { line: 5, character: 0 }, + end: { line: 5, character: 20 }, + }, + selectionRange: { + start: { line: 5, character: 9 }, + end: { line: 5, character: 21 }, + }, + }, + ]), + workspaceSymbols: mock(async () => [ + { + name: "TestClass", + kind: 5, // Class + location: { + uri: "file:///test/class.ts", + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 10 }, + }, + }, + }, + ]), + openDocument: mock(async () => {}), + waitForDiagnostics: mock(async () => {}), + getDiagnostics: mock(() => [ + { + range: { + start: { line: 1, character: 5 }, + end: { line: 1, character: 10 }, + }, + severity: 1, + message: "Test error", + source: "test", + }, + ]), + prepareRename: mock(async () => ({ + start: { line: 1, character: 0 }, + end: { line: 1, character: 5 }, + })), + rename: mock(async () => ({ + changes: { + "file:///test/file.ts": [ + { + range: { start: { line: 1, character: 0 }, - end: { line: 1, character: 5 } - })), - rename: mock(async () => ({ - changes: { - 'file:///test/file.ts': [{ - range: { start: { line: 1, character: 0 }, end: { line: 1, character: 5 } }, - newText: 'newName' - }] - } - })), - codeActions: mock(async () => ([{ - title: 'Extract function', - kind: 'refactor.extract', - edit: { - changes: { - 'file:///test/file.ts': [{ - range: { start: { line: 2, character: 0 }, end: { line: 4, character: 0 } }, - newText: 'extractedFunction() {\n // code\n}' - }] - } - } - }])), - supportsPullDiagnostics: false - }; - return await fn(mockClient); - }) + end: { line: 1, character: 5 }, + }, + newText: "newName", + }, + ], + }, + })), + codeActions: mock(async () => [ + { + title: "Extract function", + kind: "refactor.extract", + edit: { + changes: { + "file:///test/file.ts": [ + { + range: { + start: { line: 2, character: 0 }, + end: { line: 4, character: 0 }, + }, + newText: "extractedFunction() {\n // code\n}", + }, + ], + }, + }, + }, + ]), + supportsPullDiagnostics: false, + }; + return await fn(mockClient); + }), }; -const mockGetAllServers = mock(() => ([ - { name: 'typescript', command: 'tsserver', extensions: ['.ts', '.tsx'], installed: true }, - { name: 'python', command: 'pylsp', extensions: ['.py'], installed: false, installHint: 'pip install python-lsp-server' } -])); +const mockGetAllServers = mock(() => [ + { + name: "typescript", + command: "tsserver", + extensions: [".ts", ".tsx"], + installed: true, + }, + { + name: "python", + command: "pylsp", + extensions: [".py"], + installed: false, + installHint: "pip install python-lsp-server", + }, +]); const mockGetServerForFile = mock((filePath: string) => { - if (filePath.endsWith('.ts')) { - return { name: 'typescript', command: 'tsserver', extensions: ['.ts', '.tsx'], installed: true }; - } - return null; + if (filePath.endsWith(".ts")) { + return { + name: "typescript", + command: "tsserver", + extensions: [".ts", ".tsx"], + installed: true, + }; + } + return null; }); -const mockFormatHover = mock((hover: any) => 'Formatted hover: Test hover content'); -const mockFormatLocations = mock((locations: any) => 'Found 1 location(s)'); -const mockFormatDocumentSymbols = mock((symbols: any) => '1 symbol(s) found'); -const mockFormatWorkspaceSymbols = mock((symbols: any) => 'Found 1 symbol(s)'); -const mockFormatDiagnostics = mock((diagnostics: any) => 'Found 1 diagnostic(s)'); -const mockFormatCodeActions = mock((actions: any) => 'Found 1 code action(s)'); -const mockFormatWorkspaceEdit = mock((edit: any) => 'Workspace edit applied'); +const mockFormatHover = mock( + (_hover: unknown) => "Formatted hover: Test hover content", +); +const mockFormatLocations = mock( + (_locations: unknown) => "Found 1 location(s)", +); +const mockFormatDocumentSymbols = mock( + (_symbols: unknown) => "1 symbol(s) found", +); +const mockFormatWorkspaceSymbols = mock( + (_symbols: unknown) => "Found 1 symbol(s)", +); +const mockFormatDiagnostics = mock( + (_diagnostics: unknown) => "Found 1 diagnostic(s)", +); +const mockFormatCodeActions = mock( + (_actions: unknown) => "Found 1 code action(s)", +); +const mockFormatWorkspaceEdit = mock( + (_edit: unknown) => "Workspace edit applied", +); const mockCountEdits = mock(() => ({ files: 1, edits: 1 })); -mock.module('./lsp/index.js', () => ({ - lspClientManager: mockLspClientManager, - getAllServers: mockGetAllServers, - getServerForFile: mockGetServerForFile, - formatHover: mockFormatHover, - formatLocations: mockFormatLocations, - formatDocumentSymbols: mockFormatDocumentSymbols, - formatWorkspaceSymbols: mockFormatWorkspaceSymbols, - formatDiagnostics: mockFormatDiagnostics, - formatCodeActions: mockFormatCodeActions, - formatWorkspaceEdit: mockFormatWorkspaceEdit, - countEdits: mockCountEdits +mock.module("./lsp/index.js", () => ({ + lspClientManager: mockLspClientManager, + getAllServers: mockGetAllServers, + getServerForFile: mockGetServerForFile, + formatHover: mockFormatHover, + formatLocations: mockFormatLocations, + formatDocumentSymbols: mockFormatDocumentSymbols, + formatWorkspaceSymbols: mockFormatWorkspaceSymbols, + formatDiagnostics: mockFormatDiagnostics, + formatCodeActions: mockFormatCodeActions, + formatWorkspaceEdit: mockFormatWorkspaceEdit, + countEdits: mockCountEdits, })); -mock.module('./diagnostics/index.js', () => ({ - runDirectoryDiagnostics: mock(async () => ({ - strategy: 'tsc', - summary: '2 errors, 1 warning', - diagnostics: 'Error: test error\nWarning: test warning' - })) +mock.module("./diagnostics/index.js", () => ({ + runDirectoryDiagnostics: mock(async () => ({ + strategy: "tsc", + summary: "2 errors, 1 warning", + diagnostics: "Error: test error\nWarning: test warning", + })), })); // Import after mocking -const { lspTools } = await import('./lsp'); - -describe('LSP Tools', () => { - beforeEach(() => { - // Reset all mocks - mockLspClientManager.runWithClientLease.mockClear(); - mockGetAllServers.mockClear(); - mockGetServerForFile.mockClear(); - Object.values({ - mockFormatHover, mockFormatLocations, mockFormatDocumentSymbols, - mockFormatWorkspaceSymbols, mockFormatDiagnostics, mockFormatCodeActions, - mockFormatWorkspaceEdit, mockCountEdits - }).forEach(mock => mock.mockClear()); +const { lspTools } = await import("./lsp"); + +describe("LSP Tools", () => { + beforeEach(() => { + // Reset all mocks + mockLspClientManager.runWithClientLease.mockClear(); + mockGetAllServers.mockClear(); + mockGetServerForFile.mockClear(); + Object.values({ + mockFormatHover, + mockFormatLocations, + mockFormatDocumentSymbols, + mockFormatWorkspaceSymbols, + mockFormatDiagnostics, + mockFormatCodeActions, + mockFormatWorkspaceEdit, + mockCountEdits, + }).forEach((mock) => mock.mockClear()); + }); + + test("should export lspTools array with expected tools", () => { + expect(Array.isArray(lspTools)).toBe(true); + expect(lspTools.length).toBe(12); // All LSP tools + + const toolNames = lspTools.map((t) => t.name); + expect(toolNames).toEqual([ + "lsp_hover", + "lsp_goto_definition", + "lsp_find_references", + "lsp_document_symbols", + "lsp_workspace_symbols", + "lsp_diagnostics", + "lsp_diagnostics_directory", + "lsp_servers", + "lsp_prepare_rename", + "lsp_rename", + "lsp_code_actions", + "lsp_code_action_resolve", + ]); + }); + + test("each tool should have required properties with correct types", () => { + for (const tool of lspTools) { + expect(tool).toHaveProperty("name"); + expect(tool).toHaveProperty("description"); + expect(tool).toHaveProperty("schema"); + expect(tool).toHaveProperty("handler"); + + expect(typeof tool.name).toBe("string"); + expect(typeof tool.description).toBe("string"); + expect(typeof tool.handler).toBe("function"); + + // Schema should be an object with properties + expect(typeof tool.schema).toBe("object"); + } + }); + + describe("lsp_hover tool", () => { + test("should call LSP hover and format result", async () => { + const result = await lspTools[0].handler({ + file: "test.ts", + line: 1, + character: 5, + }); + + expect(mockLspClientManager.runWithClientLease).toHaveBeenCalledWith( + "test.ts", + expect.any(Function), + ); + expect(mockFormatHover).toHaveBeenCalled(); + expect(result.content[0].text).toBe( + "Formatted hover: Test hover content", + ); }); - - test('should export lspTools array with expected tools', () => { - expect(Array.isArray(lspTools)).toBe(true); - expect(lspTools.length).toBe(12); // All LSP tools - - const toolNames = lspTools.map(t => t.name); - expect(toolNames).toEqual([ - 'lsp_hover', - 'lsp_goto_definition', - 'lsp_find_references', - 'lsp_document_symbols', - 'lsp_workspace_symbols', - 'lsp_diagnostics', - 'lsp_diagnostics_directory', - 'lsp_servers', - 'lsp_prepare_rename', - 'lsp_rename', - 'lsp_code_actions', - 'lsp_code_action_resolve' - ]); + }); + + describe("lsp_goto_definition tool", () => { + test("should find definition locations", async () => { + const result = await lspTools[1].handler({ + file: "test.ts", + line: 1, + character: 5, + }); + + expect(mockFormatLocations).toHaveBeenCalledWith([ + { + uri: "file:///test/file.ts", + range: { + start: { line: 5, character: 0 }, + end: { line: 5, character: 15 }, + }, + }, + ]); + expect(result.content[0].text).toBe("Found 1 location(s)"); }); - - test('each tool should have required properties with correct types', () => { - for (const tool of lspTools) { - expect(tool).toHaveProperty('name'); - expect(tool).toHaveProperty('description'); - expect(tool).toHaveProperty('schema'); - expect(tool).toHaveProperty('handler'); - - expect(typeof tool.name).toBe('string'); - expect(typeof tool.description).toBe('string'); - expect(typeof tool.handler).toBe('function'); - - // Schema should be an object with properties - expect(typeof tool.schema).toBe('object'); - } + }); + + describe("lsp_find_references tool", () => { + test("should find references with includeDeclaration true", async () => { + const result = await lspTools[2].handler({ + file: "test.ts", + line: 1, + character: 5, + includeDeclaration: true, + }); + + expect(result.content[0].text).toBe( + "Found 1 reference(s):\n\nFound 1 location(s)", + ); }); - describe('lsp_hover tool', () => { - test('should call LSP hover and format result', async () => { - const result = await lspTools[0].handler({ - file: 'test.ts', - line: 1, - character: 5 - }); - - expect(mockLspClientManager.runWithClientLease).toHaveBeenCalledWith( - 'test.ts', - expect.any(Function) - ); - expect(mockFormatHover).toHaveBeenCalled(); - expect(result.content[0].text).toBe('Formatted hover: Test hover content'); - }); + test("should handle no references found", async () => { + mockLspClientManager.runWithClientLease.mockImplementationOnce( + async (filePath, fn) => { + const mockClient = { + references: mock(async () => []), + }; + return await fn(mockClient); + }, + ); + + const result = await lspTools[2].handler({ + file: "test.ts", + line: 1, + character: 5, + }); + + expect(result.content[0].text).toBe("No references found"); }); + }); - describe('lsp_goto_definition tool', () => { - test('should find definition locations', async () => { - const result = await lspTools[1].handler({ - file: 'test.ts', - line: 1, - character: 5 - }); - - expect(mockFormatLocations).toHaveBeenCalledWith([{ - uri: 'file:///test/file.ts', - range: { start: { line: 5, character: 0 }, end: { line: 5, character: 15 } } - }]); - expect(result.content[0].text).toBe('Found 1 location(s)'); - }); - }); + describe("lsp_document_symbols tool", () => { + test("should get document symbols", async () => { + const result = await lspTools[3].handler({ + file: "test.ts", + }); - describe('lsp_find_references tool', () => { - test('should find references with includeDeclaration true', async () => { - const result = await lspTools[2].handler({ - file: 'test.ts', - line: 1, - character: 5, - includeDeclaration: true - }); - - expect(result.content[0].text).toBe('Found 1 reference(s):\n\nFound 1 location(s)'); - }); - - test('should handle no references found', async () => { - mockLspClientManager.runWithClientLease.mockImplementationOnce(async (filePath, fn) => { - const mockClient = { - references: mock(async () => []) - }; - return await fn(mockClient); - }); - - const result = await lspTools[2].handler({ - file: 'test.ts', - line: 1, - character: 5 - }); - - expect(result.content[0].text).toBe('No references found'); - }); + expect(mockFormatDocumentSymbols).toHaveBeenCalled(); + expect(result.content[0].text).toBe("1 symbol(s) found"); }); - - describe('lsp_document_symbols tool', () => { - test('should get document symbols', async () => { - const result = await lspTools[3].handler({ - file: 'test.ts' - }); - - expect(mockFormatDocumentSymbols).toHaveBeenCalled(); - expect(result.content[0].text).toBe('1 symbol(s) found'); - }); + }); + + describe("lsp_workspace_symbols tool", () => { + test("should search workspace symbols", async () => { + const result = await lspTools[4].handler({ + query: "TestClass", + file: "test.ts", + }); + + expect(result.content[0].text).toBe( + 'Found 1 symbol(s) matching "TestClass":\n\nFound 1 symbol(s)', + ); }); - describe('lsp_workspace_symbols tool', () => { - test('should search workspace symbols', async () => { - const result = await lspTools[4].handler({ - query: 'TestClass', - file: 'test.ts' - }); - - expect(result.content[0].text).toBe('Found 1 symbol(s) matching "TestClass":\n\nFound 1 symbol(s)'); - }); - - test('should handle no symbols found', async () => { - mockLspClientManager.runWithClientLease.mockImplementationOnce(async (filePath, fn) => { - const mockClient = { - workspaceSymbols: mock(async () => []) - }; - return await fn(mockClient); - }); - - const result = await lspTools[4].handler({ - query: 'UnknownSymbol', - file: 'test.ts' - }); - - expect(result.content[0].text).toBe('No symbols found matching: UnknownSymbol'); - }); + test("should handle no symbols found", async () => { + mockLspClientManager.runWithClientLease.mockImplementationOnce( + async (filePath, fn) => { + const mockClient = { + workspaceSymbols: mock(async () => []), + }; + return await fn(mockClient); + }, + ); + + const result = await lspTools[4].handler({ + query: "UnknownSymbol", + file: "test.ts", + }); + + expect(result.content[0].text).toBe( + "No symbols found matching: UnknownSymbol", + ); }); + }); - describe('lsp_diagnostics tool', () => { - test('should get diagnostics with severity filter', async () => { - const result = await lspTools[5].handler({ - file: 'test.ts', - severity: 'error' - }); + describe("lsp_diagnostics tool", () => { + test("should get diagnostics with severity filter", async () => { + const result = await lspTools[5].handler({ + file: "test.ts", + severity: "error", + }); - expect(result.content[0].text).toContain('Found 1 diagnostic(s)'); - }); + expect(result.content[0].text).toContain("Found 1 diagnostic(s)"); }); + }); - describe('lsp_servers tool', () => { - test('should list all language servers', async () => { - const result = await lspTools[7].handler({}); + describe("lsp_servers tool", () => { + test("should list all language servers", async () => { + const result = await lspTools[7].handler({}); - expect(mockGetAllServers).toHaveBeenCalled(); - expect(result.content[0].text).toContain('Language Server Status'); - expect(result.content[0].text).toContain('typescript'); - expect(result.content[0].text).toContain('python'); - }); + expect(mockGetAllServers).toHaveBeenCalled(); + expect(result.content[0].text).toContain("Language Server Status"); + expect(result.content[0].text).toContain("typescript"); + expect(result.content[0].text).toContain("python"); }); - - describe('lsp_rename tool', () => { - test('should perform rename and show edit summary', async () => { - const result = await lspTools[9].handler({ - file: 'test.ts', - line: 1, - character: 5, - newName: 'newFunctionName' - }); - - expect(mockCountEdits).toHaveBeenCalled(); - expect(result.content[0].text).toContain('Rename to "newFunctionName" would affect 1 file(s) with 1 edit(s)'); - }); + }); + + describe("lsp_rename tool", () => { + test("should perform rename and show edit summary", async () => { + const result = await lspTools[9].handler({ + file: "test.ts", + line: 1, + character: 5, + newName: "newFunctionName", + }); + + expect(mockCountEdits).toHaveBeenCalled(); + expect(result.content[0].text).toContain( + 'Rename to "newFunctionName" would affect 1 file(s) with 1 edit(s)', + ); }); - - describe('lsp_code_actions tool', () => { - test('should get code actions for selection', async () => { - const result = await lspTools[10].handler({ - file: 'test.ts', - startLine: 1, - startCharacter: 0, - endLine: 3, - endCharacter: 10 - }); - - expect(mockFormatCodeActions).toHaveBeenCalled(); - expect(result.content[0].text).toBe('Found 1 code action(s)'); - }); + }); + + describe("lsp_code_actions tool", () => { + test("should get code actions for selection", async () => { + const result = await lspTools[10].handler({ + file: "test.ts", + startLine: 1, + startCharacter: 0, + endLine: 3, + endCharacter: 10, + }); + + expect(mockFormatCodeActions).toHaveBeenCalled(); + expect(result.content[0].text).toBe("Found 1 code action(s)"); }); + }); - describe('error handling', () => { - test('should handle unsupported file type', async () => { - mockGetServerForFile.mockReturnValueOnce(null); - - const result = await lspTools[0].handler({ - file: 'test.unknown', - line: 1, - character: 5 - }); + describe("error handling", () => { + test("should handle unsupported file type", async () => { + mockGetServerForFile.mockReturnValueOnce(null); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('No language server available'); - }); + const result = await lspTools[0].handler({ + file: "test.unknown", + line: 1, + character: 5, + }); - test('should handle LSP client errors', async () => { - mockLspClientManager.runWithClientLease.mockRejectedValueOnce(new Error('LSP connection failed')); - - const result = await lspTools[0].handler({ - file: 'test.ts', - line: 1, - character: 5 - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("No language server available"); + }); - expect(result.isError).toBe(true); - expect(result.content[0].text).toBe('Error in hover: LSP connection failed'); - }); + test("should handle LSP client errors", async () => { + mockLspClientManager.runWithClientLease.mockRejectedValueOnce( + new Error("LSP connection failed"), + ); + + const result = await lspTools[0].handler({ + file: "test.ts", + line: 1, + character: 5, + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe( + "Error in hover: LSP connection failed", + ); }); -}); \ No newline at end of file + }); +}); diff --git a/packages/agent/tools/lsp.ts b/packages/agent/tools/lsp.ts index a31919a3..cb11221e 100644 --- a/packages/agent/tools/lsp.ts +++ b/packages/agent/tools/lsp.ts @@ -11,7 +11,7 @@ * - Code actions */ -import { z } from 'zod'; +import { z } from "zod"; import { lspClientManager, getAllServers, @@ -23,10 +23,10 @@ import { formatDiagnostics, formatCodeActions, formatWorkspaceEdit, - countEdits -} from './lsp/index.js'; -import { runDirectoryDiagnostics } from './diagnostics/index.js'; -import { ToolDefinition } from './types.js'; + countEdits, +} from "./lsp/index.js"; +import { runDirectoryDiagnostics } from "./diagnostics/index.js"; +import { ToolDefinition } from "./types.js"; /** * Helper to handle LSP errors gracefully. @@ -36,48 +36,63 @@ import { ToolDefinition } from './types.js'; async function withLspClient( filePath: string, operation: string, - fn: (client: NonNullable>>) => Promise -): Promise<{ isError?: true; content: Array<{ type: 'text'; text: string }> }> { + fn: ( + client: NonNullable< + Awaited> + >, + ) => Promise, +): Promise<{ isError?: true; content: Array<{ type: "text"; text: string }> }> { try { // Pre-check: is there a server for this file type? const serverConfig = getServerForFile(filePath); if (!serverConfig) { return { isError: true as const, - content: [{ - type: 'text' as const, - text: `No language server available for file type: ${filePath}\n\nUse lsp_servers tool to see available language servers.` - }] + content: [ + { + type: "text" as const, + text: `No language server available for file type: ${filePath}\n\nUse lsp_servers tool to see available language servers.`, + }, + ], }; } - const result = await lspClientManager.runWithClientLease(filePath, async (client) => { - return fn(client); - }); + const result = await lspClientManager.runWithClientLease( + filePath, + async (client) => { + return fn(client); + }, + ); return { - content: [{ - type: 'text' as const, - text: String(result) - }] + content: [ + { + type: "text" as const, + text: String(result), + }, + ], }; } catch (error) { const message = error instanceof Error ? error.message : String(error); // Surface install hints for missing servers - if (message.includes('not found')) { + if (message.includes("not found")) { return { isError: true as const, - content: [{ - type: 'text' as const, - text: `${message}` - }] + content: [ + { + type: "text" as const, + text: `${message}`, + }, + ], }; } return { isError: true as const, - content: [{ - type: 'text' as const, - text: `Error in ${operation}: ${message}` - }] + content: [ + { + type: "text" as const, + text: `Error in ${operation}: ${message}`, + }, + ], }; } } @@ -90,20 +105,25 @@ export const lspHoverTool: ToolDefinition<{ line: z.ZodNumber; character: z.ZodNumber; }> = { - name: 'lsp_hover', - description: 'Get type information, documentation, and signature at a specific position in a file. Useful for understanding what a symbol represents.', + name: "lsp_hover", + description: + "Get type information, documentation, and signature at a specific position in a file. Useful for understanding what a symbol represents.", schema: { - file: z.string().describe('Path to the source file'), - line: z.number().int().min(1).describe('Line number (1-indexed)'), - character: z.number().int().min(0).describe('Character position in the line (0-indexed)') + file: z.string().describe("Path to the source file"), + line: z.number().int().min(1).describe("Line number (1-indexed)"), + character: z + .number() + .int() + .min(0) + .describe("Character position in the line (0-indexed)"), }, handler: async (args) => { const { file, line, character } = args; - return withLspClient(file, 'hover', async (client) => { + return withLspClient(file, "hover", async (client) => { const hover = await client!.hover(file, line - 1, character); return formatHover(hover); }); - } + }, }; /** @@ -114,20 +134,25 @@ export const lspGotoDefinitionTool: ToolDefinition<{ line: z.ZodNumber; character: z.ZodNumber; }> = { - name: 'lsp_goto_definition', - description: 'Find the definition location of a symbol (function, variable, class, etc.). Returns the file path and position where the symbol is defined.', + name: "lsp_goto_definition", + description: + "Find the definition location of a symbol (function, variable, class, etc.). Returns the file path and position where the symbol is defined.", schema: { - file: z.string().describe('Path to the source file'), - line: z.number().int().min(1).describe('Line number (1-indexed)'), - character: z.number().int().min(0).describe('Character position in the line (0-indexed)') + file: z.string().describe("Path to the source file"), + line: z.number().int().min(1).describe("Line number (1-indexed)"), + character: z + .number() + .int() + .min(0) + .describe("Character position in the line (0-indexed)"), }, handler: async (args) => { const { file, line, character } = args; - return withLspClient(file, 'goto definition', async (client) => { + return withLspClient(file, "goto definition", async (client) => { const locations = await client!.definition(file, line - 1, character); return formatLocations(locations); }); - } + }, }; /** @@ -139,24 +164,37 @@ export const lspFindReferencesTool: ToolDefinition<{ character: z.ZodNumber; includeDeclaration: z.ZodOptional; }> = { - name: 'lsp_find_references', - description: 'Find all references to a symbol across the codebase. Useful for understanding usage patterns and impact of changes.', + name: "lsp_find_references", + description: + "Find all references to a symbol across the codebase. Useful for understanding usage patterns and impact of changes.", schema: { - file: z.string().describe('Path to the source file'), - line: z.number().int().min(1).describe('Line number (1-indexed)'), - character: z.number().int().min(0).describe('Character position in the line (0-indexed)'), - includeDeclaration: z.boolean().optional().describe('Include the declaration in results (default: true)') + file: z.string().describe("Path to the source file"), + line: z.number().int().min(1).describe("Line number (1-indexed)"), + character: z + .number() + .int() + .min(0) + .describe("Character position in the line (0-indexed)"), + includeDeclaration: z + .boolean() + .optional() + .describe("Include the declaration in results (default: true)"), }, handler: async (args) => { const { file, line, character, includeDeclaration = true } = args; - return withLspClient(file, 'find references', async (client) => { - const locations = await client!.references(file, line - 1, character, includeDeclaration); + return withLspClient(file, "find references", async (client) => { + const locations = await client!.references( + file, + line - 1, + character, + includeDeclaration, + ); if (!locations || locations.length === 0) { - return 'No references found'; + return "No references found"; } return `Found ${locations.length} reference(s):\n\n${formatLocations(locations)}`; }); - } + }, }; /** @@ -165,18 +203,19 @@ export const lspFindReferencesTool: ToolDefinition<{ export const lspDocumentSymbolsTool: ToolDefinition<{ file: z.ZodString; }> = { - name: 'lsp_document_symbols', - description: 'Get a hierarchical outline of all symbols in a file (functions, classes, variables, etc.). Useful for understanding file structure.', + name: "lsp_document_symbols", + description: + "Get a hierarchical outline of all symbols in a file (functions, classes, variables, etc.). Useful for understanding file structure.", schema: { - file: z.string().describe('Path to the source file') + file: z.string().describe("Path to the source file"), }, handler: async (args) => { const { file } = args; - return withLspClient(file, 'document symbols', async (client) => { + return withLspClient(file, "document symbols", async (client) => { const symbols = await client!.documentSymbols(file); return formatDocumentSymbols(symbols); }); - } + }, }; /** @@ -186,22 +225,27 @@ export const lspWorkspaceSymbolsTool: ToolDefinition<{ query: z.ZodString; file: z.ZodString; }> = { - name: 'lsp_workspace_symbols', - description: 'Search for symbols (functions, classes, etc.) across the entire workspace by name. Useful for finding definitions without knowing the exact file.', + name: "lsp_workspace_symbols", + description: + "Search for symbols (functions, classes, etc.) across the entire workspace by name. Useful for finding definitions without knowing the exact file.", schema: { - query: z.string().describe('Symbol name or pattern to search'), - file: z.string().describe('Any file in the workspace (used to determine which language server to use)') + query: z.string().describe("Symbol name or pattern to search"), + file: z + .string() + .describe( + "Any file in the workspace (used to determine which language server to use)", + ), }, handler: async (args) => { const { query, file } = args; - return withLspClient(file, 'workspace symbols', async (client) => { + return withLspClient(file, "workspace symbols", async (client) => { const symbols = await client!.workspaceSymbols(query); if (!symbols || symbols.length === 0) { return `No symbols found matching: ${query}`; } return `Found ${symbols.length} symbol(s) matching "${query}":\n\n${formatWorkspaceSymbols(symbols)}`; }); - } + }, }; /** @@ -209,17 +253,21 @@ export const lspWorkspaceSymbolsTool: ToolDefinition<{ */ export const lspDiagnosticsTool: ToolDefinition<{ file: z.ZodString; - severity: z.ZodOptional>; + severity: z.ZodOptional>; }> = { - name: 'lsp_diagnostics', - description: 'Get language server diagnostics (errors, warnings, hints) for a file. Useful for finding issues without running the compiler.', + name: "lsp_diagnostics", + description: + "Get language server diagnostics (errors, warnings, hints) for a file. Useful for finding issues without running the compiler.", schema: { - file: z.string().describe('Path to the source file'), - severity: z.enum(['error', 'warning', 'info', 'hint']).optional().describe('Filter by severity level') + file: z.string().describe("Path to the source file"), + severity: z + .enum(["error", "warning", "info", "hint"]) + .optional() + .describe("Filter by severity level"), }, handler: async (args) => { const { file, severity } = args; - return withLspClient(file, 'diagnostics', async (client) => { + return withLspClient(file, "diagnostics", async (client) => { await client!.openDocument(file); let diagnostics; @@ -232,13 +280,13 @@ export const lspDiagnosticsTool: ToolDefinition<{ if (severity) { const severityMap: Record = { - 'error': 1, - 'warning': 2, - 'info': 3, - 'hint': 4 + error: 1, + warning: 2, + info: 3, + hint: 4, }; const severityNum = severityMap[severity]; - diagnostics = diagnostics.filter(d => d.severity === severityNum); + diagnostics = diagnostics.filter((d) => d.severity === severityNum); } if (diagnostics.length === 0) { @@ -249,49 +297,52 @@ export const lspDiagnosticsTool: ToolDefinition<{ return `Found ${diagnostics.length} diagnostic(s):\n\n${formatDiagnostics(diagnostics, file)}`; }); - } + }, }; /** * LSP Servers Tool - List available language servers */ export const lspServersTool: ToolDefinition> = { - name: 'lsp_servers', - description: 'List all known language servers and their installation status. Shows which servers are available and how to install missing ones.', + name: "lsp_servers", + description: + "List all known language servers and their installation status. Shows which servers are available and how to install missing ones.", schema: {}, handler: async () => { const servers = getAllServers(); - const installed = servers.filter(s => s.installed); - const notInstalled = servers.filter(s => !s.installed); + const installed = servers.filter((s) => s.installed); + const notInstalled = servers.filter((s) => !s.installed); - let text = '## Language Server Status\n\n'; + let text = "## Language Server Status\n\n"; if (installed.length > 0) { - text += '### Installed:\n'; + text += "### Installed:\n"; for (const server of installed) { text += `- ${server.name} (${server.command})\n`; - text += ` Extensions: ${server.extensions.join(', ')}\n`; + text += ` Extensions: ${server.extensions.join(", ")}\n`; } - text += '\n'; + text += "\n"; } if (notInstalled.length > 0) { - text += '### Not Installed:\n'; + text += "### Not Installed:\n"; for (const server of notInstalled) { text += `- ${server.name} (${server.command})\n`; - text += ` Extensions: ${server.extensions.join(', ')}\n`; + text += ` Extensions: ${server.extensions.join(", ")}\n`; text += ` Install: ${server.installHint}\n`; } } return { - content: [{ - type: 'text' as const, - text - }] + content: [ + { + type: "text" as const, + text, + }, + ], }; - } + }, }; /** @@ -302,23 +353,28 @@ export const lspPrepareRenameTool: ToolDefinition<{ line: z.ZodNumber; character: z.ZodNumber; }> = { - name: 'lsp_prepare_rename', - description: 'Check if a symbol at the given position can be renamed. Returns the range of the symbol if rename is possible.', + name: "lsp_prepare_rename", + description: + "Check if a symbol at the given position can be renamed. Returns the range of the symbol if rename is possible.", schema: { - file: z.string().describe('Path to the source file'), - line: z.number().int().min(1).describe('Line number (1-indexed)'), - character: z.number().int().min(0).describe('Character position in the line (0-indexed)') + file: z.string().describe("Path to the source file"), + line: z.number().int().min(1).describe("Line number (1-indexed)"), + character: z + .number() + .int() + .min(0) + .describe("Character position in the line (0-indexed)"), }, handler: async (args) => { const { file, line, character } = args; - return withLspClient(file, 'prepare rename', async (client) => { + return withLspClient(file, "prepare rename", async (client) => { const range = await client!.prepareRename(file, line - 1, character); if (!range) { - return 'Cannot rename symbol at this position'; + return "Cannot rename symbol at this position"; } return `Rename possible. Symbol range: line ${range.start.line + 1}, col ${range.start.character + 1} to line ${range.end.line + 1}, col ${range.end.character + 1}`; }); - } + }, }; /** @@ -330,26 +386,31 @@ export const lspRenameTool: ToolDefinition<{ character: z.ZodNumber; newName: z.ZodString; }> = { - name: 'lsp_rename', - description: 'Rename a symbol (variable, function, class, etc.) across all files in the project. Returns the list of edits that would be made. Does NOT apply the changes automatically.', + name: "lsp_rename", + description: + "Rename a symbol (variable, function, class, etc.) across all files in the project. Returns the list of edits that would be made. Does NOT apply the changes automatically.", schema: { - file: z.string().describe('Path to the source file'), - line: z.number().int().min(1).describe('Line number (1-indexed)'), - character: z.number().int().min(0).describe('Character position in the line (0-indexed)'), - newName: z.string().min(1).describe('New name for the symbol') + file: z.string().describe("Path to the source file"), + line: z.number().int().min(1).describe("Line number (1-indexed)"), + character: z + .number() + .int() + .min(0) + .describe("Character position in the line (0-indexed)"), + newName: z.string().min(1).describe("New name for the symbol"), }, handler: async (args) => { const { file, line, character, newName } = args; - return withLspClient(file, 'rename', async (client) => { + return withLspClient(file, "rename", async (client) => { const edit = await client!.rename(file, line - 1, character, newName); if (!edit) { - return 'Rename failed or no edits returned'; + return "Rename failed or no edits returned"; } const { files, edits } = countEdits(edit); return `Rename to "${newName}" would affect ${files} file(s) with ${edits} edit(s):\n\n${formatWorkspaceEdit(edit)}\n\nNote: Use the Edit tool to apply these changes.`; }); - } + }, }; /** @@ -362,26 +423,43 @@ export const lspCodeActionsTool: ToolDefinition<{ endLine: z.ZodNumber; endCharacter: z.ZodNumber; }> = { - name: 'lsp_code_actions', - description: 'Get available code actions (refactorings, quick fixes) for a selection. Returns a list of possible actions that can be applied.', + name: "lsp_code_actions", + description: + "Get available code actions (refactorings, quick fixes) for a selection. Returns a list of possible actions that can be applied.", schema: { - file: z.string().describe('Path to the source file'), - startLine: z.number().int().min(1).describe('Start line of selection (1-indexed)'), - startCharacter: z.number().int().min(0).describe('Start character of selection (0-indexed)'), - endLine: z.number().int().min(1).describe('End line of selection (1-indexed)'), - endCharacter: z.number().int().min(0).describe('End character of selection (0-indexed)') + file: z.string().describe("Path to the source file"), + startLine: z + .number() + .int() + .min(1) + .describe("Start line of selection (1-indexed)"), + startCharacter: z + .number() + .int() + .min(0) + .describe("Start character of selection (0-indexed)"), + endLine: z + .number() + .int() + .min(1) + .describe("End line of selection (1-indexed)"), + endCharacter: z + .number() + .int() + .min(0) + .describe("End character of selection (0-indexed)"), }, handler: async (args) => { const { file, startLine, startCharacter, endLine, endCharacter } = args; - return withLspClient(file, 'code actions', async (client) => { + return withLspClient(file, "code actions", async (client) => { const range = { start: { line: startLine - 1, character: startCharacter }, - end: { line: endLine - 1, character: endCharacter } + end: { line: endLine - 1, character: endCharacter }, }; const actions = await client!.codeActions(file, range); return formatCodeActions(actions); }); - } + }, }; /** @@ -395,27 +473,57 @@ export const lspCodeActionResolveTool: ToolDefinition<{ endCharacter: z.ZodNumber; actionIndex: z.ZodNumber; }> = { - name: 'lsp_code_action_resolve', - description: 'Get the full edit details for a specific code action. Use after lsp_code_actions to see what changes an action would make.', + name: "lsp_code_action_resolve", + description: + "Get the full edit details for a specific code action. Use after lsp_code_actions to see what changes an action would make.", schema: { - file: z.string().describe('Path to the source file'), - startLine: z.number().int().min(1).describe('Start line of selection (1-indexed)'), - startCharacter: z.number().int().min(0).describe('Start character of selection (0-indexed)'), - endLine: z.number().int().min(1).describe('End line of selection (1-indexed)'), - endCharacter: z.number().int().min(0).describe('End character of selection (0-indexed)'), - actionIndex: z.number().int().min(1).describe('Index of the action (1-indexed, from lsp_code_actions output)') + file: z.string().describe("Path to the source file"), + startLine: z + .number() + .int() + .min(1) + .describe("Start line of selection (1-indexed)"), + startCharacter: z + .number() + .int() + .min(0) + .describe("Start character of selection (0-indexed)"), + endLine: z + .number() + .int() + .min(1) + .describe("End line of selection (1-indexed)"), + endCharacter: z + .number() + .int() + .min(0) + .describe("End character of selection (0-indexed)"), + actionIndex: z + .number() + .int() + .min(1) + .describe( + "Index of the action (1-indexed, from lsp_code_actions output)", + ), }, handler: async (args) => { - const { file, startLine, startCharacter, endLine, endCharacter, actionIndex } = args; - return withLspClient(file, 'code action resolve', async (client) => { + const { + file, + startLine, + startCharacter, + endLine, + endCharacter, + actionIndex, + } = args; + return withLspClient(file, "code action resolve", async (client) => { const range = { start: { line: startLine - 1, character: startCharacter }, - end: { line: endLine - 1, character: endCharacter } + end: { line: endLine - 1, character: endCharacter }, }; const actions = await client!.codeActions(file, range); if (!actions || actions.length === 0) { - return 'No code actions available'; + return "No code actions available"; } if (actionIndex < 1 || actionIndex > actions.length) { @@ -438,7 +546,7 @@ export const lspCodeActionResolveTool: ToolDefinition<{ return result; }); - } + }, }; /** @@ -446,16 +554,22 @@ export const lspCodeActionResolveTool: ToolDefinition<{ */ export const lspDiagnosticsDirectoryTool: ToolDefinition<{ directory: z.ZodString; - strategy: z.ZodOptional>; + strategy: z.ZodOptional>; }> = { - name: 'lsp_diagnostics_directory', - description: 'Run project-level diagnostics on a directory using tsc --noEmit (preferred) or LSP iteration (fallback). Useful for checking the entire codebase for errors.', + name: "lsp_diagnostics_directory", + description: + "Run project-level diagnostics on a directory using tsc --noEmit (preferred) or LSP iteration (fallback). Useful for checking the entire codebase for errors.", schema: { - directory: z.string().describe('Project directory to check'), - strategy: z.enum(['tsc', 'lsp', 'auto']).optional().describe('Strategy to use: "tsc" (TypeScript compiler), "lsp" (Language Server iteration), or "auto" (default: auto-detect)') + directory: z.string().describe("Project directory to check"), + strategy: z + .enum(["tsc", "lsp", "auto"]) + .optional() + .describe( + 'Strategy to use: "tsc" (TypeScript compiler), "lsp" (Language Server iteration), or "auto" (default: auto-detect)', + ), }, handler: async (args) => { - const { directory, strategy = 'auto' } = args; + const { directory, strategy = "auto" } = args; try { const result = await runDirectoryDiagnostics(directory, strategy); @@ -470,21 +584,25 @@ export const lspDiagnosticsDirectoryTool: ToolDefinition<{ } return { - content: [{ - type: 'text' as const, - text: output - }] + content: [ + { + type: "text" as const, + text: output, + }, + ], }; } catch (error) { return { isError: true as const, - content: [{ - type: 'text' as const, - text: `Error running directory diagnostics: ${error instanceof Error ? error.message : String(error)}` - }] + content: [ + { + type: "text" as const, + text: `Error running directory diagnostics: ${error instanceof Error ? error.message : String(error)}`, + }, + ], }; } - } + }, }; /** @@ -502,5 +620,5 @@ export const lspTools = [ lspPrepareRenameTool, lspRenameTool, lspCodeActionsTool, - lspCodeActionResolveTool -]; \ No newline at end of file + lspCodeActionResolveTool, +]; diff --git a/packages/agent/tools/notepad.test.ts b/packages/agent/tools/notepad.test.ts index dddbd7b5..adb67f1f 100644 --- a/packages/agent/tools/notepad.test.ts +++ b/packages/agent/tools/notepad.test.ts @@ -1,360 +1,416 @@ -import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; +import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; // Mock notepad hooks -const mockGetPriorityContext = mock(() => 'Priority context content'); -const mockGetWorkingMemory = mock(() => 'Working memory entries\n- Entry 1\n- Entry 2'); -const mockGetManualSection = mock(() => 'Manual section content'); +const mockGetPriorityContext = mock(() => "Priority context content"); +const mockGetWorkingMemory = mock( + () => "Working memory entries\n- Entry 1\n- Entry 2", +); +const mockGetManualSection = mock(() => "Manual section content"); const mockSetPriorityContext = mock(() => ({ success: true })); const mockAddWorkingMemoryEntry = mock(() => true); const mockAddManualEntry = mock(() => true); const mockPruneOldEntries = mock(() => ({ pruned: 2, remaining: 5 })); const mockGetNotepadStats = mock(() => ({ - exists: true, - totalSize: 1024, - prioritySize: 256, - workingMemoryEntries: 3, - oldestEntry: '2024-01-01' + exists: true, + totalSize: 1024, + prioritySize: 256, + workingMemoryEntries: 3, + oldestEntry: "2024-01-01", })); -const mockFormatFullNotepad = mock(() => '## Priority Context\nContent\n\n## Working Memory\nEntries\n\n## MANUAL\nManual content'); +const mockFormatFullNotepad = mock( + () => + "## Priority Context\nContent\n\n## Working Memory\nEntries\n\n## MANUAL\nManual content", +); const mockDEFAULT_CONFIG = { workingMemoryDays: 7 }; -mock.module('../hooks/notepad/index.js', () => ({ - getPriorityContext: mockGetPriorityContext, - getWorkingMemory: mockGetWorkingMemory, - getManualSection: mockGetManualSection, - setPriorityContext: mockSetPriorityContext, - addWorkingMemoryEntry: mockAddWorkingMemoryEntry, - addManualEntry: mockAddManualEntry, - pruneOldEntries: mockPruneOldEntries, - getNotepadStats: mockGetNotepadStats, - formatFullNotepad: mockFormatFullNotepad, - DEFAULT_CONFIG: mockDEFAULT_CONFIG +mock.module("../hooks/notepad/index.js", () => ({ + getPriorityContext: mockGetPriorityContext, + getWorkingMemory: mockGetWorkingMemory, + getManualSection: mockGetManualSection, + setPriorityContext: mockSetPriorityContext, + addWorkingMemoryEntry: mockAddWorkingMemoryEntry, + addManualEntry: mockAddManualEntry, + pruneOldEntries: mockPruneOldEntries, + getNotepadStats: mockGetNotepadStats, + formatFullNotepad: mockFormatFullNotepad, + DEFAULT_CONFIG: mockDEFAULT_CONFIG, })); // Mock worktree paths -const mockGetWorktreeNotepadPath = mock((root: string) => path.join(root, '.omc', 'notepad.md')); +const mockGetWorktreeNotepadPath = mock((root: string) => + path.join(root, ".omc", "notepad.md"), +); const mockEnsureOmcDir = mock(() => {}); -const mockValidateWorkingDirectory = mock((wd?: string) => wd || '/test/root'); +const mockValidateWorkingDirectory = mock((wd?: string) => wd || "/test/root"); -mock.module('../lib/worktree-paths.js', () => ({ - getWorktreeNotepadPath: mockGetWorktreeNotepadPath, - ensureOmcDir: mockEnsureOmcDir, - validateWorkingDirectory: mockValidateWorkingDirectory +mock.module("../lib/worktree-paths.js", () => ({ + getWorktreeNotepadPath: mockGetWorktreeNotepadPath, + ensureOmcDir: mockEnsureOmcDir, + validateWorkingDirectory: mockValidateWorkingDirectory, })); // Import after mocking -const { notepadTools } = await import('./notepad'); - -describe('Notepad Tools', () => { - let testRoot: string; +const { notepadTools } = await import("./notepad"); + +describe("Notepad Tools", () => { + let testRoot: string; + + beforeEach(async () => { + testRoot = await mkdtemp(path.join(tmpdir(), "notepad-test-")); + + // Reset all mocks + Object.values({ + mockGetPriorityContext, + mockGetWorkingMemory, + mockGetManualSection, + mockSetPriorityContext, + mockAddWorkingMemoryEntry, + mockAddManualEntry, + mockPruneOldEntries, + mockGetNotepadStats, + mockFormatFullNotepad, + mockGetWorktreeNotepadPath, + mockEnsureOmcDir, + mockValidateWorkingDirectory, + }).forEach((mock) => mock.mockClear()); + }); + + afterEach(async () => { + try { + await rm(testRoot, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + test("should export notepadTools array with 6 tools", () => { + expect(Array.isArray(notepadTools)).toBe(true); + expect(notepadTools.length).toBe(6); + + const toolNames = notepadTools.map((t) => t.name); + expect(toolNames).toEqual([ + "notepad_read", + "notepad_write_priority", + "notepad_write_working", + "notepad_write_manual", + "notepad_prune", + "notepad_stats", + ]); + }); + + test("each tool should have required properties with correct types", () => { + for (const tool of notepadTools) { + expect(tool).toHaveProperty("name"); + expect(tool).toHaveProperty("description"); + expect(tool).toHaveProperty("schema"); + expect(tool).toHaveProperty("handler"); + + expect(typeof tool.name).toBe("string"); + expect(typeof tool.description).toBe("string"); + expect(typeof tool.handler).toBe("function"); + expect(typeof tool.schema).toBe("object"); + } + }); + + describe("notepad_read tool", () => { + test('should read full notepad when section is "all"', async () => { + const result = await notepadTools[0].handler({ + section: "all", + workingDirectory: testRoot, + }); + + expect(mockValidateWorkingDirectory).toHaveBeenCalledWith(testRoot); + expect(mockFormatFullNotepad).toHaveBeenCalledWith(testRoot); + expect(result.content[0].text).toContain("## Notepad"); + expect(result.content[0].text).toContain("Path:"); + }); - beforeEach(async () => { - testRoot = await mkdtemp(path.join(tmpdir(), 'notepad-test-')); + test("should read priority section", async () => { + const result = await notepadTools[0].handler({ + section: "priority", + workingDirectory: testRoot, + }); - // Reset all mocks - Object.values({ - mockGetPriorityContext, mockGetWorkingMemory, mockGetManualSection, - mockSetPriorityContext, mockAddWorkingMemoryEntry, mockAddManualEntry, - mockPruneOldEntries, mockGetNotepadStats, mockFormatFullNotepad, - mockGetWorktreeNotepadPath, mockEnsureOmcDir, mockValidateWorkingDirectory - }).forEach(mock => mock.mockClear()); + expect(mockGetPriorityContext).toHaveBeenCalledWith(testRoot); + expect(result.content[0].text).toBe( + "## Priority Context\n\nPriority context content", + ); }); - afterEach(async () => { - try { - await rm(testRoot, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - }); + test("should read working memory section", async () => { + const result = await notepadTools[0].handler({ + section: "working", + workingDirectory: testRoot, + }); - test('should export notepadTools array with 6 tools', () => { - expect(Array.isArray(notepadTools)).toBe(true); - expect(notepadTools.length).toBe(6); - - const toolNames = notepadTools.map(t => t.name); - expect(toolNames).toEqual([ - 'notepad_read', - 'notepad_write_priority', - 'notepad_write_working', - 'notepad_write_manual', - 'notepad_prune', - 'notepad_stats' - ]); + expect(mockGetWorkingMemory).toHaveBeenCalledWith(testRoot); + expect(result.content[0].text).toBe( + "## Working Memory\n\nWorking memory entries\n- Entry 1\n- Entry 2", + ); }); - test('each tool should have required properties with correct types', () => { - for (const tool of notepadTools) { - expect(tool).toHaveProperty('name'); - expect(tool).toHaveProperty('description'); - expect(tool).toHaveProperty('schema'); - expect(tool).toHaveProperty('handler'); - - expect(typeof tool.name).toBe('string'); - expect(typeof tool.description).toBe('string'); - expect(typeof tool.handler).toBe('function'); - expect(typeof tool.schema).toBe('object'); - } + test("should read manual section", async () => { + const result = await notepadTools[0].handler({ + section: "manual", + workingDirectory: testRoot, + }); + + expect(mockGetManualSection).toHaveBeenCalledWith(testRoot); + expect(result.content[0].text).toBe( + "## MANUAL\n\nManual section content", + ); }); - describe('notepad_read tool', () => { - test('should read full notepad when section is "all"', async () => { - const result = await notepadTools[0].handler({ - section: 'all', - workingDirectory: testRoot - }); - - expect(mockValidateWorkingDirectory).toHaveBeenCalledWith(testRoot); - expect(mockFormatFullNotepad).toHaveBeenCalledWith(testRoot); - expect(result.content[0].text).toContain('## Notepad'); - expect(result.content[0].text).toContain('Path:'); - }); - - test('should read priority section', async () => { - const result = await notepadTools[0].handler({ - section: 'priority', - workingDirectory: testRoot - }); - - expect(mockGetPriorityContext).toHaveBeenCalledWith(testRoot); - expect(result.content[0].text).toBe('## Priority Context\n\nPriority context content'); - }); - - test('should read working memory section', async () => { - const result = await notepadTools[0].handler({ - section: 'working', - workingDirectory: testRoot - }); - - expect(mockGetWorkingMemory).toHaveBeenCalledWith(testRoot); - expect(result.content[0].text).toBe('## Working Memory\n\nWorking memory entries\n- Entry 1\n- Entry 2'); - }); - - test('should read manual section', async () => { - const result = await notepadTools[0].handler({ - section: 'manual', - workingDirectory: testRoot - }); - - expect(mockGetManualSection).toHaveBeenCalledWith(testRoot); - expect(result.content[0].text).toBe('## MANUAL\n\nManual section content'); - }); - - test('should handle empty sections', async () => { - mockGetPriorityContext.mockReturnValueOnce(null); - - const result = await notepadTools[0].handler({ - section: 'priority', - workingDirectory: testRoot - }); - - expect(result.content[0].text).toBe('## Priority Context\n\n(Empty or notepad does not exist)'); - }); - - test('should handle missing notepad', async () => { - mockFormatFullNotepad.mockReturnValueOnce(null); - - const result = await notepadTools[0].handler({ - section: 'all', - workingDirectory: testRoot - }); - - expect(result.content[0].text).toBe('Notepad does not exist. Use notepad_write_* tools to create it.'); - }); - - test('should use default working directory', async () => { - const result = await notepadTools[0].handler({ - section: 'all' - }); - - expect(mockValidateWorkingDirectory).toHaveBeenCalledWith(undefined); - }); + test("should handle empty sections", async () => { + mockGetPriorityContext.mockReturnValueOnce(null); + + const result = await notepadTools[0].handler({ + section: "priority", + workingDirectory: testRoot, + }); + + expect(result.content[0].text).toBe( + "## Priority Context\n\n(Empty or notepad does not exist)", + ); }); - describe('notepad_write_priority tool', () => { - test('should write priority context successfully', async () => { - const result = await notepadTools[1].handler({ - content: 'New priority content', - workingDirectory: testRoot - }); - - expect(mockValidateWorkingDirectory).toHaveBeenCalledWith(testRoot); - expect(mockEnsureOmcDir).toHaveBeenCalled(); - expect(mockSetPriorityContext).toHaveBeenCalledWith(testRoot, 'New priority content'); - expect(result.content[0].text).toBe('Successfully wrote to Priority Context (20 chars)'); - }); - - test('should handle write failure', async () => { - mockSetPriorityContext.mockReturnValueOnce({ success: false }); - - const result = await notepadTools[1].handler({ - content: 'Content', - workingDirectory: testRoot - }); - - expect(result.content[0].text).toBe('Failed to write to Priority Context. Check file permissions.'); - }); - - test('should show warning on long content', async () => { - mockSetPriorityContext.mockReturnValueOnce({ - success: true, - warning: 'Content exceeds recommended length' - }); - - const result = await notepadTools[1].handler({ - content: 'Very long content that exceeds limits', - workingDirectory: testRoot - }); - - expect(result.content[0].text).toContain('**Warning:** Content exceeds recommended length'); - }); - - test('should accept content up to max length', async () => { - const maxContent = 'x'.repeat(2000); - - const result = await notepadTools[1].handler({ - content: maxContent, - workingDirectory: testRoot - }); - - expect(result.content[0].text).toContain('Successfully wrote'); - }); + test("should handle missing notepad", async () => { + mockFormatFullNotepad.mockReturnValueOnce(null); + + const result = await notepadTools[0].handler({ + section: "all", + workingDirectory: testRoot, + }); + + expect(result.content[0].text).toBe( + "Notepad does not exist. Use notepad_write_* tools to create it.", + ); }); - describe('notepad_write_working tool', () => { - test('should add working memory entry successfully', async () => { - const result = await notepadTools[2].handler({ - content: 'New working memory entry', - workingDirectory: testRoot - }); + test("should use default working directory", async () => { + const _result = await notepadTools[0].handler({ + section: "all", + }); - expect(mockAddWorkingMemoryEntry).toHaveBeenCalledWith(testRoot, 'New working memory entry'); - expect(result.content[0].text).toBe('Successfully added entry to Working Memory (24 chars)'); - }); + expect(mockValidateWorkingDirectory).toHaveBeenCalledWith(undefined); + }); + }); + + describe("notepad_write_priority tool", () => { + test("should write priority context successfully", async () => { + const result = await notepadTools[1].handler({ + content: "New priority content", + workingDirectory: testRoot, + }); + + expect(mockValidateWorkingDirectory).toHaveBeenCalledWith(testRoot); + expect(mockEnsureOmcDir).toHaveBeenCalled(); + expect(mockSetPriorityContext).toHaveBeenCalledWith( + testRoot, + "New priority content", + ); + expect(result.content[0].text).toBe( + "Successfully wrote to Priority Context (20 chars)", + ); + }); - test('should handle add failure', async () => { - mockAddWorkingMemoryEntry.mockReturnValueOnce(false); + test("should handle write failure", async () => { + mockSetPriorityContext.mockReturnValueOnce({ success: false }); - const result = await notepadTools[2].handler({ - content: 'Content', - workingDirectory: testRoot - }); + const result = await notepadTools[1].handler({ + content: "Content", + workingDirectory: testRoot, + }); - expect(result.content[0].text).toBe('Failed to add entry to Working Memory. Check file permissions.'); - }); + expect(result.content[0].text).toBe( + "Failed to write to Priority Context. Check file permissions.", + ); }); - describe('notepad_write_manual tool', () => { - test('should add manual entry successfully', async () => { - const result = await notepadTools[3].handler({ - content: 'New manual entry', - workingDirectory: testRoot - }); + test("should show warning on long content", async () => { + mockSetPriorityContext.mockReturnValueOnce({ + success: true, + warning: "Content exceeds recommended length", + }); + + const result = await notepadTools[1].handler({ + content: "Very long content that exceeds limits", + workingDirectory: testRoot, + }); - expect(mockAddManualEntry).toHaveBeenCalledWith(testRoot, 'New manual entry'); - expect(result.content[0].text).toBe('Successfully added entry to MANUAL section (16 chars)'); - }); + expect(result.content[0].text).toContain( + "**Warning:** Content exceeds recommended length", + ); + }); - test('should handle add failure', async () => { - mockAddManualEntry.mockReturnValueOnce(false); + test("should accept content up to max length", async () => { + const maxContent = "x".repeat(2000); - const result = await notepadTools[3].handler({ - content: 'Content', - workingDirectory: testRoot - }); + const result = await notepadTools[1].handler({ + content: maxContent, + workingDirectory: testRoot, + }); - expect(result.content[0].text).toBe('Failed to add entry to MANUAL section. Check file permissions.'); - }); + expect(result.content[0].text).toContain("Successfully wrote"); }); + }); + + describe("notepad_write_working tool", () => { + test("should add working memory entry successfully", async () => { + const result = await notepadTools[2].handler({ + content: "New working memory entry", + workingDirectory: testRoot, + }); + + expect(mockAddWorkingMemoryEntry).toHaveBeenCalledWith( + testRoot, + "New working memory entry", + ); + expect(result.content[0].text).toBe( + "Successfully added entry to Working Memory (24 chars)", + ); + }); + + test("should handle add failure", async () => { + mockAddWorkingMemoryEntry.mockReturnValueOnce(false); + + const result = await notepadTools[2].handler({ + content: "Content", + workingDirectory: testRoot, + }); - describe('notepad_prune tool', () => { - test('should prune entries with default days', async () => { - const result = await notepadTools[4].handler({ - workingDirectory: testRoot - }); - - expect(mockPruneOldEntries).toHaveBeenCalledWith(testRoot, 7); - expect(result.content[0].text).toBe('## Prune Results\n\n- Pruned: 2 entries\n- Remaining: 5 entries\n- Threshold: 7 days'); - }); - - test('should prune entries with custom days', async () => { - const result = await notepadTools[4].handler({ - daysOld: 14, - workingDirectory: testRoot - }); - - expect(mockPruneOldEntries).toHaveBeenCalledWith(testRoot, 14); - expect(result.content[0].text).toContain('Threshold: 14 days'); - }); + expect(result.content[0].text).toBe( + "Failed to add entry to Working Memory. Check file permissions.", + ); + }); + }); + + describe("notepad_write_manual tool", () => { + test("should add manual entry successfully", async () => { + const result = await notepadTools[3].handler({ + content: "New manual entry", + workingDirectory: testRoot, + }); + + expect(mockAddManualEntry).toHaveBeenCalledWith( + testRoot, + "New manual entry", + ); + expect(result.content[0].text).toBe( + "Successfully added entry to MANUAL section (16 chars)", + ); }); - describe('notepad_stats tool', () => { - test('should get notepad statistics', async () => { - const result = await notepadTools[5].handler({ - workingDirectory: testRoot - }); + test("should handle add failure", async () => { + mockAddManualEntry.mockReturnValueOnce(false); - expect(mockGetNotepadStats).toHaveBeenCalledWith(testRoot); - expect(result.content[0].text).toContain('## Notepad Statistics'); - expect(result.content[0].text).toContain('**Total Size:** 1024 bytes'); - expect(result.content[0].text).toContain('**Working Memory Entries:** 3'); - expect(result.content[0].text).toContain('Path:'); - }); + const result = await notepadTools[3].handler({ + content: "Content", + workingDirectory: testRoot, + }); - test('should handle non-existent notepad', async () => { - mockGetNotepadStats.mockReturnValueOnce({ exists: false }); + expect(result.content[0].text).toBe( + "Failed to add entry to MANUAL section. Check file permissions.", + ); + }); + }); + + describe("notepad_prune tool", () => { + test("should prune entries with default days", async () => { + const result = await notepadTools[4].handler({ + workingDirectory: testRoot, + }); + + expect(mockPruneOldEntries).toHaveBeenCalledWith(testRoot, 7); + expect(result.content[0].text).toBe( + "## Prune Results\n\n- Pruned: 2 entries\n- Remaining: 5 entries\n- Threshold: 7 days", + ); + }); - const result = await notepadTools[5].handler({ - workingDirectory: testRoot - }); + test("should prune entries with custom days", async () => { + const result = await notepadTools[4].handler({ + daysOld: 14, + workingDirectory: testRoot, + }); - expect(result.content[0].text).toBe('## Notepad Statistics\n\nNotepad does not exist yet.'); - }); + expect(mockPruneOldEntries).toHaveBeenCalledWith(testRoot, 14); + expect(result.content[0].text).toContain("Threshold: 14 days"); + }); + }); + + describe("notepad_stats tool", () => { + test("should get notepad statistics", async () => { + const result = await notepadTools[5].handler({ + workingDirectory: testRoot, + }); + + expect(mockGetNotepadStats).toHaveBeenCalledWith(testRoot); + expect(result.content[0].text).toContain("## Notepad Statistics"); + expect(result.content[0].text).toContain("**Total Size:** 1024 bytes"); + expect(result.content[0].text).toContain("**Working Memory Entries:** 3"); + expect(result.content[0].text).toContain("Path:"); }); - describe('error handling', () => { - test('should handle validation errors', async () => { - mockValidateWorkingDirectory.mockImplementationOnce(() => { - throw new Error('Invalid working directory'); - }); + test("should handle non-existent notepad", async () => { + mockGetNotepadStats.mockReturnValueOnce({ exists: false }); - const result = await notepadTools[0].handler({ - section: 'all', - workingDirectory: '/invalid/path' - }); + const result = await notepadTools[5].handler({ + workingDirectory: testRoot, + }); - expect(result.content[0].text).toBe('Error reading notepad: Invalid working directory'); - }); + expect(result.content[0].text).toBe( + "## Notepad Statistics\n\nNotepad does not exist yet.", + ); + }); + }); + + describe("error handling", () => { + test("should handle validation errors", async () => { + mockValidateWorkingDirectory.mockImplementationOnce(() => { + throw new Error("Invalid working directory"); + }); + + const result = await notepadTools[0].handler({ + section: "all", + workingDirectory: "/invalid/path", + }); + + expect(result.content[0].text).toBe( + "Error reading notepad: Invalid working directory", + ); + }); - test('should handle hook errors', async () => { - mockGetPriorityContext.mockImplementationOnce(() => { - throw new Error('File system error'); - }); + test("should handle hook errors", async () => { + mockGetPriorityContext.mockImplementationOnce(() => { + throw new Error("File system error"); + }); - const result = await notepadTools[0].handler({ - section: 'priority', - workingDirectory: testRoot - }); + const result = await notepadTools[0].handler({ + section: "priority", + workingDirectory: testRoot, + }); - expect(result.content[0].text).toBe('Error reading notepad: File system error'); - }); + expect(result.content[0].text).toBe( + "Error reading notepad: File system error", + ); }); + }); - describe('schema validation', () => { - test('notepad_write_priority should have content schema', () => { - expect(notepadTools[1].schema).toHaveProperty('content'); - }); + describe("schema validation", () => { + test("notepad_write_priority should have content schema", () => { + expect(notepadTools[1].schema).toHaveProperty("content"); + }); - test('write tools should have content schema', () => { - expect(notepadTools[2].schema).toHaveProperty('content'); - expect(notepadTools[3].schema).toHaveProperty('content'); - }); + test("write tools should have content schema", () => { + expect(notepadTools[2].schema).toHaveProperty("content"); + expect(notepadTools[3].schema).toHaveProperty("content"); + }); - test('notepad_prune should have daysOld schema', () => { - expect(notepadTools[4].schema).toHaveProperty('daysOld'); - }); + test("notepad_prune should have daysOld schema", () => { + expect(notepadTools[4].schema).toHaveProperty("daysOld"); }); -}); \ No newline at end of file + }); +}); diff --git a/packages/agent/tools/notepad.ts b/packages/agent/tools/notepad.ts index 2ab56dea..2f350447 100644 --- a/packages/agent/tools/notepad.ts +++ b/packages/agent/tools/notepad.ts @@ -5,12 +5,12 @@ * (Priority Context, Working Memory, MANUAL). */ -import { z } from 'zod'; +import { z } from "zod"; import { getWorktreeNotepadPath, ensureOmcDir, validateWorkingDirectory, -} from '../lib/worktree-paths.js'; +} from "../lib/worktree-paths.js"; import { getPriorityContext, getWorkingMemory, @@ -22,10 +22,15 @@ import { getNotepadStats, formatFullNotepad, DEFAULT_CONFIG, -} from '../hooks/notepad/index.js'; -import { ToolDefinition } from './types.js'; +} from "../hooks/notepad/index.js"; +import { ToolDefinition } from "./types.js"; -const SECTION_NAMES: [string, ...string[]] = ['all', 'priority', 'working', 'manual']; +const SECTION_NAMES: [string, ...string[]] = [ + "all", + "priority", + "working", + "manual", +]; // ============================================================================ // notepad_read - Read notepad content @@ -35,78 +40,97 @@ export const notepadReadTool: ToolDefinition<{ section: z.ZodOptional>; workingDirectory: z.ZodOptional; }> = { - name: 'notepad_read', - description: 'Read the notepad content. Can read the full notepad or a specific section (priority, working, manual).', + name: "notepad_read", + description: + "Read the notepad content. Can read the full notepad or a specific section (priority, working, manual).", schema: { - section: z.enum(SECTION_NAMES).optional().describe('Section to read: "all" (default), "priority", "working", or "manual"'), - workingDirectory: z.string().optional().describe('Working directory (defaults to cwd)'), + section: z + .enum(SECTION_NAMES) + .optional() + .describe( + 'Section to read: "all" (default), "priority", "working", or "manual"', + ), + workingDirectory: z + .string() + .optional() + .describe("Working directory (defaults to cwd)"), }, handler: async (args) => { - const { section = 'all', workingDirectory } = args; + const { section = "all", workingDirectory } = args; try { const root = validateWorkingDirectory(workingDirectory); - if (section === 'all') { + if (section === "all") { const content = formatFullNotepad(root); if (!content) { return { - content: [{ - type: 'text' as const, - text: 'Notepad does not exist. Use notepad_write_* tools to create it.' - }] + content: [ + { + type: "text" as const, + text: "Notepad does not exist. Use notepad_write_* tools to create it.", + }, + ], }; } return { - content: [{ - type: 'text' as const, - text: `## Notepad\n\nPath: ${getWorktreeNotepadPath(root)}\n\n${content}` - }] + content: [ + { + type: "text" as const, + text: `## Notepad\n\nPath: ${getWorktreeNotepadPath(root)}\n\n${content}`, + }, + ], }; } let sectionContent: string | null = null; - let sectionTitle = ''; + let sectionTitle = ""; switch (section) { - case 'priority': + case "priority": sectionContent = getPriorityContext(root); - sectionTitle = 'Priority Context'; + sectionTitle = "Priority Context"; break; - case 'working': + case "working": sectionContent = getWorkingMemory(root); - sectionTitle = 'Working Memory'; + sectionTitle = "Working Memory"; break; - case 'manual': + case "manual": sectionContent = getManualSection(root); - sectionTitle = 'MANUAL'; + sectionTitle = "MANUAL"; break; } if (!sectionContent) { return { - content: [{ - type: 'text' as const, - text: `## ${sectionTitle}\n\n(Empty or notepad does not exist)` - }] + content: [ + { + type: "text" as const, + text: `## ${sectionTitle}\n\n(Empty or notepad does not exist)`, + }, + ], }; } return { - content: [{ - type: 'text' as const, - text: `## ${sectionTitle}\n\n${sectionContent}` - }] + content: [ + { + type: "text" as const, + text: `## ${sectionTitle}\n\n${sectionContent}`, + }, + ], }; } catch (error) { return { - content: [{ - type: 'text' as const, - text: `Error reading notepad: ${error instanceof Error ? error.message : String(error)}` - }] + content: [ + { + type: "text" as const, + text: `Error reading notepad: ${error instanceof Error ? error.message : String(error)}`, + }, + ], }; } - } + }, }; // ============================================================================ @@ -117,11 +141,18 @@ export const notepadWritePriorityTool: ToolDefinition<{ content: z.ZodString; workingDirectory: z.ZodOptional; }> = { - name: 'notepad_write_priority', - description: 'Write to the Priority Context section. This REPLACES the existing content. Keep under 500 chars - this is always loaded at session start.', + name: "notepad_write_priority", + description: + "Write to the Priority Context section. This REPLACES the existing content. Keep under 500 chars - this is always loaded at session start.", schema: { - content: z.string().max(2000).describe('Content to write (recommend under 500 chars)'), - workingDirectory: z.string().optional().describe('Working directory (defaults to cwd)'), + content: z + .string() + .max(2000) + .describe("Content to write (recommend under 500 chars)"), + workingDirectory: z + .string() + .optional() + .describe("Working directory (defaults to cwd)"), }, handler: async (args) => { const { content, workingDirectory } = args; @@ -130,16 +161,18 @@ export const notepadWritePriorityTool: ToolDefinition<{ const root = validateWorkingDirectory(workingDirectory); // Ensure .omc directory exists - ensureOmcDir('', root); + ensureOmcDir("", root); const result = setPriorityContext(root, content); if (!result.success) { return { - content: [{ - type: 'text' as const, - text: 'Failed to write to Priority Context. Check file permissions.' - }] + content: [ + { + type: "text" as const, + text: "Failed to write to Priority Context. Check file permissions.", + }, + ], }; } @@ -149,20 +182,24 @@ export const notepadWritePriorityTool: ToolDefinition<{ } return { - content: [{ - type: 'text' as const, - text: response - }] + content: [ + { + type: "text" as const, + text: response, + }, + ], }; } catch (error) { return { - content: [{ - type: 'text' as const, - text: `Error writing to Priority Context: ${error instanceof Error ? error.message : String(error)}` - }] + content: [ + { + type: "text" as const, + text: `Error writing to Priority Context: ${error instanceof Error ? error.message : String(error)}`, + }, + ], }; } - } + }, }; // ============================================================================ @@ -173,11 +210,15 @@ export const notepadWriteWorkingTool: ToolDefinition<{ content: z.ZodString; workingDirectory: z.ZodOptional; }> = { - name: 'notepad_write_working', - description: 'Add an entry to Working Memory section. Entries are timestamped and auto-pruned after 7 days.', + name: "notepad_write_working", + description: + "Add an entry to Working Memory section. Entries are timestamped and auto-pruned after 7 days.", schema: { - content: z.string().max(4000).describe('Content to add as a new entry'), - workingDirectory: z.string().optional().describe('Working directory (defaults to cwd)'), + content: z.string().max(4000).describe("Content to add as a new entry"), + workingDirectory: z + .string() + .optional() + .describe("Working directory (defaults to cwd)"), }, handler: async (args) => { const { content, workingDirectory } = args; @@ -186,34 +227,40 @@ export const notepadWriteWorkingTool: ToolDefinition<{ const root = validateWorkingDirectory(workingDirectory); // Ensure .omc directory exists - ensureOmcDir('', root); + ensureOmcDir("", root); const success = addWorkingMemoryEntry(root, content); if (!success) { return { - content: [{ - type: 'text' as const, - text: 'Failed to add entry to Working Memory. Check file permissions.' - }] + content: [ + { + type: "text" as const, + text: "Failed to add entry to Working Memory. Check file permissions.", + }, + ], }; } return { - content: [{ - type: 'text' as const, - text: `Successfully added entry to Working Memory (${content.length} chars)` - }] + content: [ + { + type: "text" as const, + text: `Successfully added entry to Working Memory (${content.length} chars)`, + }, + ], }; } catch (error) { return { - content: [{ - type: 'text' as const, - text: `Error writing to Working Memory: ${error instanceof Error ? error.message : String(error)}` - }] + content: [ + { + type: "text" as const, + text: `Error writing to Working Memory: ${error instanceof Error ? error.message : String(error)}`, + }, + ], }; } - } + }, }; // ============================================================================ @@ -224,11 +271,15 @@ export const notepadWriteManualTool: ToolDefinition<{ content: z.ZodString; workingDirectory: z.ZodOptional; }> = { - name: 'notepad_write_manual', - description: 'Add an entry to the MANUAL section. Content in this section is never auto-pruned.', + name: "notepad_write_manual", + description: + "Add an entry to the MANUAL section. Content in this section is never auto-pruned.", schema: { - content: z.string().max(4000).describe('Content to add as a new entry'), - workingDirectory: z.string().optional().describe('Working directory (defaults to cwd)'), + content: z.string().max(4000).describe("Content to add as a new entry"), + workingDirectory: z + .string() + .optional() + .describe("Working directory (defaults to cwd)"), }, handler: async (args) => { const { content, workingDirectory } = args; @@ -237,34 +288,40 @@ export const notepadWriteManualTool: ToolDefinition<{ const root = validateWorkingDirectory(workingDirectory); // Ensure .omc directory exists - ensureOmcDir('', root); + ensureOmcDir("", root); const success = addManualEntry(root, content); if (!success) { return { - content: [{ - type: 'text' as const, - text: 'Failed to add entry to MANUAL section. Check file permissions.' - }] + content: [ + { + type: "text" as const, + text: "Failed to add entry to MANUAL section. Check file permissions.", + }, + ], }; } return { - content: [{ - type: 'text' as const, - text: `Successfully added entry to MANUAL section (${content.length} chars)` - }] + content: [ + { + type: "text" as const, + text: `Successfully added entry to MANUAL section (${content.length} chars)`, + }, + ], }; } catch (error) { return { - content: [{ - type: 'text' as const, - text: `Error writing to MANUAL: ${error instanceof Error ? error.message : String(error)}` - }] + content: [ + { + type: "text" as const, + text: `Error writing to MANUAL: ${error instanceof Error ? error.message : String(error)}`, + }, + ], }; } - } + }, }; // ============================================================================ @@ -275,34 +332,49 @@ export const notepadPruneTool: ToolDefinition<{ daysOld: z.ZodOptional; workingDirectory: z.ZodOptional; }> = { - name: 'notepad_prune', - description: 'Prune Working Memory entries older than N days (default: 7 days).', + name: "notepad_prune", + description: + "Prune Working Memory entries older than N days (default: 7 days).", schema: { - daysOld: z.number().int().min(1).max(365).optional().describe('Remove entries older than this many days (default: 7)'), - workingDirectory: z.string().optional().describe('Working directory (defaults to cwd)'), + daysOld: z + .number() + .int() + .min(1) + .max(365) + .optional() + .describe("Remove entries older than this many days (default: 7)"), + workingDirectory: z + .string() + .optional() + .describe("Working directory (defaults to cwd)"), }, handler: async (args) => { - const { daysOld = DEFAULT_CONFIG.workingMemoryDays, workingDirectory } = args; + const { daysOld = DEFAULT_CONFIG.workingMemoryDays, workingDirectory } = + args; try { const root = validateWorkingDirectory(workingDirectory); const result = pruneOldEntries(root, daysOld); return { - content: [{ - type: 'text' as const, - text: `## Prune Results\n\n- Pruned: ${result.pruned} entries\n- Remaining: ${result.remaining} entries\n- Threshold: ${daysOld} days` - }] + content: [ + { + type: "text" as const, + text: `## Prune Results\n\n- Pruned: ${result.pruned} entries\n- Remaining: ${result.remaining} entries\n- Threshold: ${daysOld} days`, + }, + ], }; } catch (error) { return { - content: [{ - type: 'text' as const, - text: `Error pruning notepad: ${error instanceof Error ? error.message : String(error)}` - }] + content: [ + { + type: "text" as const, + text: `Error pruning notepad: ${error instanceof Error ? error.message : String(error)}`, + }, + ], }; } - } + }, }; // ============================================================================ @@ -312,10 +384,14 @@ export const notepadPruneTool: ToolDefinition<{ export const notepadStatsTool: ToolDefinition<{ workingDirectory: z.ZodOptional; }> = { - name: 'notepad_stats', - description: 'Get statistics about the notepad (size, entry count, oldest entry).', + name: "notepad_stats", + description: + "Get statistics about the notepad (size, entry count, oldest entry).", schema: { - workingDirectory: z.string().optional().describe('Working directory (defaults to cwd)'), + workingDirectory: z + .string() + .optional() + .describe("Working directory (defaults to cwd)"), }, handler: async (args) => { const { workingDirectory } = args; @@ -326,37 +402,43 @@ export const notepadStatsTool: ToolDefinition<{ if (!stats.exists) { return { - content: [{ - type: 'text' as const, - text: '## Notepad Statistics\n\nNotepad does not exist yet.' - }] + content: [ + { + type: "text" as const, + text: "## Notepad Statistics\n\nNotepad does not exist yet.", + }, + ], }; } const lines = [ - '## Notepad Statistics\n', + "## Notepad Statistics\n", `- **Total Size:** ${stats.totalSize} bytes`, `- **Priority Context Size:** ${stats.prioritySize} bytes`, `- **Working Memory Entries:** ${stats.workingMemoryEntries}`, - `- **Oldest Entry:** ${stats.oldestEntry || 'None'}`, + `- **Oldest Entry:** ${stats.oldestEntry || "None"}`, `- **Path:** ${getWorktreeNotepadPath(root)}`, ]; return { - content: [{ - type: 'text' as const, - text: lines.join('\n') - }] + content: [ + { + type: "text" as const, + text: lines.join("\n"), + }, + ], }; } catch (error) { return { - content: [{ - type: 'text' as const, - text: `Error getting notepad stats: ${error instanceof Error ? error.message : String(error)}` - }] + content: [ + { + type: "text" as const, + text: `Error getting notepad stats: ${error instanceof Error ? error.message : String(error)}`, + }, + ], }; } - } + }, }; /** @@ -369,4 +451,4 @@ export const notepadTools = [ notepadWriteManualTool, notepadPruneTool, notepadStatsTool, -]; \ No newline at end of file +]; diff --git a/packages/agent/tools/tools.test.ts b/packages/agent/tools/tools.test.ts index 73f9136a..813d0ea6 100644 --- a/packages/agent/tools/tools.test.ts +++ b/packages/agent/tools/tools.test.ts @@ -1,7 +1,15 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + type Mock, + test, +} from "bun:test"; import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { setTimeout } from "node:timers/promises"; + import path from "node:path"; import type { ToolNeedsApprovalFunction } from "./utils"; @@ -460,10 +468,11 @@ describe("tools execute behavior", () => { }); describe("commitAndPrTool", () => { - let mockSandboxExec: mock.Mock; + // ทดสอบเครื่องมือ commit และสร้าง PR + let mockSandboxExec: Mock; let mockSandbox: { workingDirectory: string; - exec: mock.Mock; + exec: Mock; }; beforeEach(() => { @@ -481,19 +490,32 @@ describe("tools execute behavior", () => { return { success: true, exitCode: 0, stdout: "", stderr: "" }; } if (command.startsWith("gh pr create")) { - return { success: true, exitCode: 0, stdout: "https://github.com/test/repo/pull/1", stderr: "" }; + return { + success: true, + exitCode: 0, + stdout: "https://github.com/test/repo/pull/1", + stderr: "", + }; } - return { success: false, exitCode: 1, stdout: "", stderr: `Unknown command: ${command}` }; + return { + success: false, + exitCode: 1, + stdout: "", + stderr: `Unknown command: ${command}`, + }; }); mockSandbox = { workingDirectory: "/repo", exec: mockSandboxExec, + readFile: mock( + async () => + "---\nname: review\ndescription: review code\n---\nRun review with $ARGUMENTS", + ), }; // Mock getSandbox to return our controlled mockSandbox mock.module("./utils", () => ({ - ...mock.module.requireActual("./utils"), // Keep other utils functions getSandbox: mock(async () => mockSandbox), })); }); @@ -511,6 +533,7 @@ describe("tools execute behavior", () => { test("should successfully commit, push, and create a PR", async () => { const args = { + needsApproval: true, branchName: "feature/test-branch", commitMessage: "feat: add new feature", prTitle: "feat: Add new feature", @@ -562,15 +585,29 @@ describe("tools execute behavior", () => { }); test("should return error if git commit fails for a non-'nothing to commit' reason", async () => { - currentMockSandbox.exec.mockImplementation(async (command: string) => { + mockSandbox.exec.mockImplementation(async (command: string) => { if (command.startsWith("git commit -m")) { - return { success: false, exitCode: 1, stdout: "Commit failed", stderr: "Pre-commit hook failed" }; + return { + success: false, + exitCode: 1, + stdout: "Commit failed", + stderr: "Pre-commit hook failed", + }; } return { success: true, exitCode: 0, stdout: "", stderr: "" }; // Other commands succeed }); - const args = { branchName: "b", commitMessage: "c", prTitle: "p", prBody: "pb" }; - const result = await commitAndPrTool.execute?.(args, executionOptions(createCommitAndPrContext())); + const args = { + needsApproval: true, + branchName: "b", + commitMessage: "c", + prTitle: "p", + prBody: "pb", + }; + const result = await commitAndPrTool.execute?.( + args, + executionOptions(createCommitAndPrContext()), + ); expect(result).toEqual({ success: false, @@ -579,15 +616,29 @@ describe("tools execute behavior", () => { }); test("should return error if git push fails", async () => { - currentMockSandbox.exec.mockImplementation(async (command: string) => { + mockSandbox.exec.mockImplementation(async (command: string) => { if (command.startsWith("git push -u origin")) { - return { success: false, exitCode: 1, stdout: "Push failed", stderr: "Authentication required" }; + return { + success: false, + exitCode: 1, + stdout: "Push failed", + stderr: "Authentication required", + }; } return { success: true, exitCode: 0, stdout: "", stderr: "" }; // Other commands succeed }); - const args = { branchName: "b", commitMessage: "c", prTitle: "p", prBody: "pb" }; - const result = await commitAndPrTool.execute?.(args, executionOptions(createCommitAndPrContext())); + const args = { + needsApproval: true, + branchName: "b", + commitMessage: "c", + prTitle: "p", + prBody: "pb", + }; + const result = await commitAndPrTool.execute?.( + args, + executionOptions(createCommitAndPrContext()), + ); expect(result).toEqual({ success: false, @@ -596,25 +647,41 @@ describe("tools execute behavior", () => { }); test("should report partial success if PR creation fails", async () => { - currentMockSandbox.exec.mockImplementation(async (command: string) => { + mockSandbox.exec.mockImplementation(async (command: string) => { if (command.startsWith("gh pr create")) { - return { success: false, exitCode: 1, stdout: "", stderr: "PR creation failed: Missing required fields" }; + return { + success: false, + exitCode: 1, + stdout: "", + stderr: "PR creation failed: Missing required fields", + }; } return { success: true, exitCode: 0, stdout: "", stderr: "" }; // Other commands succeed }); - const args = { branchName: "b", commitMessage: "c", prTitle: "p", prBody: "pb" }; - const result = await commitAndPrTool.execute?.(args, executionOptions(createCommitAndPrContext())); + const args = { + needsApproval: true, + branchName: "b", + commitMessage: "c", + prTitle: "p", + prBody: "pb", + }; + const result = await commitAndPrTool.execute?.( + args, + executionOptions(createCommitAndPrContext()), + ); expect(result).toEqual({ success: true, - message: "Successfully committed and pushed to branch b. Note: Could not create PR automatically via gh CLI. Please create it manually.", + message: + "Successfully committed and pushed to branch b. Note: Could not create PR automatically via gh CLI. Please create it manually.", errorDetails: "PR creation failed: Missing required fields", }); }); test("needsApproval should always return true", async () => { const args = { + needsApproval: true, branchName: "feature/test-branch", commitMessage: "feat: add new feature", prTitle: "feat: Add new feature", @@ -653,6 +720,10 @@ describe("tools execute behavior", () => { }, }; + mock.module("./utils", () => ({ + getSandbox: mock(async () => sandbox), + })); + const context = createContext(sandbox); const result = await webFetchTool.execute?.( @@ -716,6 +787,10 @@ describe("tools execute behavior", () => { "---\nname: review\ndescription: review code\n---\nRun review with $ARGUMENTS", }; + mock.module("./utils", () => ({ + getSandbox: mock(async () => sandbox), + })); + const result = await skillTool.execute?.( { skill: "Review", args: "--quick" }, executionOptions({ @@ -849,177 +924,177 @@ describe("tools execute behavior", () => { }); }); }); - expect(body.length).toBe(MAX_BODY_LENGTH); - }); - test("askUserQuestionTool formats structured answers", () => { - const answerOutput = askUserQuestionTool.toModelOutput?.({ - toolCallId: "tool-call-1", - input: { questions: [] }, - output: { - answers: { - "Which package manager?": "bun", - "Which checks?": ["typecheck", "test"], - }, +test("askUserQuestionTool formats structured answers", () => { + const answerOutput = askUserQuestionTool.toModelOutput?.({ + toolCallId: "tool-call-1", + input: { questions: [] }, + output: { + answers: { + "Which package manager?": "bun", + "Which checks?": ["typecheck", "test"], }, - }); + }, + }); - expect(answerOutput).toEqual({ - type: "text", - value: - 'User has answered your questions: "Which package manager?"="bun", "Which checks?"="typecheck, test". You can now continue with the user\'s answers in mind.', - }); + expect(answerOutput).toEqual({ + type: "text", + value: + 'User has answered your questions: "Which package manager?"="bun", "Which checks?"="typecheck, test". You can now continue with the user\'s answers in mind.', + }); - const declinedOutput = askUserQuestionTool.toModelOutput?.({ - toolCallId: "tool-call-1", - input: { questions: [] }, - output: { declined: true }, - }); + const declinedOutput = askUserQuestionTool.toModelOutput?.({ + toolCallId: "tool-call-1", + input: { questions: [] }, + output: { declined: true }, + }); - expect(declinedOutput).toEqual({ - type: "text", - value: - "User declined to answer questions. You should continue without this information or ask in a different way.", - }); + expect(declinedOutput).toEqual({ + type: "text", + value: + "User declined to answer questions. You should continue without this information or ask in a different way.", }); +}); - test("skillTool loads skill content and substitutes arguments", async () => { - const sandbox = { - workingDirectory: "/repo", - readFile: async () => - "---\nname: review\ndescription: review code\n---\nRun review with $ARGUMENTS", - }; +test("skillTool loads skill content and substitutes arguments", async () => { + const sandbox = { + workingDirectory: "/repo", + readFile: async () => + "---\nname: review\ndescription: review code\n---\nRun review with $ARGUMENTS", + }; - const result = await skillTool.execute?.( - { skill: "Review", args: "--quick" }, - executionOptions({ - ...createContext(sandbox), - skills: [ - { - name: "review", - description: "Review code changes", - path: "/repo/.skills/review", - filename: "SKILL.md", - options: {}, - }, - ], - }), - ); + mock.module("./utils", () => ({ + getSandbox: mock(async () => sandbox), + })); - expect(result).toEqual({ - success: true, - skillName: "Review", - skillPath: "/repo/.skills/review", - content: - "Skill directory: /repo/.skills/review\n\nRun review with --quick", - }); + const result = await skillTool.execute?.( + { skill: "Review", args: "--quick" }, + executionOptions({ + ...createContext(sandbox), + skills: [ + { + name: "review", + description: "Review code changes", + path: "/repo/.skills/review", + filename: "SKILL.md", + options: {}, + }, + ], + }), + ); + + expect(result).toEqual({ + success: true, + skillName: "Review", + skillPath: "/repo/.skills/review", + content: "Skill directory: /repo/.skills/review\n\nRun review with --quick", }); +}); - test("skillTool returns helpful errors for missing or disabled skills", async () => { - const sandbox = { - workingDirectory: "/repo", - readFile: async () => "skill-body", - }; - - const missingResult = await skillTool.execute?.( - { skill: "unknown" }, - executionOptions({ ...createContext(sandbox), skills: [] }), - ); - - expect(missingResult).toEqual({ - success: false, - error: "Skill 'unknown' not found. Available skills: none", - }); +test("skillTool returns helpful errors for missing or disabled skills", async () => { + const sandbox = { + workingDirectory: "/repo", + readFile: async () => "skill-body", + }; - const disabledResult = await skillTool.execute?.( - { skill: "commit" }, - executionOptions({ - ...createContext(sandbox), - skills: [ - { - name: "commit", - description: "Create a commit", - path: "/repo/.skills/commit", - filename: "SKILL.md", - options: { disableModelInvocation: true }, - }, - ], - }), - ); + const missingResult = await skillTool.execute?.( + { skill: "unknown" }, + executionOptions({ ...createContext(sandbox), skills: [] }), + ); - expect(disabledResult).toEqual({ - success: false, - error: - "Skill 'commit' cannot be invoked by the model (disable-model-invocation is set)", - }); + expect(missingResult).toEqual({ + success: false, + error: "Skill 'unknown' not found. Available skills: none", }); - test("taskTool exposes both subagent types without approval gates", async () => { - const explorerNeedsApproval = await getNeedsApprovalResult( - taskTool.needsApproval, - { - subagentType: "explorer", - task: "Find usages", - instructions: "Search for helper usage", - }, - { - sandbox: { workingDirectory: "/repo" }, - model: "test-model", - approval: {}, - }, - ); - expect(explorerNeedsApproval).toBe(false); + const disabledResult = await skillTool.execute?.( + { skill: "commit" }, + executionOptions({ + ...createContext(sandbox), + skills: [ + { + name: "commit", + description: "Create a commit", + path: "/repo/.skills/commit", + filename: "SKILL.md", + options: { disableModelInvocation: true }, + }, + ], + }), + ); - const executorNeedsApproval = await getNeedsApprovalResult( - taskTool.needsApproval, - { - subagentType: "executor", - task: "Apply changes", - instructions: "Update files", - }, - { - sandbox: { workingDirectory: "/repo" }, - model: "test-model", - approval: {}, - }, - ); - expect(executorNeedsApproval).toBe(false); + expect(disabledResult).toEqual({ + success: false, + error: + "Skill 'commit' cannot be invoked by the model (disable-model-invocation is set)", }); +}); - test("taskTool description lists subagents from the shared registry", () => { - expect(taskTool.description).toContain( - "`explorer` - Use for read-only codebase exploration, tracing behavior, and answering questions without changing files", - ); - expect(taskTool.description).toContain( - "`executor` - Use for well-scoped implementation work, including edits, scaffolding, refactors, and other file changes", - ); - expect(taskTool.description).toContain("up to 100 tool steps"); - }); +test("taskTool exposes both subagent types without approval gates", async () => { + const explorerNeedsApproval = await getNeedsApprovalResult( + taskTool.needsApproval, + { + subagentType: "explorer", + task: "Find usages", + instructions: "Search for helper usage", + }, + { + sandbox: { workingDirectory: "/repo" }, + model: "test-model", + approval: {}, + }, + ); + expect(explorerNeedsApproval).toBe(false); + + const executorNeedsApproval = await getNeedsApprovalResult( + taskTool.needsApproval, + { + subagentType: "executor", + task: "Apply changes", + instructions: "Update files", + }, + { + sandbox: { workingDirectory: "/repo" }, + model: "test-model", + approval: {}, + }, + ); + expect(executorNeedsApproval).toBe(false); +}); - test("buildSystemPrompt lists subagents from the shared registry", () => { - const prompt = buildSystemPrompt({}); +test("taskTool description lists subagents from the shared registry", () => { + expect(taskTool.description).toContain( + "`explorer` - Use for read-only codebase exploration, tracing behavior, and answering questions without changing files", + ); + expect(taskTool.description).toContain( + "`executor` - Use for well-scoped implementation work, including edits, scaffolding, refactors, and other file changes", + ); + expect(taskTool.description).toContain("up to 100 tool steps"); +}); - expect(prompt).toContain("Available subagents:"); - expect(prompt).toContain( - "`explorer` - Use for read-only codebase exploration, tracing behavior, and answering questions without changing files", - ); - expect(prompt).toContain( - "`executor` - Use for well-scoped implementation work, including edits, scaffolding, refactors, and other file changes", - ); - }); +test("buildSystemPrompt lists subagents from the shared registry", () => { + const prompt = buildSystemPrompt({}); - test("todoWriteTool returns updated todo list metadata", async () => { - const todos = [ - { id: "1", content: "Write tests", status: "in_progress" as const }, - { id: "2", content: "Run checks", status: "pending" as const }, - ]; + expect(prompt).toContain("Available subagents:"); + expect(prompt).toContain( + "`explorer` - Use for read-only codebase exploration, tracing behavior, and answering questions without changing files", + ); + expect(prompt).toContain( + "`executor` - Use for well-scoped implementation work, including edits, scaffolding, refactors, and other file changes", + ); +}); - const result = await todoWriteTool.execute?.({ todos }, executionOptions()); +test("todoWriteTool returns updated todo list metadata", async () => { + const todos = [ + { id: "1", content: "Write tests", status: "in_progress" as const }, + { id: "2", content: "Run checks", status: "pending" as const }, + ]; - expect(result).toEqual({ - success: true, - message: "Updated task list with 2 items", - todos, - }); + const result = await todoWriteTool.execute?.({ todos }, executionOptions()); + + expect(result).toEqual({ + success: true, + message: "Updated task list with 2 items", + todos, }); }); diff --git a/packages/mcp/context.ts b/packages/mcp/context.ts index 738c350a..fcae6d10 100644 --- a/packages/mcp/context.ts +++ b/packages/mcp/context.ts @@ -6,4 +6,4 @@ export const context7 = { ? { Authorization: `Bearer ${process.env.CONTEXT7_API_KEY}` } : undefined, oauth: false as const, -} +}; diff --git a/packages/prompt/bot.ts b/packages/prompt/bot.ts index 01a69888..c23a1a3e 100644 --- a/packages/prompt/bot.ts +++ b/packages/prompt/bot.ts @@ -1,6 +1,10 @@ -import type { AgentConfigData, ThreadContext } from '../types' -import type { AgentConfig } from '../router/schema' -import { applyAgentConfig, applyComplexity, applyTemporalContext } from './shared' +import type { AgentConfigData, ThreadContext } from "../types"; +import type { AgentConfig } from "../router/schema"; +import { + applyAgentConfig, + applyComplexity, + applyTemporalContext, +} from "./shared"; export const BOT_SYSTEM_PROMPT = `You are a documentation assistant with bash access to a sandbox containing docs (markdown, JSON, YAML). {{TEMPORAL_CONTEXT}} @@ -70,49 +74,58 @@ You have access to a \`search_web\` tool for finding information NOT in the sand - **Contextualize your answer to the user's question.** If they ask about a feature in a specific framework, show that framework's config — not the underlying library's config. Adapt code examples to the framework they're asking about. - When a topic spans multiple sources, **cross-reference both** — search the specific source AND related docs. - Include code examples from the documentation -- Try to give a direct answer` +- Try to give a direct answer`; -export function buildBotSystemPrompt(context?: ThreadContext, agentConfig?: AgentConfig, savoirConfig?: AgentConfigData | null): string { - let prompt: string = applyTemporalContext(BOT_SYSTEM_PROMPT) +export function buildBotSystemPrompt( + context?: ThreadContext, + agentConfig?: AgentConfig, + savoirConfig?: AgentConfigData | null, +): string { + let prompt: string = applyTemporalContext(BOT_SYSTEM_PROMPT); if (savoirConfig) { - prompt = applyAgentConfig(prompt, savoirConfig) + prompt = applyAgentConfig(prompt, savoirConfig); } if (agentConfig) { - prompt = applyComplexity(prompt, agentConfig) + prompt = applyComplexity(prompt, agentConfig); } if (context) { - const ref = context.number ? `#${context.number}` : 'Thread' - prompt += `\n\n${ref}: "${context.title}" in ${context.source} (${context.platform})` + const ref = context.number ? `#${context.number}` : "Thread"; + prompt += `\n\n${ref}: "${context.title}" in ${context.source} (${context.platform})`; } - return prompt + return prompt; } -export function buildBotUserMessage(question: string, context?: ThreadContext): string { - const cleanQuestion = question.replace(/@[\w-]+(\[bot\])?/gi, '').trim() - const parts: string[] = [] +export function buildBotUserMessage( + question: string, + context?: ThreadContext, +): string { + const cleanQuestion = question.replace(/@[\w-]+(\[bot\])?/gi, "").trim(); + const parts: string[] = []; if (context) { if (context.body) { - parts.push(`**Description:**\n${context.body.slice(0, 1000)}`) + parts.push(`**Description:**\n${context.body.slice(0, 1000)}`); } if (context.previousComments?.length) { const relevant = context.previousComments - .filter(c => !c.isBot) + .filter((c) => !c.isBot) .slice(-2) - .map(c => `@${c.author}: ${c.body.slice(0, 200)}`) + .map((c) => `@${c.author}: ${c.body.slice(0, 200)}`); if (relevant.length) { - parts.push(`**Previous comments:**\n${relevant.join('\n')}`) + parts.push(`**Previous comments:**\n${relevant.join("\n")}`); } } } - parts.push(`**Question:**\n${cleanQuestion || context?.title || 'How can I help?'}`) + parts.push( + `**Question:**\n${cleanQuestion || context?.title || "How can I help?"}`, + ); - return parts.join('\n\n') + return parts.join("\n\n"); } From def18cad351278651d5564074aaf6827db5bc002 Mon Sep 17 00:00:00 2001 From: DevOps Bot Date: Mon, 27 Apr 2026 15:34:26 +0000 Subject: [PATCH 07/12] fix: import Github icon from @lucide/lab to resolve build error --- apps/web/app/get-started/get-started-flow.tsx | 13 +++++++------ apps/web/package.json | 1 + bun.lock | 3 +++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/web/app/get-started/get-started-flow.tsx b/apps/web/app/get-started/get-started-flow.tsx index f5354e6f..e55c6c2d 100644 --- a/apps/web/app/get-started/get-started-flow.tsx +++ b/apps/web/app/get-started/get-started-flow.tsx @@ -4,7 +4,8 @@ import { useCallback, useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { Check, Github, Loader2 } from "lucide-react"; +import { Check, Loader2, Icon } from "lucide-react"; +import { github } from "@lucide/lab"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { useSession } from "@/hooks/use-session"; @@ -304,9 +305,9 @@ function GitHubConnectStep({ className="size-8 rounded-full bg-zinc-800" /> ) : ( -
- -
+
+ +
)}

@@ -345,7 +346,7 @@ function GitHubConnectStep({ className="gap-2 border-zinc-700 bg-transparent text-zinc-300 hover:bg-white/5 hover:text-white" > - + Install GitHub App @@ -376,7 +377,7 @@ function GitHubConnectStep({ {isLinking ? ( ) : ( - + )} {forceReconnect ? "Reconnect GitHub" : "Connect GitHub"} diff --git a/apps/web/package.json b/apps/web/package.json index 1433ed67..c5eb0098 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -50,6 +50,7 @@ "ioredis": "^5.9.2", "jose": "^6.1.3", "lucide-react": "^1.11.0", + "@lucide/lab": "^0.1.2", "nanoid": "^5.1.6", "next": "16.2.3", "postgres": "^3.4.8", diff --git a/bun.lock b/bun.lock index 773c0555..3289e4d3 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "dependencies": { "@ai-sdk/elevenlabs": "^2.0.29", "@ai-sdk/react": "catalog:", + "@lucide/lab": "^0.1.2", "@octokit/auth-app": "^8.2.0", "@octokit/rest": "^22.0.1", "@open-agents/agent": "workspace:*", @@ -411,6 +412,8 @@ "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + "@lucide/lab": ["@lucide/lab@0.1.2", "", {}, "sha512-VprF2BJa7ZuTGOhUd5cf8tHJXyL63wdxcGieAiVVoR9hO0YmPsnZO0AGqDiX2/br+/MC6n8BoJcmPilltOXIJA=="], + "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], From c24f614c740b1fb9aab6cac660982131cb8d5c31 Mon Sep 17 00:00:00 2001 From: DevOps Bot Date: Mon, 27 Apr 2026 15:49:21 +0000 Subject: [PATCH 08/12] fix: apply code review suggestions for naming conventions and import paths --- apps/web/components/ai-elements/code-block.tsx | 4 ++-- packages/agent/tools/kilo.test.ts | 8 ++++---- packages/agent/tools/notepad.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/web/components/ai-elements/code-block.tsx b/apps/web/components/ai-elements/code-block.tsx index dc02f110..ccef52c3 100644 --- a/apps/web/components/ai-elements/code-block.tsx +++ b/apps/web/components/ai-elements/code-block.tsx @@ -77,7 +77,7 @@ const TokenSpan = ({ token }: { token: ThemedToken }) => ( ); // Line number styles using CSS counters -const LINE_NUMBER_CLASSES = cn( +const lineNumberClasses = cn( "block", "before:content-[counter(line)]", "before:inline-block", @@ -98,7 +98,7 @@ const LineSpan = ({ keyedLine: KeyedLine; showLineNumbers: boolean; }) => ( - + {keyedLine.tokens.length === 0 ? "\n" : keyedLine.tokens.map(({ token, key }) => ( diff --git a/packages/agent/tools/kilo.test.ts b/packages/agent/tools/kilo.test.ts index ddb1889f..86d8fec1 100644 --- a/packages/agent/tools/kilo.test.ts +++ b/packages/agent/tools/kilo.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; // Mock node-fetch -const fetchMock = mock(async (_url: string, _options: RequestInit) => { +const fetchMock = mock(async (unusedUrl: string, unusedOptions: RequestInit) => { return new Response( JSON.stringify({ jsonrpc: "2.0", result: { success: true }, id: 1 }), { @@ -90,7 +90,7 @@ describe("Context7Client", () => { expect(fetchMock).toHaveBeenCalledTimes(3); - const bodies = fetchMock.mock.calls.map(([_, options]) => + const bodies = fetchMock.mock.calls.map(([unusedUrl, options]) => JSON.parse((options as RequestInit)?.body as string), ); @@ -102,7 +102,7 @@ describe("Context7Client", () => { test("should handle empty params", async () => { await client.call("test.method"); - const [_, options] = fetchMock.mock.calls[0]; + const [unusedUrl, options] = fetchMock.mock.calls[0]; const body = JSON.parse((options as RequestInit)?.body as string); expect(body.params).toEqual({}); }); @@ -204,7 +204,7 @@ describe("Context7Client", () => { arguments: params, }); - const [_, options] = fetchMock.mock.calls[0]; + const [unusedUrl, options] = fetchMock.mock.calls[0]; const body = JSON.parse((options as RequestInit)?.body as string); expect(body.params.name).toBe("query-docs"); diff --git a/packages/agent/tools/notepad.ts b/packages/agent/tools/notepad.ts index 2f350447..e00cfa7d 100644 --- a/packages/agent/tools/notepad.ts +++ b/packages/agent/tools/notepad.ts @@ -10,7 +10,7 @@ import { getWorktreeNotepadPath, ensureOmcDir, validateWorkingDirectory, -} from "../lib/worktree-paths.js"; +} from "../lib/worktree-paths"; import { getPriorityContext, getWorkingMemory, @@ -22,7 +22,7 @@ import { getNotepadStats, formatFullNotepad, DEFAULT_CONFIG, -} from "../hooks/notepad/index.js"; +} from "../hooks/notepad/index"; import { ToolDefinition } from "./types.js"; const SECTION_NAMES: [string, ...string[]] = [ From e3c969bd94e900012be48acf664d70a82b41a8f8 Mon Sep 17 00:00:00 2001 From: billlzzz26 Date: Mon, 27 Apr 2026 22:51:47 +0700 Subject: [PATCH 09/12] Update scripts/clean-workspace.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- scripts/clean-workspace.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/clean-workspace.sh b/scripts/clean-workspace.sh index 872b7f4d..80e8e28f 100755 --- a/scripts/clean-workspace.sh +++ b/scripts/clean-workspace.sh @@ -1,16 +1,25 @@ #!/bin/bash +set -euo pipefail echo "🧹 Starting workspace cleanup to free up disk space..." # Go to project root (assuming script is in scripts/) -cd "$(dirname "$0")/.." +cd "$(dirname "$0")/.." || { echo "❌ Failed to cd to repo root"; exit 1; } + +# Refuse to run outside a git checkout to avoid accidental wipes. +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "❌ Not inside a git working tree; aborting." + exit 1 +fi echo "🗑️ Removing all node_modules directories..." # find and remove all node_modules safely find . -name "node_modules" -type d -prune -exec rm -rf '{}' + echo "🗑️ Cleaning Bun cache (~/.bun/install/cache)..." -rm -rf ~/.bun/install/cache/* +if [ -d "$HOME/.bun/install/cache" ]; then + rm -rf "$HOME/.bun/install/cache"/* +fi echo "🗑️ Cleaning npm cache..." npm cache clean --force 2>/dev/null From 39a8c8d6d40b43360137d9843d556585976c3a8a Mon Sep 17 00:00:00 2001 From: billlzzz26 Date: Mon, 27 Apr 2026 22:52:18 +0700 Subject: [PATCH 10/12] Update packages/agent/tools/github.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/agent/tools/github.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/agent/tools/github.ts b/packages/agent/tools/github.ts index 34c4018e..e0ceb7eb 100644 --- a/packages/agent/tools/github.ts +++ b/packages/agent/tools/github.ts @@ -19,7 +19,6 @@ USAGE: - Pushes the branch to origin - Uses the 'gh' CLI to create the PR`, inputSchema: z.object({ - needsApproval: z.literal(true), branchName: z .string() .describe("The name of the new branch to create and push"), From f634e85d6f343db285d49313e4e8df50060abd17 Mon Sep 17 00:00:00 2001 From: billlzzz26 Date: Mon, 27 Apr 2026 22:52:34 +0700 Subject: [PATCH 11/12] Update packages/agent/tools/lsp.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/agent/tools/lsp.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/agent/tools/lsp.ts b/packages/agent/tools/lsp.ts index cb11221e..e3d10fed 100644 --- a/packages/agent/tools/lsp.ts +++ b/packages/agent/tools/lsp.ts @@ -24,9 +24,9 @@ import { formatCodeActions, formatWorkspaceEdit, countEdits, -} from "./lsp/index.js"; -import { runDirectoryDiagnostics } from "./diagnostics/index.js"; -import { ToolDefinition } from "./types.js"; +} from "./lsp/index"; +import { runDirectoryDiagnostics } from "./diagnostics/index"; +import { ToolDefinition } from "./types"; /** * Helper to handle LSP errors gracefully. From 8f501b33895d8e2dd9817984f8655a4e80223485 Mon Sep 17 00:00:00 2001 From: billlzzz26 Date: Tue, 28 Apr 2026 10:58:33 +0000 Subject: [PATCH 12/12] feat: add Kilo AI Gateway provider support and devcontainer setup - Add Kilo AI Gateway client (packages/agent/providers/kilo/client.ts) - Update tools/kilo.ts to use new client path - Add Kilo model selection utilities and documentation in models.ts - Add comprehensive system-prompt docs for Kilo Gateway models - Add devcontainer config with Bun + Node.js setup - Add .github automation workflows and agent configs - Add VS Code AI model selection settings - Fix Dockerfile cache optimization and hook error logging - Remove legacy packages/agent/kilo/client.ts --- .devcontainer/Dockerfile | 18 +- .devcontainer/devcontainer.json | 21 +- .github/automations.yaml | 42 ++ .github/code-security.agent.md | 71 ++ .github/design-style-extractor.agent.md | 47 ++ .github/devcontainer-setup.prompt.md | 24 + .github/hooks/learn-signal-after-agent.json | 10 + .github/hooks/scripts/hook-bootstap.js | 29 + .github/hooks/scripts/learn.js | 634 ++++++++++++++++++ .github/modal-qwen-sandbox.agent.md | 43 ++ .gitig | 1 - .vscode/extensions.json | 6 +- .vscode/mcp.json | 57 ++ .vscode/settings.json | 17 +- packages/agent/models.ts | 177 +++++ packages/agent/{ => providers}/kilo/client.ts | 0 packages/agent/system-prompt.ts | 72 ++ packages/agent/tools/kilo.ts | 4 +- 18 files changed, 1260 insertions(+), 13 deletions(-) create mode 100644 .github/automations.yaml create mode 100644 .github/code-security.agent.md create mode 100644 .github/design-style-extractor.agent.md create mode 100644 .github/devcontainer-setup.prompt.md create mode 100644 .github/hooks/learn-signal-after-agent.json create mode 100644 .github/hooks/scripts/hook-bootstap.js create mode 100644 .github/hooks/scripts/learn.js create mode 100644 .github/modal-qwen-sandbox.agent.md delete mode 100644 .gitig create mode 100644 .vscode/mcp.json rename packages/agent/{ => providers}/kilo/client.ts (100%) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 05045092..50cba949 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,7 +1,19 @@ FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 -# Install Bun at the version pinned in package.json#packageManager +ARG BUN_VERSION=1.3.13 ENV BUN_INSTALL=/usr/local/bun ENV PATH="${BUN_INSTALL}/bin:${PATH}" -RUN curl -fsSL https://bun.sh/install | bash -s "bun-v1.2.14" \ - && bun --version + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean \ + && curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" \ + && bun --version + +# Install Node.js and npm for compatibility with tools that require them +# Node.js is used for npx and npm commands +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && node --version \ + && npm --version diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c0523cab..516315a8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,7 +2,10 @@ "name": "Open Agents", "build": { "context": ".", - "dockerfile": "Dockerfile" + "dockerfile": ".devcontainer/Dockerfile", + "args": { + "BUN_VERSION": "1.3.13" + } }, "forwardPorts": [3000], "portsAttributes": { @@ -17,8 +20,20 @@ "oxc.oxc-vscode", "bradlc.vscode-tailwindcss", "ms-vscode.vscode-typescript-next", - "esbenp.prettier-vscode" + "esbenp.prettier-vscode", + "EditorConfig.EditorConfig" ] } - } + }, + "settings": { + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.oxc": "explicit" + }, + "typescript.tsdk": "node_modules/typescript/lib", + "emmet.showExpandedAbbreviation": "never" + }, + "postCreateCommand": "bun install --frozen-lockfile" } diff --git a/.github/automations.yaml b/.github/automations.yaml new file mode 100644 index 00000000..edac91bb --- /dev/null +++ b/.github/automations.yaml @@ -0,0 +1,42 @@ +version: "1.0" +tasks: + - id: install-dependencies + label: "Install dependencies" + description: "Install Bun dependencies for the monorepo." + command: "bun install --frozen-lockfile" + + - id: dev-web + label: "Run web dev server" + description: "Start the web app development server on localhost:3000." + command: "bun run web" + runInBackground: true + + - id: lint-format + label: "Lint & format" + description: "Run repository lint and formatting checks." + command: "bun run fix" + + - id: typecheck + label: "Typecheck" + description: "Run TypeScript type checking across the monorepo." + command: "bun run typecheck" + + - id: test + label: "Run tests" + description: "Run the repository test suite." + command: "bun test" + + - id: ci + label: "Run CI checks" + description: "Run the full CI validation suite." + command: "bun run ci" + + - id: db-generate-migration + label: "DB: Generate migration" + description: "Generate a new Drizzle migration for the web app database." + command: "bun run --cwd apps/web db:generate" + + - id: db-studio + label: "DB: Studio" + description: "Launch the Drizzle DB Studio for local database exploration." + command: "bun run --cwd apps/web db:studio" diff --git a/.github/code-security.agent.md b/.github/code-security.agent.md new file mode 100644 index 00000000..e35b6cb1 --- /dev/null +++ b/.github/code-security.agent.md @@ -0,0 +1,71 @@ +--- +name: code-security +description: Perform a comprehensive security assessment across application code, configuration, and dependencies with OWASP-aligned scanning and remediation guidance. +--- + +# Code Security Agent + +This agent is designed for full security assessment tasks across the repository. It evaluates code, configuration, routes, input handling, dependency risk, and architecture for security vulnerabilities and misconfigurations. + +## When to use + +- Performing a general security audit of the repository or a specific area of code +- Checking changed files, current workspace files, or security-sensitive modules +- Reviewing authentication, authorization, API endpoints, database access, and deployment configuration +- Detecting insecure coding patterns, hardcoded secrets, and misuse of dependencies + +## What this agent does + +- Reads files and context relevant to security concerns +- Focuses on security-sensitive areas: auth flows, API endpoints, data access, config, middleware, and client-side input handling +- Scans for OWASP Top 10 vulnerabilities, XSS, CSRF, SSRF, security headers, cookies, and secret exposure +- Classifies findings by severity (Critical / High / Medium / Low) +- Suggests or applies fixes for Critical and High issues when safe and obvious +- Encourages documentation with JSDoc or Rustdoc for security-sensitive functions and APIs +- Uses Thai language for developer-facing guidance and local environments, and English for production-facing documentation and deployment contexts + +## Execution Steps + +1. Security Scoping + - Identify the relevant files and entrypoints for the requested audit + - Understand authentication, authorization, data flow, and user input vectors + +2. Vulnerability Scanning + - OWASP Top 10 coverage + - Additional patterns: XSS, CSRF, security headers, cookie flags, input validation, SSRF + - Detect hardcoded secrets, insecure defaults, stale dependencies, and unsafe runtime usage + +3. Severity Classification + - 🔴 Critical: SQL injection, auth bypass, hardcoded secrets, remote code execution, unsafe HTML insertion + - 🟠 High: missing authorization, command injection, insecure defaults, insecure session handling + - 🟡 Medium: missing CSRF, weak cookie settings, excessive logging of sensitive data, missing rate limiting + - 🟢 Low: best practice violations, missing security headers, code quality issues with security implications + +4. Remediation Guidance + - Recommend parameterized queries instead of string concatenation + - Replace unsafe DOM sinks with safe alternatives + - Move secrets into environment variables and configuration + - Add missing auth/authorization checks for sensitive operations + - Recommend security headers and secure cookie settings where appropriate + +## Detection Patterns + +- SQL injection: string concatenation or template literals in database queries +- XSS: `innerHTML`, `dangerouslySetInnerHTML`, `dangerouslySetInnerHTML`, unescaped template output +- CSRF: state-changing endpoints without CSRF tokens or SameSite cookie enforcement +- Hardcoded secrets: API keys, passwords, tokens in source files +- Missing auth: sensitive routes without authentication/authorization middleware or ownership enforcement +- Security headers: missing `X-Frame-Options`, `X-Content-Type-Options`, `Strict-Transport-Security`, `Content-Security-Policy` + +## Output expectations + +- A concise security summary of findings +- Severity-classified issues and remediation recommendations +- Automatic fixes for Critical and High findings when applicable +- Security best-practice suggestions for remaining issues + +## Documentation and Language Guidance + +- Prefer JSDoc comments in JavaScript/TypeScript code and Rustdoc comments in Rust code for public functions, security checks, and API behavior +- Use Thai when writing developer-facing notes, local environment guidance, and internal security comments +- Use English when writing production-facing documentation, deployment instructions, and public-facing security guidance diff --git a/.github/design-style-extractor.agent.md b/.github/design-style-extractor.agent.md new file mode 100644 index 00000000..1aa23bc5 --- /dev/null +++ b/.github/design-style-extractor.agent.md @@ -0,0 +1,47 @@ +--- +name: design-style-extractor +description: วิเคราะห์และสกัดสไตล์การออกแบบ (Design Style) และธีม (Theme) จากรูปภาพ เพื่อนำไปใช้ในการสร้างงานออกแบบใหม่หรือคุมโทนงาน ใช้เมื่อผู้ใช้ส่งรูปภาพตัวอย่างและต้องการทราบรายละเอียดองค์ประกอบทางดีไซน์ เช่น สี, ฟอนต์, และอารมณ์ของงาน +--- + +# Design Style Extractor + +Agent นี้มีหน้าที่วิเคราะห์รูปภาพเพื่อตีความสไตล์การออกแบบ และสรุปธีมการนำเสนอในเชิงปฏิบัติได้จริงสำหรับงานออกแบบต่อไป + +## เมื่อใช้ + +- ผู้ใช้ส่งรูปภาพตัวอย่างงานดีไซน์ +- ผู้ใช้ต้องการให้สรุปสไตล์หลัก, โทนสี, ฟอนต์, และบรรยากาศของงาน +- ผู้ใช้ต้องการแนวทางนำไปใช้ต่อ เช่น รีแบรนด์, สร้างคอนเซ็ปต์, หรือออกแบบธีม + +## งานของ agent + +- วิเคราะห์สีหลัก, สีรอง, สีเน้น +- ระบุประเภทฟอนต์, การจัดลำดับข้อความ, และน้ำหนักตัวอักษร +- ระบุรูปทรง, คอมโพส, เอฟเฟกต์ และบรรยากาศภาพรวม +- สังเคราะห์ธีมและ Mood ให้อ่านง่าย +- สร้างรายงานสรุปที่เหมาะสำหรับนักออกแบบกราฟิก, นักออกแบบ UI/UX, หรือทีมครีเอทีฟ + +## การทำงาน + +1. Visual Inventory: สำรวจองค์ประกอบพื้นฐาน เช่น สี, ฟอนต์, รูปทรง, พื้นผิว, และการจัดวาง +2. Style Identification: เปรียบเทียบองค์ประกอบกับสไตล์การออกแบบที่รู้จัก เช่น Modern, Minimal, Futuristic, Retro, และอื่น ๆ +3. Theme Synthesis: สรุป Mood และ Concept โดยใช้คำที่ชัดเจนและจับต้องได้ +4. Reporting: จัดผลลัพธ์เป็นรายงานที่ชัดเจน พร้อมตารางและคำอธิบายสั้น ๆ + +## แนวทางการวิเคราะห์ + +- Color Analysis: ระบุสีหลัก, สีรอง, สีเน้น และคำแนะนำการใช้งานแต่ละกลุ่มสี +- Typography: ระบุประเภทฟอนต์หลัก, โทนการจัดลำดับข้อความ, ความหนา, และคุณลักษณะของฟอนต์ +- Visual Elements: ระบุรูปทรง, ความโค้งมน, ระดับการใช้เส้น, เงา, ความโปร่งใส, หรือสไตล์พื้นผิว + +## Output รูปแบบแนะนำ + +- สรุปสไตล์หลักและ Mood +- ตารางสั้นสำหรับสี, ฟอนต์, และองค์ประกอบสำคัญ +- ข้อเสนอแนะการใช้งานต่อ เช่น แนวทางถึงการสร้างธีมต่อไป + +## ตัวอย่าง prompt ที่ใช้ + +- "วิเคราะห์ภาพดีไซน์นี้และสรุปสไตล์ สี ฟอนต์ และ Mood" +- "สกัดธีมการออกแบบจากภาพตัวอย่างเพื่อให้ฉันใช้ในการออกแบบหน้าเว็บใหม่" +- "ช่วยสรุปโทนสีและแนวทางภาพรวมของแบรนด์จากรูปภาพนี้" diff --git a/.github/devcontainer-setup.prompt.md b/.github/devcontainer-setup.prompt.md new file mode 100644 index 00000000..5c269941 --- /dev/null +++ b/.github/devcontainer-setup.prompt.md @@ -0,0 +1,24 @@ +--- +name: devcontainer-setup +description: Set up a fully working development environment as code for the current repository using devcontainer and VS Code automation files. +--- + +You are tasked with configuring a complete development environment for the current repository. + +Steps: + +1. Analyze the repository structure and determine the appropriate development environment requirements. +2. Create a `devcontainer.json` file for the repository root. +3. Create an `automations.yaml` file for any project-level automation workflows or setup tasks. +4. Create VS Code configuration files under `.vscode/`: `settings.json`, `tasks.json`, and `extensions.json`, `launch.json`. +5. Create shell scripts or makefiles for environment setup, lint, format, clean, and dead-code removal. +6. Ensure configuration is consistent with Bun-based tooling, repository package scripts, and any existing project conventions. +7. Create github workflow files under `.github/workflows/` for CI/CD processes relevant to the repository (e.g., build, test, lint). +8. Do not create any additional documentation files. +9. After creating the files, describe how to rebuild the devcontainer and verify the environment. + +Output: + +- Provide the exact file contents for each created file. +- Provide commands to rebuild the devcontainer and verify the workspace. +- Do not include documentation or explanatory files beyond the requested configuration files. diff --git a/.github/hooks/learn-signal-after-agent.json b/.github/hooks/learn-signal-after-agent.json new file mode 100644 index 00000000..b54b91b6 --- /dev/null +++ b/.github/hooks/learn-signal-after-agent.json @@ -0,0 +1,10 @@ +{ + "hooks": { + "PreCompact": [ + { + "type": "command", + "command": "node \"${.github}/hooks/scripts/hook-bootstrap.js\" \"${extensionPath}/hooks/scripts/learn.js\"", + } + ] + } +} diff --git a/.github/hooks/scripts/hook-bootstap.js b/.github/hooks/scripts/hook-bootstap.js new file mode 100644 index 00000000..4b46aa01 --- /dev/null +++ b/.github/hooks/scripts/hook-bootstap.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +function emitHookOutput(systemMessage = "") { + process.stdout.write( + JSON.stringify({ + decision: "allow", + systemMessage, + }), + ); +} + +async function main() { + const target = process.argv[2]; + if (typeof target !== "string" || !target.trim()) { + emitHookOutput(""); + return; + } + + const resolvedTarget = path.resolve(process.cwd(), target); + await import(pathToFileURL(resolvedTarget).href); +} + +main().catch((err) => { + console.error('Hook bootstrap error:', err); + emitHookOutput(""); +}); diff --git a/.github/hooks/scripts/learn.js b/.github/hooks/scripts/learn.js new file mode 100644 index 00000000..3bff6056 --- /dev/null +++ b/.github/hooks/scripts/learn.js @@ -0,0 +1,634 @@ +#!/usr/bin/env node +/** + * OmG Learn Signal Hook + * + * Safety-hardened learn nudger: + * - skips informational-only sessions + * - suppresses nudges while deep-interview lock is active + * - enforces cross-session cooldown to reduce repeat nudges + * - deduplicates repeated transcript snapshots + * - sanitizes legacy state before reuse + */ + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const QUIET_HOOKS_ENV = "OMG_HOOKS_QUIET"; +const STATE_ROOT_ENV = "OMG_STATE_ROOT"; +const HOOK_PROFILE_ENV = "OMG_HOOK_PROFILE"; +const DISABLED_HOOKS_ENV = "OMG_DISABLED_HOOKS"; +const ALLOW_DELEGATED_HOOKS_ENV = "OMG_ALLOW_DELEGATED_HOOKS"; +const DEFAULT_STATE_RELATIVE_PATH = path.join(".omg", "state", "learn-watch.json"); +const DEFAULT_DEEP_INTERVIEW_STATE_RELATIVE_PATH = path.join( + ".omg", + "state", + "deep-interview.json", +); + +const ACTION_KEYWORDS = [ + "build", + "implement", + "fix", + "refactor", + "test", + "write", + "add", + "remove", + "optimize", + "migrate", + "debug", + "ship", +]; + +const INFORMATIONAL_PREFIXES = [ + "what is", + "what are", + "why is", + "why are", + "how does", + "how do", + "explain", + "define", + "difference between", + "compare", +]; + +const LEARN_HOOK_KEYS = new Set([ + "learn", + "learn-signal", + "omg-learn-signal-after-agent", +]); + +function readStdinText() { + return new Promise((resolve) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + data += chunk; + }); + process.stdin.on("end", () => { + resolve(data); + }); + process.stdin.on("error", () => { + resolve(""); + }); + }); +} + +function safeJsonParse(text, fallback = null) { + try { + const normalized = + typeof text === "string" ? text.replace(/^\uFEFF/, "") : text; + return JSON.parse(normalized); + } catch { + return fallback; + } +} + +function isTruthy(value) { + if (typeof value !== "string") { + return false; + } + const normalized = value.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; +} + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function parseCsvEnv(value) { + if (typeof value !== "string") { + return []; + } + return value + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); +} + +function isDelegatedSubagentTurn(hookInput) { + const metadata = isObject(hookInput?.metadata) ? hookInput.metadata : {}; + const lane = typeof hookInput?.lane === "string" ? hookInput.lane.trim().toLowerCase() : ""; + const subagent = + typeof hookInput?.subagent === "string" + ? hookInput.subagent.trim().toLowerCase() + : typeof metadata?.subagent === "string" + ? metadata.subagent.trim().toLowerCase() + : ""; + + if (subagent && !["main", "primary", "root", "orchestrator", "director"].includes(subagent)) { + return true; + } + + if (lane && !["main", "primary", "root", "orchestration"].includes(lane)) { + return true; + } + + return [ + hookInput?.delegated, + hookInput?.is_delegated, + hookInput?.worker, + hookInput?.is_worker, + metadata?.delegated, + metadata?.is_delegated, + metadata?.worker, + metadata?.is_worker, + ].some((value) => value === true); +} + +function resolveSessionCwd(hookInput) { + const rawCwd = + typeof hookInput?.cwd === "string" && hookInput.cwd.trim() + ? hookInput.cwd.trim() + : ""; + if (!rawCwd) { + return null; + } + + const resolved = path.resolve(rawCwd); + try { + if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { + return resolved; + } + } catch { + return null; + } + + return null; +} + +function resolveHookProfile() { + const raw = + typeof process.env[HOOK_PROFILE_ENV] === "string" + ? process.env[HOOK_PROFILE_ENV].trim().toLowerCase() + : ""; + if (raw === "minimal" || raw === "balanced" || raw === "strict") { + return raw; + } + return "balanced"; +} + +function isHookDisabled(disabledHooks, candidates) { + for (const candidate of candidates) { + if (disabledHooks.includes(candidate)) { + return true; + } + } + return false; +} + +function resolveStatePath(cwd) { + const customStateRoot = process.env[STATE_ROOT_ENV]; + if (typeof customStateRoot === "string" && customStateRoot.trim()) { + const stateRoot = path.isAbsolute(customStateRoot) + ? customStateRoot.trim() + : cwd + ? path.join(cwd, customStateRoot.trim()) + : ""; + if (!stateRoot) { + return null; + } + return path.join(stateRoot, "learn-watch.json"); + } + if (!cwd) { + return null; + } + return path.join(cwd, DEFAULT_STATE_RELATIVE_PATH); +} + +function resolveDeepInterviewStatePath(cwd) { + const customStateRoot = process.env[STATE_ROOT_ENV]; + if (typeof customStateRoot === "string" && customStateRoot.trim()) { + const stateRoot = path.isAbsolute(customStateRoot) + ? customStateRoot.trim() + : cwd + ? path.join(cwd, customStateRoot.trim()) + : ""; + if (!stateRoot) { + return null; + } + return path.join(stateRoot, "deep-interview.json"); + } + if (!cwd) { + return null; + } + return path.join(cwd, DEFAULT_DEEP_INTERVIEW_STATE_RELATIVE_PATH); +} + +function hashText(text) { + return crypto.createHash("sha256").update(text, "utf8").digest("hex"); +} + +function buildEventKey(sessionId, transcriptPath, transcriptRaw) { + const sessionPart = typeof sessionId === "string" && sessionId ? sessionId : "unknown"; + if (typeof transcriptRaw === "string") { + return `${sessionPart}:transcript:${hashText(transcriptRaw)}`; + } + const sourcePart = typeof transcriptPath === "string" && transcriptPath ? transcriptPath : "missing"; + return `${sessionPart}:transcript-missing:${sourcePart}`; +} + +function normalizeState(state) { + if (!state || typeof state !== "object") { + return {}; + } + return { + last_session_id: typeof state.last_session_id === "string" ? state.last_session_id : "", + last_event_key: typeof state.last_event_key === "string" ? state.last_event_key : "", + last_prompted_session_id: + typeof state.last_prompted_session_id === "string" ? state.last_prompted_session_id : "", + last_reason: typeof state.last_reason === "string" ? state.last_reason : "", + last_prompted_at: + typeof state.last_prompted_at === "string" ? state.last_prompted_at : "", + last_user_message_count: Number.isFinite(Number(state.last_user_message_count)) + ? Number(state.last_user_message_count) + : 0, + last_actionable_message_count: Number.isFinite(Number(state.last_actionable_message_count)) + ? Number(state.last_actionable_message_count) + : 0, + }; +} + +function readState(statePath) { + if (!statePath) { + return {}; + } + try { + const raw = fs.readFileSync(statePath, "utf8"); + return normalizeState(safeJsonParse(raw, {})); + } catch { + return {}; + } +} + +function readDeepInterviewState(statePath) { + if (!statePath) { + return null; + } + try { + const raw = fs.readFileSync(statePath, "utf8"); + const parsed = safeJsonParse(raw, null); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function parseTimestamp(value) { + if (typeof value !== "string" || !value.trim()) { + return null; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function isDeepInterviewLockActive(state, nowMs = Date.now()) { + if (!state || typeof state !== "object") { + return false; + } + + if (state.active === false || state.locked === false) { + return false; + } + + if (typeof state.released_at === "string" && state.released_at.trim()) { + return false; + } + + const expiresAt = parseTimestamp(state.expires_at); + if (expiresAt !== null && expiresAt <= nowMs) { + return false; + } + + if (state.active === true || state.locked === true) { + return true; + } + + const status = + typeof state.status === "string" ? state.status.trim().toLowerCase() : ""; + if (["active", "locked", "in-progress", "running", "interviewing"].includes(status)) { + return true; + } + if ( + [ + "done", + "completed", + "released", + "inactive", + "idle", + "stopped", + "failed", + "cancelled", + "canceled", + ].includes(status) + ) { + return false; + } + + // Backward-compatible fallback: treat recently updated lock snapshots as active + // even when no explicit boolean/status flag exists. + const updatedAt = parseTimestamp(state.updated_at); + if (updatedAt !== null && nowMs - updatedAt <= 2 * 60 * 60 * 1000) { + return true; + } + + return false; +} + +function writeState(statePath, state) { + if (!statePath) { + return; + } + try { + const dir = path.dirname(statePath); + fs.mkdirSync(dir, { recursive: true }); + const tempPath = path.join( + dir, + `${path.basename(statePath)}.${process.pid}.${Date.now()}.tmp`, + ); + fs.writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + fs.renameSync(tempPath, statePath); + } catch { + // Fail-open: learn hook must never block the parent workflow. + } +} + +function emitHookOutput(systemMessage) { + const output = { + decision: "allow", + systemMessage, + }; + process.stdout.write(JSON.stringify(output)); +} + +function asText(value) { + if (typeof value === "string") { + return value; + } + if (Array.isArray(value)) { + return value.map((item) => asText(item)).filter(Boolean).join(" "); + } + if (value && typeof value === "object") { + if (typeof value.text === "string") { + return value.text; + } + if (typeof value.content === "string" || Array.isArray(value.content) || (value.content && typeof value.content === "object")) { + return asText(value.content); + } + if (typeof value.value === "string") { + return value.value; + } + } + return ""; +} + +function normalizeText(text) { + return text + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); +} + +function containsAny(text, terms) { + for (const term of terms) { + if (text.includes(term)) { + return true; + } + } + return false; +} + +function isInformationalOnlyQuery(text) { + for (const prefix of INFORMATIONAL_PREFIXES) { + if (text.startsWith(prefix)) { + return true; + } + } + return false; +} + +function classifySession(userTexts) { + let nonEmptyCount = 0; + let actionableCount = 0; + let informationalCount = 0; + + for (const rawText of userTexts) { + const text = normalizeText(rawText); + if (!text) { + continue; + } + nonEmptyCount += 1; + + const hasAction = containsAny(text, ACTION_KEYWORDS); + if (hasAction) { + actionableCount += 1; + continue; + } + + if (isInformationalOnlyQuery(text)) { + informationalCount += 1; + } + } + + return { + nonEmptyCount, + actionableCount, + informationalOnly: + nonEmptyCount > 0 && actionableCount === 0 && informationalCount === nonEmptyCount, + }; +} + +function loadLearnConfig(cwd) { + const defaults = { + minSessionLength: 10, + learnedSkillsPath: ".omg/rules/learned/", + promptOncePerSession: true, + promptCooldownMinutes: 45, + }; + if (!cwd) { + return defaults; + } + const configPath = path.join(cwd, ".omg", "rules", "learn.json"); + if (!fs.existsSync(configPath)) { + return defaults; + } + + const config = safeJsonParse(fs.readFileSync(configPath, "utf8"), {}); + if (!config || typeof config !== "object") { + return defaults; + } + + const minSessionLength = + Number.isFinite(Number(config.min_session_length)) && Number(config.min_session_length) > 0 + ? Number(config.min_session_length) + : defaults.minSessionLength; + const learnedSkillsPath = + typeof config.learned_skills_path === "string" && config.learned_skills_path.trim() + ? config.learned_skills_path.trim() + : defaults.learnedSkillsPath; + const promptOncePerSession = + typeof config.prompt_once_per_session === "boolean" + ? config.prompt_once_per_session + : defaults.promptOncePerSession; + const promptCooldownMinutes = + Number.isFinite(Number(config.prompt_cooldown_minutes)) && + Number(config.prompt_cooldown_minutes) >= 0 + ? Number(config.prompt_cooldown_minutes) + : defaults.promptCooldownMinutes; + + return { + minSessionLength, + learnedSkillsPath, + promptOncePerSession, + promptCooldownMinutes, + }; +} + +function isPromptCooldownActive(state, cooldownMinutes, nowMs = Date.now()) { + if (!Number.isFinite(cooldownMinutes) || cooldownMinutes <= 0) { + return false; + } + + const lastPromptedAt = parseTimestamp(state?.last_prompted_at); + if (lastPromptedAt === null) { + return false; + } + + return nowMs - lastPromptedAt < cooldownMinutes * 60 * 1000; +} + +async function main() { + const rawInput = await readStdinText(); + const hookInput = safeJsonParse(rawInput, {}); + + const sessionCwd = resolveSessionCwd(hookInput); + const sessionId = + typeof hookInput?.session_id === "string" && hookInput.session_id + ? hookInput.session_id + : "unknown"; + const transcriptPath = + typeof hookInput?.transcript_path === "string" && hookInput.transcript_path + ? hookInput.transcript_path + : ""; + const quietHooks = isTruthy(process.env[QUIET_HOOKS_ENV]); + const hookProfile = resolveHookProfile(); + const disabledHooks = parseCsvEnv(process.env[DISABLED_HOOKS_ENV]); + const statePath = resolveStatePath(sessionCwd); + const deepInterviewStatePath = resolveDeepInterviewStatePath(sessionCwd); + const prevState = readState(statePath); + const config = loadLearnConfig(sessionCwd); + const deepInterviewState = readDeepInterviewState(deepInterviewStatePath); + const deepInterviewLockActive = isDeepInterviewLockActive(deepInterviewState); + const nowMs = Date.now(); + const nowIso = new Date(nowMs).toISOString(); + const learnHookDisabled = + hookProfile === "minimal" || isHookDisabled(disabledHooks, [...LEARN_HOOK_KEYS]); + + if (learnHookDisabled) { + emitHookOutput(""); + return; + } + const allowDelegatedHooks = isTruthy(process.env[ALLOW_DELEGATED_HOOKS_ENV]); + if (!allowDelegatedHooks && isDelegatedSubagentTurn(hookInput)) { + emitHookOutput(""); + return; + } + + if (!statePath) { + emitHookOutput(""); + return; + } + + if (!transcriptPath || !fs.existsSync(transcriptPath)) { + const eventKey = buildEventKey(sessionId, transcriptPath, null); + const duplicateEvent = + prevState.last_session_id === sessionId && prevState.last_event_key === eventKey; + if (duplicateEvent) { + emitHookOutput(""); + return; + } + writeState(statePath, { + ...prevState, + last_session_id: sessionId, + last_event_key: eventKey, + last_reason: "missing-transcript", + updated_at: new Date().toISOString(), + }); + emitHookOutput(""); + return; + } + + const transcriptRaw = fs.readFileSync(transcriptPath, "utf8"); + const eventKey = buildEventKey(sessionId, transcriptPath, transcriptRaw); + const duplicateEvent = + prevState.last_session_id === sessionId && prevState.last_event_key === eventKey; + if (duplicateEvent) { + emitHookOutput(""); + return; + } + + const transcript = safeJsonParse(transcriptRaw, { messages: [] }); + const messages = Array.isArray(transcript.messages) ? transcript.messages : []; + const userTexts = messages + .filter((msg) => msg?.type === "user") + .map((msg) => asText(msg?.content ?? msg?.message ?? msg?.text)); + + const messageCount = userTexts.filter((text) => normalizeText(text).length > 0).length; + const classification = classifySession(userTexts); + let reason = ""; + let shouldPrompt = false; + + if (messageCount < config.minSessionLength) { + reason = "short-session"; + } else if (deepInterviewLockActive) { + reason = "deep-interview-lock-active"; + } else if ( + isPromptCooldownActive(prevState, config.promptCooldownMinutes, nowMs) + ) { + reason = "prompt-cooldown-active"; + } else if (classification.informationalOnly) { + reason = "informational-only"; + } else if ( + config.promptOncePerSession && + prevState.last_prompted_session_id === sessionId + ) { + reason = "already-prompted-this-session"; + } else { + shouldPrompt = true; + reason = "actionable-session-detected"; + } + + const systemMessage = + shouldPrompt && !quietHooks + ? `[OMG][Learn] Actionable session signals detected (${classification.actionableCount}/${messageCount}). Run '/omg:learn' to extract reusable patterns into ${config.learnedSkillsPath}.` + : ""; + + writeState(statePath, { + ...prevState, + last_session_id: sessionId, + last_event_key: eventKey, + last_prompted_session_id: shouldPrompt ? sessionId : prevState.last_prompted_session_id || "", + last_prompted_at: shouldPrompt ? nowIso : prevState.last_prompted_at || "", + last_reason: reason, + last_user_message_count: messageCount, + last_actionable_message_count: classification.actionableCount, + deep_interview_lock_active: deepInterviewLockActive, + deep_interview_lock_source: deepInterviewStatePath, + updated_at: nowIso, + }); + + emitHookOutput(systemMessage); +} + +main().catch((err) => { + const quietHooks = isTruthy(process.env[QUIET_HOOKS_ENV]); + const fallback = { + decision: "allow", + systemMessage: quietHooks + ? "" + : `[OMG][Learn] monitor-hook error: ${err?.message || String(err)}\n${err?.stack || ''}`, + }; + process.stdout.write(JSON.stringify(fallback)); +}); diff --git a/.github/modal-qwen-sandbox.agent.md b/.github/modal-qwen-sandbox.agent.md new file mode 100644 index 00000000..fd04e150 --- /dev/null +++ b/.github/modal-qwen-sandbox.agent.md @@ -0,0 +1,43 @@ +--- +name: modal-qwen-sandbox +description: ช่วยสร้างและรัน Qwen-Code ภายใน Modal Sandbox โดยใช้ฟีเจอร์ Docker in Sandboxes (Alpha) แทนการรัน Docker/Podman ในเครื่องท้องถิ่น +--- + +# Modal Qwen Sandbox + +Agent นี้ถูกออกแบบมาเพื่อช่วยผู้ใช้สร้าง Sandbox บน Modal.com ที่เปิดใช้งาน Docker daemon ภายใน และรัน Qwen-Code CLI โดยตรงภายใน Sandbox นั้น + +## เมื่อควรใช้ + +- ต้องการรัน Qwen-Code บน Modal.com แทน Docker/Podman ภายในเครื่อง +- ต้องการเปิดใช้งาน Docker ภายใน Sandbox ด้วย `experimental_options={"enable_docker": True}` +- ต้องการสร้างและใช้ภาพจาก `.qwen/sandbox.Dockerfile` +- ต้องการติดตั้ง Qwen-Code จาก source แล้วรัน CLI ภายใน Sandbox + +## ความสามารถหลัก + +- สร้างโครงสร้างไฟล์ที่จำเป็น เช่น `.qwen/sandbox.Dockerfile` +- เขียนสคริปต์ Python สำหรับสร้าง Modal Sandbox พร้อม Docker +- แนะนำการติดตั้ง Docker daemon ภายใน Sandbox +- แนะนำการ clone, build, และติดตั้ง Qwen-Code จาก source +- ให้คำแนะนำการเชื่อมต่อกับ Sandbox ผ่าน `modal shell` + +## คำแนะนำการใช้งาน + +1. สร้างไฟล์ `.qwen/sandbox.Dockerfile` ตามสเปคของ Qwen-Code +2. สร้างสคริปต์ `modal_qwen_sandbox.py` ซึ่งใช้ `modal` SDK และ `experimental_options={"enable_docker": True}` +3. สร้าง Docker image สำหรับ Sandbox ที่ติดตั้ง Docker CLI, daemon, Node.js และ dependency ที่จำเป็น +4. รัน Sandbox, start Docker daemon, และติดตั้ง Qwen-Code source +5. เชื่อมต่อเข้า Sandbox ด้วย `modal shell --sandbox-id ` เพื่อใช้ Qwen-Code CLI + +## ผลลัพธ์ที่คาดหวัง + +- Sandbox ที่ใช้งานได้พร้อม Docker daemon ภายใน +- Qwen-Code CLI ถูกติดตั้งจาก source และใช้งานได้ภายใน Sandbox +- ไฟล์ `.qwen/sandbox.Dockerfile` ถูกสร้างและสามารถใช้ build image สำหรับ Qwen-Code + +## ตัวอย่าง prompt + +- "ช่วยสร้างสคริปต์ Modal เพื่อรัน Qwen-Code ด้วย Docker in Sandboxes" +- "อธิบายวิธีใช้งาน Modal Sandbox สำหรับ Qwen-Code โดยไม่ใช้ Docker/Podman ในเครื่อง" +- "เขียน `modal_qwen_sandbox.py` ที่ติดตั้ง Docker daemon ใน Sandbox และ build `.qwen/sandbox.Dockerfile`" diff --git a/.gitig b/.gitig deleted file mode 100644 index b9f40a7b..00000000 --- a/.gitig +++ /dev/null @@ -1 +0,0 @@ -ore diff --git a/.vscode/extensions.json b/.vscode/extensions.json index f7a820a7..2d59d0dd 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,9 +1,11 @@ -{ + { "recommendations": [ "oxc.oxc-vscode", "bradlc.vscode-tailwindcss", "ms-vscode.vscode-typescript-next", "esbenp.prettier-vscode", - "EditorConfig.EditorConfig" + "EditorConfig.EditorConfig", + "GitHub.copilot", + "GitHub.copilot-chat" ] } diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 00000000..fd5f02d0 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,57 @@ +{ + "servers": { + "io.github.upstash/context7": { + "type": "stdio", + "command": "npx", + "args": [ + "@upstash/context7-mcp@1.0.31" + ], + "env": { + "CONTEXT7_API_KEY": "${input:CONTEXT7_API_KEY}" + }, + "gallery": "https://api.mcp.github.com", + "version": "1.0.31" + }, + "microsoft/playwright-mcp": { + "type": "stdio", + "command": "npx", + "args": [ + "@playwright/mcp@latest" + ], + "gallery": "https://api.mcp.github.com", + "version": "0.0.1-seed" + }, + "oraios/serena": { + "type": "stdio", + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "serena@latest", + "--context", + "ide-assistant" + ], + "gallery": "https://api.mcp.github.com", + "version": "1.0.0" + }, + "io.github.vercel/next-devtools-mcp": { + "type": "stdio", + "command": "npx", + "args": [ + "next-devtools-mcp@0.3.6" + ], + "gallery": "https://api.mcp.github.com", + "version": "0.3.6" + } + }, + "inputs": [ + { + "id": "CONTEXT7_API_KEY", + "type": "promptString", + "description": "API key for authentication", + "password": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a6f8ab7..ebf25d13 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,7 +26,7 @@ "editor.defaultFormatter": "oxc.oxc-vscode" }, "[jsonc]": { - "editor.defaultFormatter": "oxc.oxc-vscode" + "editor.defaultFormatter": "vscode.json-language-features" }, "[less]": { "editor.defaultFormatter": "oxc.oxc-vscode" @@ -50,9 +50,22 @@ "editor.defaultFormatter": "oxc.oxc-vscode" }, "[yaml]": { - "editor.defaultFormatter": "oxc.oxc-vscode" + "editor.defaultFormatter": "esbenp.prettier-vscode" }, "editor.codeActionsOnSave": { "source.fixAll.oxc": "explicit" + }, + "ai.experimental.models": { + "inlineCompletions": "anthropic/claude-sonnet-4.6", + "autocomplete": "anthropic/claude-haiku-4.5", + "chat": "anthropic/claude-sonnet-4.6" + }, + "inlineChat.mode": "live", + "inlineChat.showToolbar": "always", + "[prompt]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[chatagent]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" } } diff --git a/packages/agent/models.ts b/packages/agent/models.ts index eb4f7b40..14cedb91 100644 --- a/packages/agent/models.ts +++ b/packages/agent/models.ts @@ -100,6 +100,183 @@ export interface GatewayOptions { export type { GatewayModelId, LanguageModel, JSONValue }; +/** + * Kilo AI Gateway model selection utilities. + * + * The Kilo Gateway provides access to hundreds of models via the unified + * "provider/model-name" format (e.g. "anthropic/claude-sonnet-4.6"). + * + * Auto models: + * - kilo-auto/frontier — Highest performance (Claude Opus 4.7 / Sonnet 4.6) + * - kilo-auto/balanced — Price/capability balance (Qwen 3.6+ / GPT-5.3-codex / Claude Haiku 4.5) + * - kilo-auto/free — Curated free models (rate-limited) + * - kilo-auto/small — Fast small models for summaries/background tasks + * + * Common provider models: + * - anthropic/claude-opus-4.7 + * - anthropic/claude-sonnet-4.6 + * - anthropic/claude-haiku-4.5 + * - openai/gpt-5.4 + * - openai/gpt-5.4-mini + * - google/gemini-3.1-pro-preview + * - google/gemini-2.5-flash + * - x-ai/grok-4 + * - x-ai/grok-code-fast-1 + * - deepseek/deepseek-v3.2 + * - moonshotai/kimi-k2.5 + * + * Free models: + * - bytedance-seed/dola-seed-2.0-pro:free + * - x-ai/grok-code-fast-1:optimized:free + * - nvidia/nemotron-3-super-120b-a12b:free + * - arcee-ai/trinity-large-thinking:free + * - openrouter/free + */ + +function isKiloAutoModel(modelId: string): boolean { + return modelId.startsWith("kilo-auto/"); +} + +/** + * Returns the Kilo Auto mode from a model ID (e.g. "frontier" from "kilo-auto/frontier"), + * or null if not a kilo-auto model. + */ +function getKiloAutoMode(modelId: string): string | null { + if (!isKiloAutoModel(modelId)) return null; + const parts = modelId.split("/"); + return parts.length === 2 ? parts[1] : null; +} + +/** + * Returns the provider from a model ID (e.g. "anthropic" from "anthropic/claude-sonnet-4.6"), + * or null if not a provider/model format. + */ +function getModelProvider(modelId: string): string | null { + if (isKiloAutoModel(modelId)) return "kilo"; + const parts = modelId.split("/"); + return parts.length === 2 ? parts[0] : null; +} + +export function shouldApplyOpenAIReasoningDefaults(modelId: string): boolean { + return modelId.startsWith("openai/gpt-5"); +} + +function shouldApplyOpenAITextVerbosityDefaults(modelId: string): boolean { + return modelId.startsWith("openai/gpt-5.4"); +} + +function getAnthropicSettings(modelId: string): AnthropicLanguageModelOptions { + if (supportsAdaptiveAnthropicThinking(modelId)) { + return { + effort: "medium", + thinking: { type: "adaptive" }, + } satisfies AnthropicLanguageModelOptions; + } + + return { + thinking: { type: "enabled", budgetTokens: 8000 }, + }; +} + +export function getProviderOptionsForModel( + modelId: string, + providerOptionsOverrides?: ProviderOptionsByProvider, +): ProviderOptionsByProvider { + const defaultProviderOptions: ProviderOptionsByProvider = {}; + + const provider = getModelProvider(modelId); + + // Apply anthropic defaults + if (provider === "anthropic") { + defaultProviderOptions.anthropic = toProviderOptionsRecord( + getAnthropicSettings(modelId), + ); + } + + // OpenAI model responses should never be persisted. + if (provider === "openai") { + defaultProviderOptions.openai = toProviderOptionsRecord({ + store: false, + } satisfies OpenAIResponsesProviderOptions); + } + + // Apply OpenAI defaults for all GPT-5 variants to expose encrypted reasoning content. + // This avoids Responses API failures when `store: false`, + // e.g. "Item with id '...' not found. Items are not persisted when `store` is set to false." + if (shouldApplyOpenAIReasoningDefaults(modelId)) { + defaultProviderOptions.openai = mergeRecords( + defaultProviderOptions.openai ?? {}, + toProviderOptionsRecord({ + reasoningSummary: "detailed", + include: ["reasoning.encrypted_content"], + } satisfies OpenAIResponsesProviderOptions), + ); + } + + if (shouldApplyOpenAITextVerbosityDefaults(modelId)) { + defaultProviderOptions.openai = mergeRecords( + defaultProviderOptions.openai ?? {}, + toProviderOptionsRecord({ + textVerbosity: "low", + } satisfies OpenAIResponsesProviderOptions), + ); + } + + const providerOptions = mergeProviderOptions( + defaultProviderOptions, + providerOptionsOverrides, + ); + + // Enforce OpenAI non-persistence even when custom provider overrides are present. + if (provider === "openai") { + providerOptions.openai = mergeRecords( + providerOptions.openai ?? {}, + toProviderOptionsRecord({ + store: false, + } satisfies OpenAIResponsesProviderOptions), + ); + } + + return providerOptions; +} + +export function gateway( + modelId: GatewayModelId, + options: GatewayOptions = {}, +): LanguageModel { + const { config, providerOptionsOverrides } = options; + + // Use custom gateway config or default AI SDK gateway + const baseGateway = config + ? createGateway({ baseURL: config.baseURL, apiKey: config.apiKey }) + : aiGateway; + + let model: LanguageModel = baseGateway(modelId); + + const providerOptions = getProviderOptionsForModel( + modelId, + providerOptionsOverrides, + ); + + if (Object.keys(providerOptions).length > 0) { + model = wrapLanguageModel({ + model, + middleware: defaultSettingsMiddleware({ + settings: { providerOptions }, + }), + }); + } + + return model; +} + +export interface GatewayOptions { + config?: GatewayConfig; + providerOptionsOverrides?: ProviderOptionsByProvider; +} + +export type { GatewayModelId, LanguageModel, JSONValue }; + export function shouldApplyOpenAIReasoningDefaults(modelId: string): boolean { return modelId.startsWith("openai/gpt-5"); } diff --git a/packages/agent/kilo/client.ts b/packages/agent/providers/kilo/client.ts similarity index 100% rename from packages/agent/kilo/client.ts rename to packages/agent/providers/kilo/client.ts diff --git a/packages/agent/system-prompt.ts b/packages/agent/system-prompt.ts index 5960feed..1efb3954 100644 --- a/packages/agent/system-prompt.ts +++ b/packages/agent/system-prompt.ts @@ -22,6 +22,78 @@ function detectModelFamily(modelId: string | undefined): ModelFamily { return "other"; } +/** + * Detects if the given model ID is a Kilo AI Gateway model. + * Kilo models use the format "provider/model-name" (e.g. "anthropic/claude-sonnet-4.6") + * or the special "kilo-auto/*" virtual models that auto-select the best underlying model. + */ +function isKiloModel(modelId: string | undefined): boolean { + if (!modelId) return false; + // kilo-auto/* virtual models + if (modelId.startsWith("kilo-auto/")) return true; + // provider/model-name format with a slash + return modelId.includes("/"); +} + +/** + * Gets the model display name for a given model ID. + * For Kilo Gateway models, returns the full model ID. + * For non-Kilo models (e.g. local ollama), returns the model ID as-is. + */ +function getModelDisplayName(modelId: string | undefined): string { + if (!modelId) return "(no model specified)"; + if (isKiloModel(modelId)) { + // Map auto models to friendly names + if (modelId === "kilo-auto/frontier") return "kilo-auto/frontier (Claude Opus 4.7 / Sonnet 4.6)"; + if (modelId === "kilo-auto/balanced") return "kilo-auto/balanced (Qwen 3.6+ / GPT-5.3-codex / Claude Haiku 4.5)"; + if (modelId === "kilo-auto/free") return "kilo-auto/free (curated free models)"; + if (modelId === "kilo-auto/small") return "kilo-auto/small (Gemma 4 31B/26B)"; + } + return modelId; +} + +// --------------------------------------------------------------------------- +// Available Kilo AI Gateway Models Reference +// --------------------------------------------------------------------------- +// +// The Kilo AI Gateway provides access to hundreds of AI models through a +// single unified API. Models are specified using the format: +// +// provider/model-name +// +// Example: "anthropic/claude-sonnet-4.6", "openai/gpt-5.4" +// +// Auto models (kilo-auto/*) automatically select the best model based on +// task type and the x-kilocode-mode header: +// +// - kilo-auto/frontier — Highest performance (Claude Opus 4.7, Sonnet 4.6) +// - kilo-auto/balanced — Price/capability balance (Qwen 3.6+, GPT-5.3-codex, Claude Haiku 4.5) +// - kilo-auto/free — Free tier models (rate-limited) +// - kilo-auto/small — Fast small models for summaries/background tasks +// +// Popular models: +// - anthropic/claude-opus-4.7 — Most capable Claude for complex reasoning +// - anthropic/claude-sonnet-4.6 — Balanced performance and cost +// - anthropic/claude-haiku-4.5 — Fast and cost-effective +// - openai/gpt-5.4 — Latest GPT model +// - openai/gpt-5.4-mini — Fast and efficient +// - google/gemini-3.1-pro-preview — Advanced reasoning +// - google/gemini-2.5-flash — Fast and efficient +// - x-ai/grok-4 — Most capable Grok model +// - x-ai/grok-code-fast-1 — Optimized for code tasks +// - deepseek/deepseek-v3.2 — Strong coding and reasoning +// - moonshotai/kimi-k2.5 — Strong coding and multilingual +// +// Free models available at no cost (subject to rate limits): +// - bytedance-seed/dola-seed-2.0-pro:free +// - x-ai/grok-code-fast-1:optimized:free +// - nvidia/nemotron-3-super-120b-a12b:free +// - arcee-ai/trinity-large-thinking:free +// - openrouter/free +// +// See: https://api.kilo.ai/api/gateway/models (no auth required) +// --------------------------------------------------------------------------- + // --------------------------------------------------------------------------- // Core system prompt -- shared across all model families // --------------------------------------------------------------------------- diff --git a/packages/agent/tools/kilo.ts b/packages/agent/tools/kilo.ts index 5401ec7c..3a6f034b 100644 --- a/packages/agent/tools/kilo.ts +++ b/packages/agent/tools/kilo.ts @@ -1,6 +1,6 @@ import { tool } from "ai"; import { z } from "zod"; -import { KiloClient } from "../kilo/client"; +import { KiloClient } from "../providers/kilo/client"; const kiloInputSchema = z.object({ action: z.enum(["list_models", "list_providers", "chat_completions", "fim_completions"]), @@ -13,7 +13,7 @@ const kiloInputSchema = z.object({ }); export const kiloTool = tool({ - description: `Interact with the Kilo AI Gateway. + description: `Interact with the Kilo AI Gateway. Supports: - list_models: Retrieve the list of available models. - list_providers: Retrieve the list of available providers.