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/.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/open-agent.ts b/packages/agent/open-agent.ts index ef4aa2b0..56a961fb 100644 --- a/packages/agent/open-agent.ts +++ b/packages/agent/open-agent.ts @@ -23,6 +23,7 @@ import { todoWriteTool, webFetchTool, writeFileTool, + kiloTool, } from "./tools"; export interface AgentModelSelection { @@ -76,6 +77,7 @@ const tools = { ask_user_question: askUserQuestionTool, skill: skillTool, web_fetch: webFetchTool, + kilo: kiloTool, } satisfies ToolSet; export const openAgent = new ToolLoopAgent({ diff --git a/packages/agent/providers/kilo/client.ts b/packages/agent/providers/kilo/client.ts new file mode 100644 index 00000000..e46a60c0 --- /dev/null +++ b/packages/agent/providers/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/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/delegate.ts b/packages/agent/tools/delegate.ts index af81f402..cd1c0a41 100644 --- a/packages/agent/tools/delegate.ts +++ b/packages/agent/tools/delegate.ts @@ -20,6 +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)"), @@ -101,11 +104,14 @@ 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: { @@ -159,6 +165,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", ); diff --git a/packages/agent/tools/index.ts b/packages/agent/tools/index.ts index c92fbc2e..cb503314 100644 --- a/packages/agent/tools/index.ts +++ b/packages/agent/tools/index.ts @@ -23,3 +23,4 @@ export { export { skillTool, type SkillToolInput } from "./skill"; export { webFetchTool } from "./fetch"; export { commitAndPrTool } from "./github"; +export { commitAndPrTool } from "./github";