diff --git a/.contributerc.json b/.contributerc.json new file mode 100644 index 0000000..24c3fee --- /dev/null +++ b/.contributerc.json @@ -0,0 +1,17 @@ +{ + "workflow": "clean-flow", + "role": "maintainer", + "mainBranch": "main", + "devBranch": "dev", + "upstream": "upstream", + "origin": "origin", + "branchPrefixes": [ + "feature", + "fix", + "docs", + "chore", + "test", + "refactor" + ], + "commitConvention": "clean-commit" +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7a0abf5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,27 @@ +# Auto-detect text files and normalize line endings to LF +* text=auto eol=lf + +# Explicitly declare text files +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf +*.env text eol=lf +*.sh text eol=lf + +# Declare binary files +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.eot binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbec433..a98a7c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,7 +117,7 @@ jobs: run: bun run build:plugins - name: Run tests (with retry for Bun runtime segfaults) - uses: nick-fields/retry@v3 + uses: nick-fields/retry@v4 with: max_attempts: 3 timeout_minutes: 10 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1707847..c7edd39 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,14 +24,14 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Initialize CodeQL - uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 + uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 + uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 + uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index b91780f..3021f4d 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -18,16 +18,16 @@ permissions: jobs: build: - if: github.event_name != 'pull_request' || github.event.pull_request.user.login != 'dependabot[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Build and Push Container - uses: wgtechlabs/container-build-flow-action@v1.3.1 # v1.3.1 + if: ${{ github.event_name != 'pull_request' }} + uses: wgtechlabs/container-build-flow-action@v1.7.1 with: registry: both dockerhub-username: ${{ secrets.DOCKER_HUB_USERNAME }} @@ -36,3 +36,4 @@ jobs: platforms: linux/amd64,linux/arm64 trivy-severity: CRITICAL fail-on-vulnerability: true + commit-convention-enabled: true diff --git a/.github/workflows/landing.yml b/.github/workflows/landing.yml index 5dd8e2b..d6830f9 100644 --- a/.github/workflows/landing.yml +++ b/.github/workflows/landing.yml @@ -47,4 +47,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index c2849ca..b5ff6e8 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -22,7 +22,6 @@ permissions: jobs: publish: - if: github.event_name != 'pull_request' || github.event.pull_request.user.login != 'dependabot[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -31,7 +30,7 @@ jobs: - uses: oven-sh/setup-bun@v2 - - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: '22' @@ -42,19 +41,21 @@ jobs: run: bun run build:packages - name: Build & Publish Packages - uses: wgtechlabs/package-build-flow-action@v2.0.1 + uses: wgtechlabs/package-build-flow-action@v2.1.1 with: monorepo: 'true' workspace-detection: 'true' package-manager: 'bun' dependency-order: 'true' changed-only: 'false' + commit-convention-enabled: 'true' + bot-detection: 'true' registry: 'both' access: 'public' npm-token: ${{ secrets.NPM_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }} publish-enabled: 'true' - dry-run: ${{ github.event.inputs.dry-run || 'false' }} + dry-run: ${{ github.event_name == 'pull_request' && 'true' || github.event.inputs.dry-run || 'false' }} build-script: 'build' audit-enabled: 'true' audit-level: 'high' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8c0cda..1719636 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: token: ${{ secrets.GH_PAT }} - name: Create Release - uses: wgtechlabs/release-build-flow-action@v1.6.0 # v1.6.0 + uses: wgtechlabs/release-build-flow-action@v1.7.0 # v1.6.0 with: github-token: ${{ secrets.GH_PAT }} monorepo: 'true' diff --git a/.gitignore b/.gitignore index c793bac..d7ae2aa 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,7 @@ coverage/ # Misc *.bak *.tmp -docs/**.* \ No newline at end of file +docs/**.* + +# Contribute Now +.contributerc.json diff --git a/.husky/commit-msg b/.husky/commit-msg deleted file mode 100644 index d03b211..0000000 --- a/.husky/commit-msg +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -bun "$(dirname "$0")/validate-commit-msg.mjs" "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index e69de29..0000000 diff --git a/.husky/validate-commit-msg.mjs b/.husky/validate-commit-msg.mjs deleted file mode 100644 index 373599b..0000000 --- a/.husky/validate-commit-msg.mjs +++ /dev/null @@ -1,69 +0,0 @@ -import { readFileSync } from "fs"; - -const msgFile = process.argv[2]; -if (!msgFile) { - console.error("Error: No commit message file path provided."); - process.exit(1); -} -let raw; -try { - raw = readFileSync(msgFile, "utf8"); -} catch (err) { - console.error( - `Error: Could not read commit message file "${msgFile}": ${err.message}`, - ); - process.exit(1); -} -const firstLine = raw.replace(/\r/g, "").split("\n")[0].trim(); - -// Allow merge and revert commits -if (/^Merge /.test(firstLine) || /^Revert /.test(firstLine)) process.exit(0); - -// Clean Commit convention pattern -// Format: [!][()]: -const pattern = - /^(πŸ“¦|πŸ”§|πŸ—‘\uFE0F?|πŸ”’|βš™\uFE0F?|β˜•|πŸ§ͺ|πŸ“–|πŸš€) (new|update|remove|security|setup|chore|test|docs|release)(!?)( \([a-zA-Z0-9][a-zA-Z0-9-]*\))?: .{1,72}$/u; - -// Only new, update, remove, security may use the breaking change marker -const breakingMatch = firstLine.match(pattern); -if (breakingMatch) { - const type = breakingMatch[2]; - const bang = breakingMatch[3]; - if (bang === '!' && !['new', 'update', 'remove', 'security'].includes(type)) { - console.error(''); - console.error('βœ– Breaking change marker (!) is only allowed for: new, update, remove, security'); - console.error(''); - process.exit(1); - } -} - -if (!pattern.test(firstLine)) { - console.error(""); - console.error("βœ– Invalid commit message format."); - console.error(""); - console.error(" Expected: [!][()]: "); - console.error(""); - console.error(" Use ! after type for breaking changes (new, update, remove, security only)"); - console.error(""); - console.error(" Types and emojis:"); - console.error(" πŸ“¦ new – new features, files, or capabilities"); - console.error(" πŸ”§ update – changes, refactoring, improvements"); - console.error(" πŸ—‘οΈ remove – removing code, files, or dependencies"); - console.error(" πŸ”’ security – security fixes or patches"); - console.error(" βš™οΈ setup – configs, CI/CD, tooling, build systems"); - console.error(" β˜• chore – maintenance, dependency updates"); - console.error(" πŸ§ͺ test – adding or updating tests"); - console.error(" πŸ“– docs – documentation changes"); - console.error(" πŸš€ release – version releases"); - console.error(""); - console.error(" Examples:"); - console.error(" πŸ“¦ new: user authentication system"); - console.error(" πŸ”§ update (api): improve error handling"); - console.error(" βš™οΈ setup (ci): configure github actions workflow"); - console.error(" πŸ“¦ new!: completely redesign authentication system"); - console.error(" πŸ”§ update! (api): change response format for all endpoints"); - console.error(""); - console.error(" Reference: https://github.com/wgtechlabs/clean-commit"); - console.error(""); - process.exit(1); -} diff --git a/Dockerfile b/Dockerfile index 5be00a1..bb9b3a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ── Stage 1: Install + Build ──────────────────────────────────────── -FROM oven/bun:1.3.9 AS builder +FROM oven/bun:1.3.11 AS builder WORKDIR /app @@ -30,7 +30,10 @@ COPY src/cli/package.json ./src/cli/ COPY src/web/package.json ./src/web/ COPY plugins/channel/plugin-channel-discord/package.json ./plugins/channel/plugin-channel-discord/ COPY plugins/channel/plugin-channel-friends/package.json ./plugins/channel/plugin-channel-friends/ +COPY plugins/channel/plugin-channel-telegram/package.json ./plugins/channel/plugin-channel-telegram/ COPY plugins/provider/plugin-provider-openai/package.json ./plugins/provider/plugin-provider-openai/ +COPY plugins/provider/plugin-provider-ollama/package.json ./plugins/provider/plugin-provider-ollama/ +COPY src/landing/package.json ./src/landing/ # Install all deps (dev included β€” needed to build) RUN bun install @@ -42,7 +45,7 @@ COPY . . RUN bun run build # ── Stage 2: Production ───────────────────────────────────────────── -FROM oven/bun:1.3.9-slim AS production +FROM oven/bun:1.3.11-slim AS production WORKDIR /app diff --git a/README.md b/README.md index 23ff841..154e1c3 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Tiny Claw is inspired by personal AI companions from science fiction like **Cods - **Own personality.** Ships with a personality (Heartware system) that's uniquely its own. - **Native, not wrapped.** Every component is built from scratch with zero dependency on external AI frameworks. - **Easy to start.** Uses Ollama Cloud with two built-in models β€” kimi-k2.5:cloud (default) and gpt-oss:120b-cloud. Choose your model during setup and switch anytime via conversation. +- **Flexible Ollama support.** Add the Ollama provider plugin for local Ollama or extra cloud models without duplicating the built-in starter models, while reusing the Ollama API key already stored during setup. - **Cost-conscious.** Smart routing tiers queries across your installed providers. Cheap models handle simple stuff, powerful models only fire when needed. ## ✨ Features @@ -133,7 +134,7 @@ tinyclaw/ secrets/ Encrypted secrets management (AES-256-GCM) plugins/ Plugin discovery and loading plugins/ Plugin packages (keep the core tiny) - channel/ Messaging integrations (Discord, Friends, etc.) + channel/ Messaging integrations (Discord, Telegram, Friends, etc.) provider/ LLM providers (OpenAI, etc.) src/ cli/ CLI entry point @@ -141,13 +142,19 @@ tinyclaw/ web/ Web UI (Svelte 5, Discord-like experience) ``` +## 🎯 Contributing + +Contributions are welcome, create a pull request to this repo and I will review your code. Please consider to submit your pull request to the `dev` branch. Thank you! + +Read the project's [contributing guide](./CONTRIBUTING.md) for more info. + ## πŸ› Issues Please report any issues and bugs by [creating a new issue here](https://github.com/warengonzaga/tinyclaw/issues/new/choose), also make sure you're reporting an issue that doesn't exist. Any help to improve the project would be appreciated. Thanks! πŸ™βœ¨ ## πŸ™ Sponsor -Like this project? Leave a star! ⭐⭐⭐⭐⭐ +Like this project? **Leave a star**! ⭐⭐⭐⭐⭐ Want to support my work and get some perks? [Become a sponsor](https://github.com/sponsors/warengonzaga)! πŸ’– @@ -157,11 +164,11 @@ Recognized my open-source contributions? [Nominate me](https://stars.github.com/ ## πŸ“‹ Code of Conduct -Read the project's [code of conduct](https://github.com/warengonzaga/tinyclaw/blob/main/CODE_OF_CONDUCT.md). +Read the project's [code of conduct](./CODE_OF_CONDUCT.md). ## πŸ“ƒ License -This project is licensed under [GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.html). +This project is licensed under [GNU General Public License v3.0](https://opensource.org/licenses/GPL-3.0). ## πŸ™ Credits @@ -175,10 +182,10 @@ This project is licensed under [GNU General Public License v3.0](https://www.gnu ## πŸ“ Author -This project is created by [Waren Gonzaga](https://github.com/warengonzaga), with the help of awesome [contributors](https://github.com/warengonzaga/tinyclaw/graphs/contributors). +This project is created by **[Waren Gonzaga](https://github.com/warengonzaga)**, with the help of awesome [contributors](https://github.com/warengonzaga/tinyclaw/graphs/contributors). [![contributors](https://contrib.rocks/image?repo=warengonzaga/tinyclaw)](https://github.com/warengonzaga/tinyclaw/graphs/contributors) --- -πŸ’»πŸ’–β˜• by [Waren Gonzaga](https://warengonzaga.com) | [YHWH](https://www.youtube.com/watch?v=VOZbswniA-g) πŸ™ - Without _Him_, none of this exists, _even me_. +πŸ’»πŸ’–β˜• by [Waren Gonzaga](https://warengonzaga.com) & [YHWH](https://www.youtube.com/watch?v=VOZbswniA-g) πŸ™ β€” Without *Him*, none of this exists, *even me*. diff --git a/biome.json b/biome.json index 958d7e2..acab172 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/bun.lock b/bun.lock index 514aaff..a52b426 100644 --- a/bun.lock +++ b/bun.lock @@ -8,11 +8,10 @@ "@wgtechlabs/log-engine": "^2.3.1", }, "devDependencies": { - "@biomejs/biome": "^2.4.4", + "@biomejs/biome": "^2.4.6", "@types/bun": "latest", - "@types/node": "^25.3.0", - "husky": "^9.1.7", - "typescript": "^5.7.0", + "@types/node": "^25.3.3", + "typescript": "^6.0.2", }, }, "packages/compactor": { @@ -24,7 +23,7 @@ }, "devDependencies": { "@tinyclaw/core": "workspace:*", - "typescript": "^5.0.0", + "typescript": "^6.0.2", }, }, "packages/config": { @@ -198,6 +197,23 @@ "@tinyclaw/types": "workspace:*", }, }, + "plugins/channel/plugin-channel-telegram": { + "name": "@tinyclaw/plugin-channel-telegram", + "version": "2.0.0", + "dependencies": { + "@tinyclaw/logger": "workspace:*", + "@tinyclaw/types": "workspace:*", + }, + }, + "plugins/provider/plugin-provider-ollama": { + "name": "@tinyclaw/plugin-provider-ollama", + "version": "2.0.0", + "dependencies": { + "@tinyclaw/core": "workspace:*", + "@tinyclaw/logger": "workspace:*", + "@tinyclaw/types": "workspace:*", + }, + }, "plugins/provider/plugin-provider-openai": { "name": "@tinyclaw/plugin-provider-openai", "version": "2.0.0", @@ -254,7 +270,7 @@ "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.1.18", "tailwindcss": "^4.1.18", - "vite": "^7.2.4", + "vite": "^8.0.0", }, }, "src/web": { @@ -275,7 +291,7 @@ "@tailwindcss/vite": "^4.1.18", "@types/qrcode": "^1.5.6", "tailwindcss": "^4.1.18", - "vite": "^7.2.4", + "vite": "^8.0.0", }, }, }, @@ -283,23 +299,23 @@ "undici": "6.23.0", }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="], + "@biomejs/biome": ["@biomejs/biome@2.4.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.6", "@biomejs/cli-darwin-x64": "2.4.6", "@biomejs/cli-linux-arm64": "2.4.6", "@biomejs/cli-linux-arm64-musl": "2.4.6", "@biomejs/cli-linux-x64": "2.4.6", "@biomejs/cli-linux-x64-musl": "2.4.6", "@biomejs/cli-win32-arm64": "2.4.6", "@biomejs/cli-win32-x64": "2.4.6" }, "bin": { "biome": "bin/biome" } }, "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.6", "", { "os": "win32", "cpu": "x64" }, "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg=="], "@clack/core": ["@clack/core@1.0.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ=="], @@ -317,6 +333,12 @@ "@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="], + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], @@ -379,6 +401,42 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], + + "@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], @@ -501,6 +559,10 @@ "@tinyclaw/plugin-channel-friends": ["@tinyclaw/plugin-channel-friends@workspace:plugins/channel/plugin-channel-friends"], + "@tinyclaw/plugin-channel-telegram": ["@tinyclaw/plugin-channel-telegram@workspace:plugins/channel/plugin-channel-telegram"], + + "@tinyclaw/plugin-provider-ollama": ["@tinyclaw/plugin-provider-ollama@workspace:plugins/provider/plugin-provider-ollama"], + "@tinyclaw/plugin-provider-openai": ["@tinyclaw/plugin-provider-openai@workspace:plugins/provider/plugin-provider-openai"], "@tinyclaw/plugins": ["@tinyclaw/plugins@workspace:packages/plugins"], @@ -523,13 +585,15 @@ "@tinyclaw/web": ["@tinyclaw/web@workspace:src/web"], - "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/figlet": ["@types/figlet@1.7.0", "", {}, "sha512-KwrT7p/8Eo3Op/HBSIwGXOsTZKYiM9NpWRBJ5sVjWP/SmlS+oxxRvJht/FNAtliJvja44N3ul1yATgohnVBV0Q=="], - "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], @@ -555,7 +619,7 @@ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], - "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], @@ -609,37 +673,35 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], @@ -669,11 +731,11 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], - "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], @@ -681,6 +743,8 @@ "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="], + "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], @@ -707,13 +771,13 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + "vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="], @@ -737,6 +801,12 @@ "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + "@sveltejs/vite-plugin-svelte/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "@sveltejs/vite-plugin-svelte-inspector/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "@tailwindcss/node/lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -749,16 +819,50 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tailwindcss/vite/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + "@types/qrcode/@types/node": ["@types/node@22.19.8", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA=="], "@types/ws/@types/node": ["@types/node@22.19.8", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA=="], - "bun-types/@types/node": ["@types/node@22.19.8", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA=="], + "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "@sveltejs/vite-plugin-svelte-inspector/vite/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "@sveltejs/vite-plugin-svelte-inspector/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "@sveltejs/vite-plugin-svelte/vite/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "@sveltejs/vite-plugin-svelte/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + + "@tailwindcss/vite/vite/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "@tailwindcss/vite/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "@types/qrcode/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@types/ws/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], } } diff --git a/package.json b/package.json index b64d7ea..778646b 100644 --- a/package.json +++ b/package.json @@ -28,15 +28,13 @@ "format": "biome format --write .", "cli": "bun run src/cli/src/index.ts", "dev:purge": "bun run cli purge --force", - "dev:landing": "bun run --cwd src/landing dev", - "prepare": "husky || true" + "dev:landing": "bun run --cwd src/landing dev" }, "devDependencies": { - "@biomejs/biome": "^2.4.4", + "@biomejs/biome": "^2.4.6", "@types/bun": "latest", - "@types/node": "^25.3.0", - "husky": "^9.1.7", - "typescript": "^5.7.0" + "@types/node": "^25.3.3", + "typescript": "^6.0.2" }, "dependencies": { "@wgtechlabs/log-engine": "^2.3.1" diff --git a/packages/compactor/package.json b/packages/compactor/package.json index 8f4a34d..fff6df5 100644 --- a/packages/compactor/package.json +++ b/packages/compactor/package.json @@ -38,6 +38,6 @@ }, "devDependencies": { "@tinyclaw/core": "workspace:*", - "typescript": "^5.0.0" + "typescript": "^6.0.2" } } diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index ed85fa6..2a02dfa 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -22,6 +22,8 @@ export type { ConfigManagerConfig, ConfigManagerInterface } from '@tinyclaw/type * stored in secrets-engine (never the actual secret). */ const ProviderEntrySchema = z.object({ + providerId: z.string().optional(), + mode: z.enum(['local', 'cloud']).optional(), model: z.string().optional(), baseUrl: z.string().url().optional(), apiKeyRef: z.string().optional(), @@ -61,7 +63,7 @@ export const TinyClawConfigSchema = z starterBrain: ProviderEntrySchema.optional(), primary: ProviderEntrySchema.optional(), }) - .passthrough() + .catchall(ProviderEntrySchema) .optional(), /** Channel configurations (telegram, discord, slack, etc.) */ diff --git a/packages/config/tests/types.test.ts b/packages/config/tests/types.test.ts index 9dcd787..ffb0c2b 100644 --- a/packages/config/tests/types.test.ts +++ b/packages/config/tests/types.test.ts @@ -61,10 +61,16 @@ describe('TinyClawConfigSchema β€” valid data', () => { baseUrl: 'http://localhost:11434', }, primary: { + providerId: 'openai', model: 'gpt-4', baseUrl: 'https://api.openai.com/v1', apiKeyRef: 'provider.openai.apiKey', }, + ollama: { + mode: 'local', + model: 'llama3.2:3b', + baseUrl: 'http://127.0.0.1:11434', + }, }, channels: { telegram: { enabled: true, tokenRef: 'channel.telegram.token' }, @@ -116,6 +122,25 @@ describe('TinyClawConfigSchema β€” valid data', () => { }); expect(result.success).toBe(true); }); + + test('accepts provider mode and providerId fields', () => { + const result = TinyClawConfigSchema.safeParse({ + providers: { + primary: { + providerId: 'ollama', + model: 'llama3.2:3b', + }, + ollama: { + mode: 'cloud', + model: 'qwen3:32b', + baseUrl: 'https://ollama.com', + apiKeyRef: 'provider.ollama.apiKey', + }, + }, + }); + + expect(result.success).toBe(true); + }); }); // ----------------------------------------------------------------------- @@ -173,4 +198,14 @@ describe('TinyClawConfigSchema β€” invalid data', () => { }); expect(result.success).toBe(false); }); + + test('rejects invalid provider mode', () => { + const result = TinyClawConfigSchema.safeParse({ + providers: { + ollama: { mode: 'edge' }, + }, + }); + + expect(result.success).toBe(false); + }); }); diff --git a/packages/core/src/loop.ts b/packages/core/src/loop.ts index 9223eca..cb804c0 100644 --- a/packages/core/src/loop.ts +++ b/packages/core/src/loop.ts @@ -6,6 +6,8 @@ import type { Message, PendingApproval, ShieldEvent, + StreamEvent, + Tool, ToolCall, } from '@tinyclaw/types'; import { isOwner, OWNER_ONLY_TOOLS } from '@tinyclaw/types'; @@ -18,6 +20,9 @@ import { BUILTIN_MODEL_TAGS } from './models.js'; */ const SELF_GATED_TOOLS: ReadonlySet = new Set([...SHELL_TOOL_NAMES]); +/** Name of the built-in restart tool β€” used in several places below. */ +const RESTART_TOOL_NAME = 'tinyclaw_restart'; + // --------------------------------------------------------------------------- // Text Sanitization β€” strip em-dashes from LLM output // --------------------------------------------------------------------------- @@ -207,6 +212,58 @@ function getWorkingMessage(toolName: string): string { return 'πŸ€” Working on that…\n\n'; } +function shouldAutoRestartAfterTool(toolName: string, result: string): boolean { + if (!/_(pair|unpair)$/.test(toolName)) { + return false; + } + + if (result.startsWith('Error')) { + return false; + } + + return result.includes(RESTART_TOOL_NAME); +} + +async function maybeRunAutoRestart( + originalToolName: string, + toolResults: Array<{ id: string; result: string }>, + tools: Tool[], + onStream: ((event: StreamEvent) => void) | undefined, +): Promise { + const needsRestart = toolResults.some((toolResult) => + shouldAutoRestartAfterTool(originalToolName, toolResult.result), + ); + + if (!needsRestart) { + return; + } + + const restartTool = tools.find((tool) => tool.name === RESTART_TOOL_NAME); + if (!restartTool) { + return; + } + + if (onStream) { + onStream({ type: 'tool_start', tool: restartTool.name }); + } + + try { + const restartResult = await restartTool.execute({ + reason: `Apply changes from ${originalToolName}`, + }); + toolResults.push({ id: `${originalToolName}:auto-restart`, result: restartResult }); + if (onStream) { + onStream({ type: 'tool_result', tool: restartTool.name, result: restartResult }); + } + } catch (error) { + const errorMsg = `Error: ${error instanceof Error ? error.message : 'Unknown error'}`; + toolResults.push({ id: `${originalToolName}:auto-restart`, result: errorMsg }); + if (onStream) { + onStream({ type: 'tool_result', tool: restartTool.name, result: errorMsg }); + } + } +} + // --------------------------------------------------------------------------- // Delegation stream event helpers // --------------------------------------------------------------------------- @@ -497,6 +554,18 @@ When the user asks to set up or change their primary provider: Providers must be installed as plugins first (added to plugins.enabled in the config). +## Plugin Setup Guidance + +When the user asks to set up, connect, install, enable, or pair a channel or provider plugin: +- First figure out whether they have already shared the required credential in the conversation, such as a bot token or API key. +- If they have not shared it yet, do not pretend the plugin is configured and do not claim it is online. +- Walk them through the setup step by step, briefly and concretely. +- For Discord, explain that they need to create an application in the Discord Developer Portal, add a bot, copy the bot token, and enable Message Content Intent. +- After they provide the required credential, call the appropriate pairing tool. +- After pairing succeeds, clearly tell them what changed and whether a restart is happening. +- Never say a plugin is active, connected, or ready unless the pairing tool succeeded and any required restart or activation step has been completed. +- If the user asks whether the Discord bot is online, offline, connected, or why it failed to start, use the discord_status tool before answering. Do not guess from config alone. + ## How to Use Tools When you need to use a tool, output ONLY a JSON object with the tool name and arguments. Examples: @@ -961,6 +1030,8 @@ export async function agentLoop( } } + await maybeRunAutoRestart(toolCall.name, toolResults, tools, onStream); + // For read/search/recall operations, send result back to LLM for natural response const isReadOperation = toolCall.name.includes('read') || @@ -999,11 +1070,10 @@ export async function agentLoop( // For write operations, feed the result back to the LLM so it // can craft a natural, conversational response instead of the // generic "Done!" that was causing a feedback loop in the history. - const writeResult = toolResults[0]?.result || 'completed'; - const _writeSummary = summarizeToolResults([toolCall], toolResults); + const resultsText = toolResults.map((result) => result.result).join('\n\n'); messages.push({ role: 'assistant', - content: `I used ${toolCall.name} and the result was: ${writeResult}`, + content: `I used these tools and the results were:\n${resultsText}`, }); messages.push({ role: 'user', @@ -1143,6 +1213,23 @@ export async function agentLoop( } } + // Determine whether the model already scheduled a tinyclaw_restart in this + // batch so we don't trigger a second (duplicate) restart via auto-restart. + const batchHasRestartCall = response.toolCalls.some((tc) => tc.name === RESTART_TOOL_NAME); + + if (!batchHasRestartCall) { + for (const toolCall of response.toolCalls) { + const matchingResults = toolResults.filter((result) => result.id === toolCall.id); + await maybeRunAutoRestart(toolCall.name, matchingResults, tools, onStream); + const autoRestartResults = matchingResults.filter( + (result) => result.id === `${toolCall.name}:auto-restart`, + ); + if (autoRestartResults.length > 0) { + toolResults.push(...autoRestartResults); + } + } + } + // If pending approvals were queued during structured tool_calls, ask the user // about the first one (subsequent ones will be handled on following turns). const paQueue = pendingApprovals.get(userId); @@ -1209,6 +1296,22 @@ export async function agentLoop( continue; } + if (toolResults.some((r) => !r.result.startsWith('Error'))) { + const resultsText = toolResults.map((r) => r.result).join('\n\n'); + messages.push({ + role: 'assistant', + content: `I used these tools and the results were:\n${resultsText}`, + }); + messages.push({ + role: 'user', + content: + 'Now respond naturally to my original message. Briefly confirm the action you took and be conversational.', + }); + + // Continue the loop to get LLM's natural response + continue; + } + const responseText = summarizeToolResults(response.toolCalls, toolResults); if (onStream) { diff --git a/packages/core/tests/loop.test.ts b/packages/core/tests/loop.test.ts new file mode 100644 index 0000000..60ccb48 --- /dev/null +++ b/packages/core/tests/loop.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, test } from 'bun:test'; +import type { AgentContext, LearnedContext, Message, Provider, Tool } from '@tinyclaw/types'; +import { createDatabase } from '../src/database.js'; +import { agentLoop } from '../src/loop.js'; + +function createLearningStub() { + return { + analyze() {}, + getContext(): LearnedContext { + return { + preferences: '', + patterns: '', + recentCorrections: '', + }; + }, + injectIntoPrompt(basePrompt: string) { + return basePrompt; + }, + }; +} + +describe('agentLoop', () => { + test('injects plugin setup walkthrough guidance into the system prompt', async () => { + let firstPrompt: Message[] = []; + + const provider: Provider = { + id: 'test-provider', + name: 'Test Provider', + async chat(messages) { + firstPrompt = messages.map((message) => ({ ...message })); + return { + type: 'text', + content: 'Tell me when you are ready to continue.', + }; + }, + async isAvailable() { + return true; + }, + }; + + const context: AgentContext = { + db: createDatabase(':memory:'), + provider, + learning: createLearningStub(), + tools: [], + }; + + await agentLoop('Help me set up the Discord plugin.', 'web:test', context); + + const systemPrompt = firstPrompt[0]?.content ?? ''; + expect(firstPrompt[0]?.role).toBe('system'); + expect(systemPrompt).toContain('## Plugin Setup Guidance'); + expect(systemPrompt).toContain('For Discord, explain that they need to create an application'); + expect(systemPrompt).toContain('do not pretend the plugin is configured'); + }); + + test('turns structured write tool calls into a natural final reply', async () => { + const prompts: Message[][] = []; + + const provider: Provider = { + id: 'test-provider', + name: 'Test Provider', + async chat(messages) { + prompts.push(messages.map((message) => ({ ...message }))); + + if (prompts.length === 1) { + return { + type: 'tool_calls', + toolCalls: [ + { + id: 'restart-1', + name: 'tinyclaw_restart_notice', + arguments: { reason: 'refresh config' }, + }, + ], + }; + } + + return { + type: 'text', + content: 'I refreshed the configuration. Please restart Tiny Claw when convenient.', + }; + }, + async isAvailable() { + return true; + }, + }; + + const tools: Tool[] = [ + { + name: 'tinyclaw_restart_notice', + description: 'Records that a restart is needed.', + parameters: { + type: 'object', + properties: { + reason: { type: 'string' }, + }, + }, + async execute(args) { + return `Restart required: ${String(args.reason)}`; + }, + }, + ]; + + const context: AgentContext = { + db: createDatabase(':memory:'), + provider, + learning: createLearningStub(), + tools, + }; + + const result = await agentLoop('Please apply the config change.', 'web:test', context); + + expect(result).toBe('I refreshed the configuration. Please restart Tiny Claw when convenient.'); + expect(prompts).toHaveLength(2); + expect(prompts[1]?.at(-2)?.role).toBe('assistant'); + expect(prompts[1]?.at(-2)?.content).toContain('I used these tools and the results were:'); + expect(prompts[1]?.at(-2)?.content).toContain('Restart required: refresh config'); + expect(prompts[1]?.at(-1)?.role).toBe('user'); + expect(prompts[1]?.at(-1)?.content).toContain('respond naturally to my original message'); + + const savedHistory = context.db.getHistory('web:test', 10); + expect(savedHistory[0]?.content).toBe('Please apply the config change.'); + expect(savedHistory[1]?.content).toBe(result); + }); + + test('auto-restarts after successful plugin pairing during structured tool calls', async () => { + const prompts: Message[][] = []; + let restartCalls = 0; + + const provider: Provider = { + id: 'test-provider', + name: 'Test Provider', + async chat(messages) { + prompts.push(messages.map((message) => ({ ...message }))); + + if (prompts.length === 1) { + return { + type: 'tool_calls', + toolCalls: [ + { + id: 'discord-pair-1', + name: 'discord_pair', + arguments: { token: 'discord-token' }, + }, + ], + }; + } + + return { + type: 'text', + content: 'Discord is paired, and Tiny Claw is restarting now so the bot can connect.', + }; + }, + async isAvailable() { + return true; + }, + }; + + const tools: Tool[] = [ + { + name: 'discord_pair', + description: 'Stores a Discord token and enables the plugin.', + parameters: { + type: 'object', + properties: { + token: { type: 'string' }, + }, + }, + async execute() { + return 'Discord bot paired successfully! Use the tinyclaw_restart tool now to connect the bot.'; + }, + }, + { + name: 'tinyclaw_restart', + description: 'Restarts Tiny Claw.', + parameters: { + type: 'object', + properties: { + reason: { type: 'string' }, + }, + }, + async execute() { + restartCalls += 1; + return 'Restart initiated. Tiny Claw will automatically respawn with the updated configuration.'; + }, + }, + ]; + + const context: AgentContext = { + db: createDatabase(':memory:'), + provider, + learning: createLearningStub(), + tools, + }; + + const result = await agentLoop('Connect my Discord bot.', 'web:test', context); + + expect(result).toBe( + 'Discord is paired, and Tiny Claw is restarting now so the bot can connect.', + ); + expect(restartCalls).toBe(1); + expect(prompts).toHaveLength(2); + expect(prompts[1]?.at(-2)?.content).toContain('Discord bot paired successfully'); + expect(prompts[1]?.at(-2)?.content).toContain( + 'Restart initiated. Tiny Claw will automatically respawn', + ); + }); + + test('includes auto-restart results in the single-tool natural reply path', async () => { + const prompts: Message[][] = []; + let restartCalls = 0; + + const provider: Provider = { + id: 'test-provider', + name: 'Test Provider', + async chat(messages) { + prompts.push(messages.map((message) => ({ ...message }))); + + if (prompts.length === 1) { + return { + type: 'text', + content: JSON.stringify({ tool: 'discord_pair', token: 'discord-token' }), + }; + } + + return { + type: 'text', + content: 'Discord is paired, and Tiny Claw is restarting now so the bot can connect.', + }; + }, + async isAvailable() { + return true; + }, + }; + + const tools: Tool[] = [ + { + name: 'discord_pair', + description: 'Stores a Discord token and enables the plugin.', + parameters: { + type: 'object', + properties: { + token: { type: 'string' }, + }, + }, + async execute() { + return 'Discord bot paired successfully! Use the tinyclaw_restart tool now to connect the bot.'; + }, + }, + { + name: 'tinyclaw_restart', + description: 'Restarts Tiny Claw.', + parameters: { + type: 'object', + properties: { + reason: { type: 'string' }, + }, + }, + async execute() { + restartCalls += 1; + return 'Restart initiated. Tiny Claw will automatically respawn with the updated configuration.'; + }, + }, + ]; + + const context: AgentContext = { + db: createDatabase(':memory:'), + provider, + learning: createLearningStub(), + tools, + }; + + const result = await agentLoop('Connect my Discord bot.', 'web:test', context); + + expect(result).toBe( + 'Discord is paired, and Tiny Claw is restarting now so the bot can connect.', + ); + expect(restartCalls).toBe(1); + expect(prompts).toHaveLength(2); + expect(prompts[1]?.at(-2)?.role).toBe('assistant'); + expect(prompts[1]?.at(-2)?.content).toContain('Discord bot paired successfully'); + expect(prompts[1]?.at(-2)?.content).toContain( + 'Restart initiated. Tiny Claw will automatically respawn', + ); + expect(prompts[1]?.at(-1)?.role).toBe('user'); + expect(prompts[1]?.at(-1)?.content).toContain('respond naturally to my original message'); + }); +}); diff --git a/packages/delegation/src/background.ts b/packages/delegation/src/background.ts index 23433c6..0aa9a53 100644 --- a/packages/delegation/src/background.ts +++ b/packages/delegation/src/background.ts @@ -217,9 +217,35 @@ export function createBackgroundRunner( } }) .catch((err) => { - // Queue-level error (e.g. queue stopped) - logger.error('Background queue error', { taskId, error: err }); + // Queue-level error (e.g. queue stopped, DB unavailable). + // Best-effort cleanup β€” DB may also be unreachable. + const errorMsg = err instanceof Error ? err.message : 'Queue error'; + logger.error('Background queue error', { taskId, error: errorMsg }); controllers.delete(taskId); + + try { + db.updateBackgroundTask(taskId, 'failed', errorMsg, Date.now()); + lifecycle.recordTaskResult(agentId, false); + + // Emit intercom event so nudge system can notify the user + intercom?.emit('task:failed', userId, { + taskId, + agentId, + error: errorMsg, + }); + + // Auto-dismiss sub-agent if no remaining running tasks + const allTasks = db.getUserBackgroundTasks(userId); + const hasRunningTasks = allTasks.some( + (t) => t.agentId === agentId && t.status === 'running', + ); + if (!hasRunningTasks) { + lifecycle.dismiss(agentId); + logger.info('Sub-agent auto-dismissed (queue error)', { agentId }); + } + } catch { + logger.error('Failed to update task status after queue error', { taskId }); + } }); return taskId; diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index 0b4d72b..e8bc3f2 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -75,5 +75,5 @@ export function setLogMode(level: LogModeName | LogMode): void { } // Re-export configured logger and emoji utilities -export const logger = LogEngine; +export const logger: typeof LogEngine = LogEngine; export { LogMode, EmojiSelector, EMOJI_MAPPINGS, FALLBACK_EMOJI, type LogCallOptions }; diff --git a/packages/plugins/src/community.ts b/packages/plugins/src/community.ts new file mode 100644 index 0000000..eb7eeb1 --- /dev/null +++ b/packages/plugins/src/community.ts @@ -0,0 +1,349 @@ +/** + * Community Plugin Manager + * + * Provides helpers for installing, removing, and listing third-party + * (community) plugins that live outside the official monorepo. + * + * Community plugins are stored in the config key `plugins.community: string[]` + * β€” separate from `plugins.enabled` which tracks actively running plugins. + * + * Installation flow: + * 1. Validate the package name (strict npm naming rules) + * 2. Run `bun add ` to install the dependency + * 3. Dynamically import the package and validate the plugin contract + * 4. Register it in `plugins.community` + * + * Security: + * - Package names are validated against strict npm naming rules before any + * shell execution β€” no metacharacters, no path traversal. + * - Official `@tinyclaw/plugin-*` packages are rejected from this flow + * (they are managed via the monorepo, not community install). + * - The installed module must satisfy the TinyClawPlugin interface before + * it's registered β€” random npm packages that aren't plugins are rejected. + */ + +import { logger } from '@tinyclaw/logger'; +import type { ConfigManagerInterface, TinyClawPlugin } from '@tinyclaw/types'; + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** + * Strict npm package name pattern (lowercase only, no shell metacharacters). + * + * Allows: + * - Scoped packages: `@scope/name` (lowercase alphanumeric, hyphens, dots) + * - Unscoped packages: `name` + * - Optional version suffix: `@1.2.3`, `@^1.0.0`, `@latest` + * + * Rejects everything else β€” no uppercase, no underscores in names, no spaces, + * no shell metacharacters, no path separators. + */ +const VALID_PACKAGE_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(@[a-z0-9.^~>=<|-]+)?$/; + +/** + * Validate a package name is safe for shell execution and npm resolution. + * Returns the cleaned name (without version suffix) or null if invalid. + */ +export function validatePackageName(input: string): { name: string; installSpec: string } | null { + const trimmed = input.trim(); + if (!trimmed || trimmed.length > 214) return null; + if (!VALID_PACKAGE_RE.test(trimmed)) return null; + + // Split name from version suffix for the name-only checks. + // Safe: the regex above guarantees scoped packages contain '/' and + // that the only '@' positions are scope-prefix and version-suffix. + const atIdx = trimmed.lastIndexOf('@'); + const hasVersionSuffix = atIdx > 0 && !trimmed.startsWith('@', atIdx - 1); + const name = hasVersionSuffix ? trimmed.slice(0, atIdx) : trimmed; + + // Reject official plugins β€” they're managed via the monorepo + if (name.startsWith('@tinyclaw/plugin-')) return null; + + return { name, installSpec: trimmed }; +} + +// --------------------------------------------------------------------------- +// Plugin contract validation +// --------------------------------------------------------------------------- + +/** + * Dynamically import a package and verify it exports a valid TinyClawPlugin. + * Returns the plugin on success, null on failure. + */ +async function validatePluginModule(packageName: string): Promise { + try { + const mod = await import(packageName); + const plugin = mod.default as TinyClawPlugin | undefined; + + if (!plugin || typeof plugin !== 'object') return null; + + const hasRequiredFields = + 'id' in plugin && + 'name' in plugin && + 'type' in plugin && + 'version' in plugin && + typeof plugin.id === 'string' && + typeof plugin.name === 'string' && + typeof plugin.type === 'string' && + typeof plugin.version === 'string' && + ['channel', 'provider', 'tools'].includes(plugin.type); + + if (!hasRequiredFields) return null; + + return plugin; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Config helpers +// --------------------------------------------------------------------------- + +/** Read the community plugin list from config. */ +export function getCommunityPlugins(configManager: ConfigManagerInterface): string[] { + return configManager.get('plugins.community') ?? []; +} + +/** Add a plugin to the community list (idempotent). */ +function addToCommunityList(configManager: ConfigManagerInterface, packageName: string): void { + const current = getCommunityPlugins(configManager); + if (!current.includes(packageName)) { + configManager.set('plugins.community', [...current, packageName]); + } +} + +/** Remove a plugin from the community list. */ +function removeFromCommunityList(configManager: ConfigManagerInterface, packageName: string): void { + const current = getCommunityPlugins(configManager); + configManager.set( + 'plugins.community', + current.filter((id) => id !== packageName), + ); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export interface InstallResult { + success: boolean; + message: string; + plugin?: { id: string; name: string; type: string; version: string }; +} + +/** + * Install a community plugin from npm. + * + * 1. Validates the package name + * 2. Runs `bun add ` to install + * 3. Imports the module and validates the plugin contract + * 4. Registers in `plugins.community` config + * + * @returns Result with success status and a human-readable message + */ +export async function installCommunityPlugin( + packageInput: string, + configManager: ConfigManagerInterface, +): Promise { + // 1. Validate package name + const validated = validatePackageName(packageInput); + if (!validated) { + return { + success: false, + message: `Invalid package name "${packageInput}". Must be a valid npm package name. Official @tinyclaw/plugin-* packages are managed separately.`, + }; + } + + const { name, installSpec } = validated; + + // 2. Check if already registered + const community = getCommunityPlugins(configManager); + if (community.includes(name)) { + return { + success: false, + message: `Plugin "${name}" is already registered as a community plugin.`, + }; + } + + // 3. Install via bun + logger.info(`Installing community plugin: ${installSpec}`, undefined, { emoji: 'πŸ“¦' }); + + try { + const proc = Bun.spawnSync(['bun', 'add', installSpec], { + stdout: 'pipe', + stderr: 'pipe', + timeout: 60_000, + }); + + if (proc.exitCode !== 0) { + const stderr = proc.stderr.toString().trim(); + return { + success: false, + message: `Failed to install "${installSpec}" from npm. ${stderr ? `Error: ${stderr.slice(0, 200)}` : 'The package may not exist or the registry is unreachable.'}`, + }; + } + } catch (err) { + return { + success: false, + message: `Installation failed: ${(err as Error).message}`, + }; + } + + // 4. Validate the plugin contract + const plugin = await validatePluginModule(name); + if (!plugin) { + // Installed but not a valid plugin β€” remove it + try { + Bun.spawnSync(['bun', 'remove', name], { + stdout: 'pipe', + stderr: 'pipe', + timeout: 30_000, + }); + } catch { + // Best-effort cleanup + } + + return { + success: false, + message: `Package "${name}" was installed but does not export a valid Tiny Claw plugin (must default-export an object with id, name, type, version). Package was removed.`, + }; + } + + // 5. Register in config + addToCommunityList(configManager, name); + + logger.info(`Community plugin installed: ${plugin.name} (${plugin.id})`, undefined, { + emoji: 'βœ…', + }); + + return { + success: true, + message: `Community plugin "${plugin.name}" (${plugin.id} v${plugin.version}) installed successfully. Restart Tiny Claw to activate it.`, + plugin: { + id: plugin.id, + name: plugin.name, + type: plugin.type, + version: plugin.version, + }, + }; +} + +/** + * Remove a community plugin. + * + * Removes from `plugins.community` and `plugins.enabled`, then runs `bun remove`. + */ +export async function removeCommunityPlugin( + packageName: string, + configManager: ConfigManagerInterface, +): Promise { + const validated = validatePackageName(packageName); + if (!validated) { + return { success: false, message: `Invalid package name "${packageName}".` }; + } + + const { name } = validated; + const community = getCommunityPlugins(configManager); + + if (!community.includes(name)) { + return { + success: false, + message: `Plugin "${name}" is not registered as a community plugin.`, + }; + } + + // Remove from config lists + removeFromCommunityList(configManager, name); + + // Also remove from enabled if present + const enabled = configManager.get('plugins.enabled') ?? []; + const wasEnabled = enabled.includes(name); + if (wasEnabled) { + configManager.set( + 'plugins.enabled', + enabled.filter((id) => id !== name), + ); + } + + // Uninstall the npm package + try { + Bun.spawnSync(['bun', 'remove', name], { + stdout: 'pipe', + stderr: 'pipe', + timeout: 30_000, + }); + } catch { + // Best-effort β€” config is already cleaned up + } + + logger.info(`Community plugin removed: ${name}`, undefined, { emoji: 'πŸ—‘οΈ' }); + + return { + success: true, + message: wasEnabled + ? `Community plugin "${name}" removed and disabled. Restart Tiny Claw to stop the running instance.` + : `Community plugin "${name}" removed.`, + }; +} + +export interface CommunityPluginInfo { + id: string; + name: string; + type: string; + version: string; + enabled: boolean; + source: 'community'; +} + +/** + * List all registered community plugins with their status. + * Imports are parallelized to avoid serial delays with many plugins. + */ +export async function listCommunityPlugins( + configManager: ConfigManagerInterface, +): Promise { + const community = getCommunityPlugins(configManager); + const enabled = new Set(configManager.get('plugins.enabled') ?? []); + + const results = await Promise.all( + community.map(async (id): Promise => { + try { + const mod = await import(id); + const plugin = mod.default as TinyClawPlugin | undefined; + + if (plugin && typeof plugin === 'object') { + return { + id: plugin.id ?? id, + name: plugin.name ?? id, + type: plugin.type ?? 'unknown', + version: plugin.version ?? 'unknown', + enabled: enabled.has(id), + source: 'community', + }; + } + return { + id, + name: id, + type: 'unknown', + version: 'unknown', + enabled: enabled.has(id), + source: 'community', + }; + } catch { + return { + id, + name: id, + type: 'unknown', + version: 'unresolvable', + enabled: enabled.has(id), + source: 'community', + }; + } + }), + ); + + return results; +} diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index 1e9e60c..743f174 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -8,14 +8,22 @@ * Discovery is config-driven (not filesystem-based) so plugins explicitly * opt in via their pairing flow. Import failures are non-fatal β€” logged and * skipped so the rest of the system boots normally. + * + * Pairing-tool discovery scans the monorepo `plugins/` directories at runtime + * so new plugins are picked up automatically without code changes. */ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { logger } from '@tinyclaw/logger'; import type { ChannelPlugin, ConfigManagerInterface, ProviderPlugin, + SecretsManagerInterface, TinyClawPlugin, + Tool, ToolsPlugin, } from '@tinyclaw/types'; @@ -82,6 +90,138 @@ export async function loadPlugins(configManager: ConfigManagerInterface): Promis return result; } +/** + * Directories inside the monorepo `plugins/` folder that contain plugin + * packages (each sub-folder must have a `package.json` with a `name` field). + */ +const PLUGIN_CATEGORY_DIRS = ['channel', 'provider'] as const; + +/** + * Scan the workspace `plugins/` directories and return all plugin package IDs. + * + * Walks `plugins/channel/*` and `plugins/provider/*`, reads each `package.json`, + * and collects the `name` field. This way new plugins added to the monorepo + * are discovered automatically β€” no hardcoded list to maintain. + * + * Works for all deployment types: + * - **Source / dev**: scans the `plugins/` directory from the workspace root + * - **Docker**: same β€” `plugins/` is copied into the image + * - **npm global install**: `plugins/` won't exist, returns empty array + * (npm-installed plugins are resolved via dynamic `import()` at pairing + * time, once they're added to `plugins.enabled`) + */ +function scanInstalledPluginIds(): string[] { + try { + // Resolve monorepo root: this file lives at packages/plugins/src/index.ts + // (or packages/plugins/dist/index.js after build), so root is 3 levels up. + const thisDir = dirname(fileURLToPath(import.meta.url)); + const workspaceRoot = join(thisDir, '..', '..', '..'); + const pluginsRoot = join(workspaceRoot, 'plugins'); + + if (!existsSync(pluginsRoot)) { + logger.debug('Plugin scan: plugins/ directory not found β€” no discoverable plugins'); + return []; + } + + const ids: string[] = []; + + for (const category of PLUGIN_CATEGORY_DIRS) { + const categoryDir = join(pluginsRoot, category); + if (!existsSync(categoryDir)) continue; + + const entries = readdirSync(categoryDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const pkgPath = join(categoryDir, entry.name, 'package.json'); + if (!existsSync(pkgPath)) continue; + + try { + const raw = readFileSync(pkgPath, 'utf-8'); + const pkg = JSON.parse(raw) as { name?: string }; + + if (typeof pkg.name === 'string' && pkg.name.startsWith('@tinyclaw/plugin-')) { + ids.push(pkg.name); + } + } catch { + // Malformed package.json β€” skip silently + } + } + } + + logger.debug('Plugin scan: discovered plugins', { ids }); + return ids; + } catch { + logger.debug('Plugin scan failed β€” no discoverable plugins'); + return []; + } +} + +/** + * Discover pairing tools from all installed plugins β€” not just enabled ones. + * + * This solves the chicken-and-egg problem: pairing tools (e.g. `discord_pair`) + * must be available to the agent *before* the plugin is enabled, otherwise the + * agent has no way to activate plugins conversationally. + * + * Only pairing tools are extracted here; full plugin lifecycle (start/stop) is + * still gated by `plugins.enabled` via `loadPlugins()`. + * + * @param enabledIds - IDs already in `plugins.enabled` (their tools are loaded + * separately via loadPlugins β€” we skip them here to avoid duplicates) + * @param secrets - SecretsManager for pairing tools that need it + * @param configManager - ConfigManager for pairing tools that need it + * @returns Array of pairing tools from not-yet-enabled plugins + */ +export async function discoverPairingTools( + enabledIds: string[], + secrets: SecretsManagerInterface, + configManager: ConfigManagerInterface, +): Promise { + const tools: Tool[] = []; + const enabledSet = new Set(enabledIds); + + // Collect IDs from both official (monorepo) and community (config) sources + const officialIds = scanInstalledPluginIds(); + const communityIds = configManager.get('plugins.community') ?? []; + const allPluginIds = [...new Set([...officialIds, ...communityIds])]; + + for (const id of allPluginIds) { + // Skip plugins that are already enabled β€” their pairing tools are loaded + // through the normal loadPlugins β†’ getPairingTools path. + if (enabledSet.has(id)) continue; + + try { + const mod = await import(id); + const plugin = mod.default as TinyClawPlugin | undefined; + + if (!plugin || !isValidPlugin(plugin)) continue; + + // Extract pairing tools from channel and provider plugins + if ( + (plugin.type === 'channel' || plugin.type === 'provider') && + 'getPairingTools' in plugin && + typeof plugin.getPairingTools === 'function' + ) { + const pairingTools = plugin.getPairingTools(secrets, configManager); + if (pairingTools.length > 0) { + tools.push(...pairingTools); + const source = communityIds.includes(id) ? 'community' : 'official'; + logger.info(`Discovered pairing tools from: ${plugin.name} (${plugin.id}) [${source}]`, { + toolNames: pairingTools.map((t) => t.name), + }); + } + } + } catch (err) { + // Non-fatal β€” plugin may not be installed in this environment + logger.debug(`Could not discover plugin "${id}": ${(err as Error).message}`); + } + } + + return tools; +} + /** Minimal structural validation for a plugin object. */ function isValidPlugin(obj: unknown): obj is TinyClawPlugin { if (!obj || typeof obj !== 'object') return false; @@ -93,3 +233,19 @@ function isValidPlugin(obj: unknown): obj is TinyClawPlugin { ['channel', 'provider', 'tools'].includes(p.type as string) ); } + +export type { CommunityPluginInfo, InstallResult } from './community.js'; +// Re-export community plugin management +export { + getCommunityPlugins, + installCommunityPlugin, + listCommunityPlugins, + removeCommunityPlugin, + validatePackageName, +} from './community.js'; +export type { PluginUpdateInfo, PluginVersionInfo } from './update-checker.js'; +// Re-export plugin update checker +export { + buildPluginUpdateContext, + checkPluginUpdates, +} from './update-checker.js'; diff --git a/packages/plugins/src/update-checker.ts b/packages/plugins/src/update-checker.ts new file mode 100644 index 0000000..41dd3be --- /dev/null +++ b/packages/plugins/src/update-checker.ts @@ -0,0 +1,420 @@ +/** + * Plugin Update Checker + * + * Checks the npm registry for newer versions of installed Tiny Claw plugins. + * Results are cached locally (24-hour TTL) to avoid repeated network calls. + * + * The update info is injected into the agent's system prompt context so the + * AI can conversationally inform the user about available plugin upgrades. + * + * Follows the same pattern as the core update checker in @tinyclaw/core. + */ + +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { logger } from '@tinyclaw/logger'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface PluginVersionInfo { + /** Plugin package name (e.g. "@tinyclaw/plugin-channel-discord"). */ + id: string; + /** Human-readable name (e.g. "Discord"). */ + name: string; + /** Currently installed version. */ + current: string; + /** Latest version published on npm. */ + latest: string; + /** Whether a newer version is available. */ + updateAvailable: boolean; +} + +export interface PluginUpdateInfo { + /** Plugins with version information. */ + plugins: PluginVersionInfo[]; + /** Number of plugins with available updates. */ + updatableCount: number; + /** Detected runtime environment. */ + runtime: 'npm' | 'docker' | 'source'; + /** Timestamp (ms) of the last check. */ + checkedAt: number; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Time-to-live for the cache file (24 hours). */ +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +/** npm registry base URL. */ +const NPM_REGISTRY_BASE = 'https://registry.npmjs.org'; + +/** Maximum time to wait for each registry response (ms). */ +const FETCH_TIMEOUT_MS = 5_000; + +/** Cache file name within the data directory. */ +const CACHE_FILENAME = 'plugin-update-check.json'; + +/** + * Directories inside the monorepo `plugins/` folder that contain plugin + * packages. + */ +const PLUGIN_CATEGORY_DIRS = ['channel', 'provider'] as const; + +// --------------------------------------------------------------------------- +// Semver comparison (minimal β€” same as core update-checker) +// --------------------------------------------------------------------------- + +function isNewerVersion(current: string, latest: string): boolean { + const parse = (v: string): number[] => + v + .replace(/^v/, '') + .replace(/[-+].*$/, '') + .split('.') + .map((s) => { + const n = Number(s); + return Number.isNaN(n) ? 0 : n; + }) + .slice(0, 3); + const [cMaj = 0, cMin = 0, cPat = 0] = parse(current); + const [lMaj = 0, lMin = 0, lPat = 0] = parse(latest); + if (lMaj !== cMaj) return lMaj > cMaj; + if (lMin !== cMin) return lMin > cMin; + return lPat > cPat; +} + +/** Matches a semver-like version string. */ +const SEMVER_RE = /^v?\d+\.\d+\.\d+/; + +/** Sanitize a version string for safe prompt interpolation. */ +function sanitizeVersion(value: string): string { + const trimmed = value.trim(); + if (!SEMVER_RE.test(trimmed)) return 'unknown'; + return trimmed.replace(/^(v?\d+\.\d+\.\d+)[\s\S]*$/, '$1'); +} + +/** Sanitize a package name for safe prompt interpolation. */ +function sanitizePackageName(value: string): string { + // Only allow scoped npm package names: @scope/name with alphanumeric, hyphens, dots + return value.replace(/[^a-zA-Z0-9@/_.-]/g, ''); +} + +// --------------------------------------------------------------------------- +// Runtime detection +// --------------------------------------------------------------------------- + +function detectRuntime(): 'npm' | 'docker' | 'source' { + const envRuntime = process.env.TINYCLAW_RUNTIME?.toLowerCase(); + if (envRuntime === 'docker') return 'docker'; + if (envRuntime === 'source') return 'source'; + try { + if (existsSync('/.dockerenv')) return 'docker'; + } catch { + // Permission errors β€” assume npm + } + return 'npm'; +} + +// --------------------------------------------------------------------------- +// Plugin scanning β€” read installed plugin IDs and versions from workspace +// --------------------------------------------------------------------------- + +interface InstalledPlugin { + id: string; + name: string; + version: string; +} + +// --------------------------------------------------------------------------- +// Community plugin scanning +// --------------------------------------------------------------------------- + +/** + * Resolve community (non-monorepo) plugins by dynamically importing them + * and reading their package metadata. Imports run in parallel. + */ +async function scanCommunityPlugins(communityIds: string[]): Promise { + const results = await Promise.all( + communityIds + .filter((id) => !id.startsWith('@tinyclaw/plugin-')) + .map(async (id): Promise => { + try { + const mod = await import(id); + const plugin = mod.default as Record | undefined; + + if (plugin && typeof plugin === 'object' && typeof plugin.version === 'string') { + return { + id, + name: typeof plugin.name === 'string' ? plugin.name : id, + version: plugin.version, + }; + } + return null; + } catch { + return null; + } + }), + ); + + return results.filter((p): p is InstalledPlugin => p !== null); +} + +// --------------------------------------------------------------------------- +// Official plugin scanning +// --------------------------------------------------------------------------- + +/** + * Scan the workspace for installed plugins and their current versions. + */ +function scanInstalledPlugins(): InstalledPlugin[] { + try { + const thisDir = dirname(fileURLToPath(import.meta.url)); + const workspaceRoot = join(thisDir, '..', '..', '..'); + const pluginsRoot = join(workspaceRoot, 'plugins'); + + if (!existsSync(pluginsRoot)) return []; + + const plugins: InstalledPlugin[] = []; + + for (const category of PLUGIN_CATEGORY_DIRS) { + const categoryDir = join(pluginsRoot, category); + if (!existsSync(categoryDir)) continue; + + const entries = readdirSync(categoryDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const pkgPath = join(categoryDir, entry.name, 'package.json'); + if (!existsSync(pkgPath)) continue; + + try { + const raw = readFileSync(pkgPath, 'utf-8'); + const pkg = JSON.parse(raw) as { + name?: string; + version?: string; + description?: string; + }; + + if (typeof pkg.name === 'string' && pkg.name.startsWith('@tinyclaw/plugin-')) { + plugins.push({ + id: pkg.name, + name: pkg.description?.replace(/plugin for Tiny Claw/i, '').trim() || entry.name, + version: pkg.version || '0.0.0', + }); + } + } catch { + // Malformed package.json β€” skip + } + } + } + + return plugins; + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// Cache I/O +// --------------------------------------------------------------------------- + +function getCachePath(dataDir: string): string { + return join(dataDir, 'data', CACHE_FILENAME); +} + +function readCache(dataDir: string): PluginUpdateInfo | null { + try { + const raw = readFileSync(getCachePath(dataDir), 'utf-8'); + const cached = JSON.parse(raw) as PluginUpdateInfo; + if ( + cached && + typeof cached.checkedAt === 'number' && + Array.isArray(cached.plugins) && + typeof cached.updatableCount === 'number' + ) { + return cached; + } + } catch { + // Missing or corrupt β€” will re-check + } + return null; +} + +function writeCache(dataDir: string, info: PluginUpdateInfo): void { + try { + const dir = join(dataDir, 'data'); + mkdirSync(dir, { recursive: true }); + writeFileSync(getCachePath(dataDir), JSON.stringify(info, null, 2), 'utf-8'); + } catch (err) { + logger.debug('Failed to write plugin update cache', err); + } +} + +// --------------------------------------------------------------------------- +// Registry fetch +// --------------------------------------------------------------------------- + +async function fetchLatestPluginVersion(packageName: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const url = `${NPM_REGISTRY_BASE}/${encodeURIComponent(packageName)}/latest`; + const res = await fetch(url, { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }); + + if (!res.ok) return null; + const data = (await res.json()) as { version?: string }; + return data.version ?? null; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Check all installed plugins for available updates. + * + * Scans both official (monorepo) and community (config-registered) plugins. + * + * - Returns cached result if still fresh (< 24 hours old). + * - Otherwise fetches the npm registry for each plugin. + * - Never throws β€” returns null on any failure. + * + * @param dataDir - The tinyclaw data directory (e.g. `~/.tinyclaw`). + * @param communityPluginIds - Optional list of community plugin IDs to also check. + */ +export async function checkPluginUpdates( + dataDir: string, + communityPluginIds?: string[], +): Promise { + try { + const officialPlugins = scanInstalledPlugins(); + const communityPlugins = await scanCommunityPlugins(communityPluginIds ?? []); + const installed = [...officialPlugins, ...communityPlugins]; + if (installed.length === 0) return null; + + // Return cached result if still fresh AND the plugin set hasn't changed. + // Adding or removing a plugin busts the cache so the new plugin is checked. + const cached = readCache(dataDir); + const cachedIds = new Set(cached?.plugins.map((p) => p.id) ?? []); + const installedIds = new Set(installed.map((p) => p.id)); + const samePluginSet = + cachedIds.size === installedIds.size && [...installedIds].every((id) => cachedIds.has(id)); + + if (cached && samePluginSet && Date.now() - cached.checkedAt < CACHE_TTL_MS) { + // Re-evaluate against currently installed versions + const refreshed = cached.plugins.map((cp) => { + const local = installed.find((ip) => ip.id === cp.id); + const current = local?.version ?? cp.current; + return { + ...cp, + current, + updateAvailable: isNewerVersion(current, cp.latest), + }; + }); + const updatableCount = refreshed.filter((p) => p.updateAvailable).length; + return { ...cached, plugins: refreshed, updatableCount }; + } + + // Fetch latest versions from npm (parallel, with individual timeouts) + const results: PluginVersionInfo[] = await Promise.all( + installed.map(async (plugin) => { + const latest = await fetchLatestPluginVersion(plugin.id); + return { + id: plugin.id, + name: plugin.name, + current: plugin.version, + latest: latest ?? plugin.version, + updateAvailable: latest ? isNewerVersion(plugin.version, latest) : false, + }; + }), + ); + + const runtime = detectRuntime(); + const info: PluginUpdateInfo = { + plugins: results, + updatableCount: results.filter((p) => p.updateAvailable).length, + runtime, + checkedAt: Date.now(), + }; + + writeCache(dataDir, info); + + if (info.updatableCount > 0) { + logger.info( + 'Plugin updates available', + { + count: info.updatableCount, + plugins: results + .filter((p) => p.updateAvailable) + .map((p) => `${p.id}@${p.current} β†’ ${p.latest}`), + }, + { emoji: 'πŸ”Œ' }, + ); + } + + return info; + } catch (err) { + logger.debug('Plugin update check failed', err); + return null; + } +} + +// --------------------------------------------------------------------------- +// System prompt context builder +// --------------------------------------------------------------------------- + +/** + * Build a system prompt section that informs the agent about available + * plugin updates. Returns an empty string if no updates are available. + */ +export function buildPluginUpdateContext(info: PluginUpdateInfo | null): string { + if (!info || info.updatableCount === 0) return ''; + + const updatable = info.plugins.filter((p) => p.updateAvailable); + + const pluginLines = updatable + .map((p) => { + const safeName = sanitizePackageName(p.id); + const safeCurrent = sanitizeVersion(p.current); + const safeLatest = sanitizeVersion(p.latest); + return `- **${safeName}**: ${safeCurrent} β†’ ${safeLatest}`; + }) + .join('\n'); + + const upgradeInstructions = + info.runtime === 'npm' + ? `Since you are running as an npm global install, you can upgrade plugins using the shell tool: +${updatable.map((p) => `\`bun install -g ${sanitizePackageName(p.id)}@latest\``).join('\n')} +After upgrading, request a restart using the tinyclaw_restart tool.` + : info.runtime === 'docker' + ? `Since you are running inside a Docker container, plugin updates are included in the new image. +Instruct the owner to pull the latest image and restart the container.` + : `Since you are running from source, instruct the owner to update and rebuild: +\`git pull && bun install && bun run build:plugins\` +Then restart using the tinyclaw_restart tool.`; + + return ` + +## Plugin Updates Available +${pluginLines} + +${upgradeInstructions} + +**Behavior guidelines:** +- Mention plugin updates naturally when relevant, but do not interrupt ongoing tasks. +- Do not repeat the plugin update reminder if the owner has already acknowledged or dismissed it. +- Plugin updates are separate from core Tiny Claw updates β€” both should be kept current.`; +} diff --git a/packages/secrets/src/manager.ts b/packages/secrets/src/manager.ts index 441e395..3cc3a31 100644 --- a/packages/secrets/src/manager.ts +++ b/packages/secrets/src/manager.ts @@ -49,6 +49,12 @@ export class SecretsManager implements SecretsManagerInterface { return await this.retrieve(key); } + async destroy(): Promise { + const storagePath = this.engine.storagePath; + await this.engine.destroy(); + logger.debug('Secrets engine destroyed', { storagePath }); + } + get size(): number { return this.engine.size; } diff --git a/packages/secrets/src/secrets-engine.d.ts b/packages/secrets/src/secrets-engine.d.ts index c435463..a7add6a 100644 --- a/packages/secrets/src/secrets-engine.d.ts +++ b/packages/secrets/src/secrets-engine.d.ts @@ -11,6 +11,7 @@ declare module '@wgtechlabs/secrets-engine' { get(key: string): Promise; has(key: string): Promise; keys(pattern?: string): Promise; + destroy(): Promise; close(): Promise; } } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d0acb02..0938066 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -150,6 +150,17 @@ export const OWNER_ONLY_TOOLS: ReadonlySet = new Set([ // Discord channel management 'discord_pair', 'discord_unpair', + // Telegram channel management + 'telegram_pair', + 'telegram_unpair', + // Provider pairing + 'openai_pair', + 'openai_unpair', + 'ollama_pair', + 'ollama_unpair', + // Community plugin management + 'plugin_install', + 'plugin_remove', ]); /** @@ -558,6 +569,8 @@ export interface SecretsManagerInterface { list(pattern?: string): Promise; /** Convenience: resolve a provider API key by provider name */ resolveProviderKey(providerName: string): Promise; + /** Permanently destroy the underlying secrets store */ + destroy(): Promise; /** Close the underlying secrets engine */ close(): Promise; } @@ -643,7 +656,10 @@ export interface ChannelPlugin extends PluginMeta { export interface ProviderPlugin extends PluginMeta { readonly type: 'provider'; /** Create and return an initialized Provider instance. */ - createProvider(secrets: SecretsManagerInterface): Promise; + createProvider( + secrets: SecretsManagerInterface, + configManager: ConfigManagerInterface, + ): Promise; /** Optional pairing tools for conversational setup (API key, model config). */ getPairingTools?(secrets: SecretsManagerInterface, configManager: ConfigManagerInterface): Tool[]; } diff --git a/plugins/channel/README.md b/plugins/channel/README.md index c002803..0e60ac9 100644 --- a/plugins/channel/README.md +++ b/plugins/channel/README.md @@ -1,6 +1,6 @@ # Channel Plugin Development Guide -This guide documents how Tiny Claw channel plugins work so you can build your own. Two reference implementations ship with the repo: **Discord** (external platform integration) and **Friends** (built-in web chat). +This guide documents how Tiny Claw channel plugins work so you can build your own. Three reference implementations ship with the repo: **Discord** (external platform integration), **Telegram** (minimal Bot API integration), and **Friends** (built-in web chat). ## What a Channel Plugin Does @@ -217,6 +217,7 @@ When implementing `start(context)`: | Plugin | Path | Description | |--------|------|-------------| | **Discord** | `plugins/channel/plugin-channel-discord/` | External platform integration via discord.js | +| **Telegram** | `plugins/channel/plugin-channel-telegram/` | Minimal Telegram Bot API integration via long polling | | **Friends** | `plugins/channel/plugin-channel-friends/` | Built-in invite-based web chat channel | Key files in the Discord plugin: diff --git a/plugins/channel/plugin-channel-discord/src/index.ts b/plugins/channel/plugin-channel-discord/src/index.ts index 63e9546..438b2d7 100644 --- a/plugins/channel/plugin-channel-discord/src/index.ts +++ b/plugins/channel/plugin-channel-discord/src/index.ts @@ -39,6 +39,24 @@ import { let client: Client | null = null; +export interface DiscordRuntimeStatus { + enabled: boolean; + state: 'idle' | 'disabled' | 'starting' | 'connected' | 'error' | 'stopped'; + readyTag: string | null; + lastError: string | null; +} + +const runtimeStatus: DiscordRuntimeStatus = { + enabled: false, + state: 'idle', + readyTag: null, + lastError: null, +}; + +export function getDiscordRuntimeStatus(): DiscordRuntimeStatus { + return { ...runtimeStatus }; +} + const discordPlugin: ChannelPlugin = { id: '@tinyclaw/plugin-channel-discord', name: 'Discord', @@ -53,17 +71,29 @@ const discordPlugin: ChannelPlugin = { async start(context: PluginRuntimeContext): Promise { const isEnabled = context.configManager.get(DISCORD_ENABLED_CONFIG_KEY); + runtimeStatus.enabled = Boolean(isEnabled); + if (!isEnabled) { + runtimeStatus.state = 'disabled'; + runtimeStatus.readyTag = null; + runtimeStatus.lastError = null; logger.info('Discord plugin: not enabled β€” run pairing to enable'); return; } const token = await context.secrets.retrieve(DISCORD_TOKEN_SECRET_KEY); if (!token) { + runtimeStatus.state = 'error'; + runtimeStatus.readyTag = null; + runtimeStatus.lastError = 'Discord bot token not found in secrets'; logger.warn('Discord plugin: enabled but no token found β€” re-pair to fix'); return; } + runtimeStatus.state = 'starting'; + runtimeStatus.readyTag = null; + runtimeStatus.lastError = null; + client = new Client({ intents: [ GatewayIntentBits.Guilds, @@ -75,9 +105,19 @@ const discordPlugin: ChannelPlugin = { }); client.once(Events.ClientReady, (readyClient) => { + runtimeStatus.state = 'connected'; + runtimeStatus.readyTag = readyClient.user.tag; + runtimeStatus.lastError = null; logger.info(`Discord bot ready: ${readyClient.user.tag}`); }); + client.on(Events.Error, (error) => { + runtimeStatus.state = 'error'; + runtimeStatus.readyTag = null; + runtimeStatus.lastError = error.message; + logger.error('Discord plugin: client error', error); + }); + client.on(Events.MessageCreate, async (msg: DiscordMessage) => { // Ignore messages from bots (including self) if (msg.author.bot) return; @@ -124,8 +164,19 @@ const discordPlugin: ChannelPlugin = { } }); - await client.login(token); - logger.info('Discord bot connected'); + try { + await client.login(token); + logger.info('Discord bot connected'); + } catch (error) { + runtimeStatus.state = 'error'; + runtimeStatus.readyTag = null; + runtimeStatus.lastError = error instanceof Error ? error.message : String(error); + if (client) { + client.destroy(); + client = null; + } + throw error; + } }, async sendToUser(userId: string, message: OutboundMessage): Promise { @@ -165,6 +216,9 @@ const discordPlugin: ChannelPlugin = { client = null; logger.info('Discord bot disconnected'); } + + runtimeStatus.state = runtimeStatus.enabled ? 'stopped' : 'disabled'; + runtimeStatus.readyTag = null; }, }; diff --git a/plugins/channel/plugin-channel-discord/src/pairing.ts b/plugins/channel/plugin-channel-discord/src/pairing.ts index 2b5588d..e5510fc 100644 --- a/plugins/channel/plugin-channel-discord/src/pairing.ts +++ b/plugins/channel/plugin-channel-discord/src/pairing.ts @@ -29,6 +29,7 @@ export function createDiscordPairingTools( name: 'discord_pair', description: 'Pair Tiny Claw with a Discord bot. ' + + 'If the user has not provided a bot token yet, first walk them through creating one in the Discord Developer Portal and ask them to paste it here. ' + 'Stores the bot token securely and enables the Discord channel plugin. ' + 'After pairing, call tinyclaw_restart to connect the bot. ' + 'To get a token: go to https://discord.com/developers/applications, ' + diff --git a/plugins/channel/plugin-channel-discord/tests/index.test.ts b/plugins/channel/plugin-channel-discord/tests/index.test.ts index 9856505..7b2531b 100644 --- a/plugins/channel/plugin-channel-discord/tests/index.test.ts +++ b/plugins/channel/plugin-channel-discord/tests/index.test.ts @@ -6,7 +6,7 @@ */ import { describe, expect, test } from 'bun:test'; -import discordPlugin, { splitIntoChunks } from '../src/index.js'; +import discordPlugin, { getDiscordRuntimeStatus, splitIntoChunks } from '../src/index.js'; // --------------------------------------------------------------------------- // Plugin metadata @@ -80,6 +80,15 @@ describe('stop', () => { test('does not throw when called without start', async () => { await expect(discordPlugin.stop()).resolves.toBeUndefined(); }); + + test('exposes runtime status', async () => { + const status = getDiscordRuntimeStatus(); + expect(status.state).toBeDefined(); + expect(typeof status.enabled).toBe('boolean'); + + await expect(discordPlugin.stop()).resolves.toBeUndefined(); + expect(getDiscordRuntimeStatus().state).toBeDefined(); + }); }); // --------------------------------------------------------------------------- diff --git a/plugins/channel/plugin-channel-telegram/CHANGELOG.md b/plugins/channel/plugin-channel-telegram/CHANGELOG.md new file mode 100644 index 0000000..f6cd8cb --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- add the initial Telegram channel plugin with pairing tools and polling runtime diff --git a/plugins/channel/plugin-channel-telegram/README.md b/plugins/channel/plugin-channel-telegram/README.md new file mode 100644 index 0000000..42ddb84 --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/README.md @@ -0,0 +1,29 @@ +# @tinyclaw/plugin-channel-telegram + +Telegram channel plugin for Tiny Claw. It connects a Telegram bot to the agent using the official Bot API over long polling. + +## Setup + +1. Create a bot with [@BotFather](https://t.me/BotFather) +2. Copy the bot token +3. Run Tiny Claw and ask it to pair the Telegram channel +4. Provide the token when prompted +5. Call `tinyclaw_restart` to connect the bot + +## How It Works + +- Listens for private chats and `@botname` mentions in groups +- Routes messages through the agent loop as `telegram:` +- Splits long responses to respect Telegram's message size limit +- Supports outbound proactive messages to users who have already started a private chat with the bot + +## Pairing Tools + +| Tool | Description | +|------|-------------| +| `telegram_pair` | Store a bot token and enable the plugin | +| `telegram_unpair` | Disable the plugin (token kept in secrets) | + +## License + +GPLv3 diff --git a/plugins/channel/plugin-channel-telegram/package.json b/plugins/channel/plugin-channel-telegram/package.json new file mode 100644 index 0000000..5994696 --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/package.json @@ -0,0 +1,39 @@ +{ + "name": "@tinyclaw/plugin-channel-telegram", + "version": "2.0.0", + "description": "Telegram channel plugin for Tiny Claw", + "license": "GPL-3.0", + "author": "Waren Gonzaga", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/warengonzaga/tinyclaw.git", + "directory": "plugins/channel/plugin-channel-telegram" + }, + "homepage": "https://github.com/warengonzaga/tinyclaw/tree/main/plugins/channel/plugin-channel-telegram#readme", + "bugs": { + "url": "https://github.com/warengonzaga/tinyclaw/issues" + }, + "keywords": [ + "tinyclaw", + "plugin", + "channel", + "telegram", + "bot" + ], + "scripts": { + "build": "tsc -p tsconfig.json" + }, + "dependencies": { + "@tinyclaw/logger": "workspace:*", + "@tinyclaw/types": "workspace:*" + } +} diff --git a/plugins/channel/plugin-channel-telegram/src/index.ts b/plugins/channel/plugin-channel-telegram/src/index.ts new file mode 100644 index 0000000..ec1b485 --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/src/index.ts @@ -0,0 +1,366 @@ +import { logger } from '@tinyclaw/logger'; +import type { + ChannelPlugin, + ConfigManagerInterface, + OutboundMessage, + PluginRuntimeContext, + SecretsManagerInterface, + Tool, +} from '@tinyclaw/types'; +import { + createTelegramPairingTools, + TELEGRAM_ENABLED_CONFIG_KEY, + TELEGRAM_TOKEN_SECRET_KEY, +} from './pairing.js'; + +const TELEGRAM_API_BASE = 'https://api.telegram.org'; +const TELEGRAM_MESSAGE_LIMIT = 4000; +const TELEGRAM_POLL_TIMEOUT_SECONDS = 30; +const TELEGRAM_RETRY_DELAY_MS = 3000; + +interface TelegramUser { + id: number; + is_bot: boolean; + first_name: string; + username?: string; +} + +interface TelegramChat { + id: number; + type: 'private' | 'group' | 'supergroup' | 'channel'; +} + +interface TelegramMessage { + message_id: number; + chat: TelegramChat; + from?: TelegramUser; + text?: string; + caption?: string; +} + +interface TelegramUpdate { + update_id: number; + message?: TelegramMessage; +} + +interface TelegramApiResponse { + ok: boolean; + result: T; + description?: string; +} + +let pollAbortController: AbortController | null = null; +let pollingTask: Promise | null = null; +let botToken: string | null = null; +const privateChatRoutes = new Map(); + +const telegramPlugin: ChannelPlugin = { + id: '@tinyclaw/plugin-channel-telegram', + name: 'Telegram', + description: 'Connect Tiny Claw to a Telegram bot', + type: 'channel', + version: '2.0.0', + channelPrefix: 'telegram', + + getPairingTools(secrets: SecretsManagerInterface, configManager: ConfigManagerInterface): Tool[] { + return createTelegramPairingTools(secrets, configManager); + }, + + async start(context: PluginRuntimeContext): Promise { + if (pollingTask) { + logger.warn('Telegram plugin: polling is already active'); + return; + } + + const isEnabled = context.configManager.get(TELEGRAM_ENABLED_CONFIG_KEY); + if (!isEnabled) { + logger.info('Telegram plugin: not enabled β€” run pairing to enable'); + return; + } + + const token = await context.secrets.retrieve(TELEGRAM_TOKEN_SECRET_KEY); + if (!token) { + logger.warn('Telegram plugin: enabled but no token found β€” re-pair to fix'); + return; + } + + const me = await telegramApi(token, 'getMe'); + + botToken = token; + pollAbortController = new AbortController(); + pollingTask = pollForUpdates( + context, + token, + me.username ?? null, + pollAbortController.signal, + ).catch((err) => { + if (!isAbortError(err)) { + logger.error('Telegram plugin: polling stopped unexpectedly', err); + } + }); + + const botLabel = me.username ? `@${me.username}` : me.first_name; + logger.info(`Telegram bot connected: ${botLabel}`); + }, + + async sendToUser(userId: string, message: OutboundMessage): Promise { + if (!botToken) { + throw new Error('Telegram bot is not connected'); + } + + const telegramUserId = parseTelegramUserId(userId); + const chatId = privateChatRoutes.get(userId) ?? telegramUserId; + await sendTelegramText(botToken, chatId, message.content); + logger.info(`Telegram: sent outbound message to ${userId}`); + }, + + async stop(): Promise { + const task = pollingTask; + const controller = pollAbortController; + + pollingTask = null; + pollAbortController = null; + botToken = null; + privateChatRoutes.clear(); + + if (controller) { + controller.abort(); + } + + if (task) { + await task.catch(() => {}); + logger.info('Telegram bot disconnected'); + } + }, +}; + +async function pollForUpdates( + context: PluginRuntimeContext, + token: string, + username: string | null, + signal: AbortSignal, +): Promise { + let offset = 0; + + while (!signal.aborted) { + try { + const updates = await telegramApi( + token, + 'getUpdates', + { + offset, + timeout: TELEGRAM_POLL_TIMEOUT_SECONDS, + allowed_updates: ['message'], + }, + signal, + ); + + for (const update of updates) { + offset = update.update_id + 1; + if (!update.message) continue; + await handleIncomingMessage(context, token, update.message, username); + } + } catch (err) { + if (isAbortError(err)) { + return; + } + + logger.error('Telegram plugin: failed to poll updates', err); + await waitForRetry(signal); + } + } +} + +async function handleIncomingMessage( + context: PluginRuntimeContext, + token: string, + message: TelegramMessage, + username: string | null, +): Promise { + if (!message.from || message.from.is_bot) { + return; + } + + const rawText = getTelegramMessageText(message); + if (!shouldHandleTelegramMessage(message, rawText, username)) { + return; + } + + const content = normalizeTelegramText(rawText, username); + if (!content) { + return; + } + + const userId = `telegram:${message.from.id}`; + + if (message.chat.type === 'private') { + privateChatRoutes.set(userId, message.chat.id); + } + + try { + const response = await context.enqueue(userId, content); + await sendTelegramText(token, message.chat.id, response, message.message_id); + } catch (err) { + logger.error('Telegram plugin: error handling message', err); + try { + await sendTelegramText( + token, + message.chat.id, + 'Sorry, I ran into an error. Please try again.', + message.message_id, + ); + } catch { + // Ignore secondary delivery failures. + } + } +} + +async function sendTelegramText( + token: string, + chatId: number, + text: string, + replyToMessageId?: number, +): Promise { + const chunks = splitIntoChunks(text, TELEGRAM_MESSAGE_LIMIT); + + for (const [index, chunk] of chunks.entries()) { + const payload: Record = { + chat_id: chatId, + text: chunk, + }; + + if (replyToMessageId && index === 0) { + payload.reply_to_message_id = replyToMessageId; + } + + await telegramApi(token, 'sendMessage', payload); + } +} + +async function telegramApi( + token: string, + method: string, + payload?: Record, + signal?: AbortSignal, +): Promise { + const response = await fetch(`${TELEGRAM_API_BASE}/bot${token}/${method}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: payload ? JSON.stringify(payload) : undefined, + signal, + }); + + if (!response.ok) { + throw new Error(`Telegram API ${method} failed with HTTP ${response.status}`); + } + + const data = (await response.json()) as TelegramApiResponse; + if (!data.ok) { + throw new Error(data.description || `Telegram API ${method} failed`); + } + + return data.result; +} + +async function waitForRetry(signal: AbortSignal): Promise { + if (signal.aborted) { + return; + } + + await new Promise((resolve) => { + const timeout = setTimeout(resolve, TELEGRAM_RETRY_DELAY_MS); + signal.addEventListener( + 'abort', + () => { + clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + }); +} + +function getTelegramMessageText(message: TelegramMessage): string { + return (message.text ?? message.caption ?? '').trim(); +} + +export function shouldHandleTelegramMessage( + message: Pick, + text: string, + username: string | null, +): boolean { + if (!text) { + return false; + } + + if (message.chat.type === 'private') { + return true; + } + + if (message.chat.type === 'group' || message.chat.type === 'supergroup') { + if (!username) { + return false; + } + return createMentionPattern(username).test(text); + } + + return false; +} + +export function normalizeTelegramText(text: string, username: string | null): string { + let normalized = text.trim(); + if (username) { + normalized = normalized + .replace(createMentionPattern(username), ' ') + .replace(/\s+/g, ' ') + .trim(); + } + return normalized; +} + +export function splitIntoChunks(text: string, maxLength: number): string[] { + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > maxLength) { + let splitAt = remaining.lastIndexOf('\n', maxLength); + if (splitAt === -1) splitAt = remaining.lastIndexOf(' ', maxLength); + if (splitAt === -1) splitAt = maxLength; + + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).trimStart(); + } + + if (remaining.length > 0) { + chunks.push(remaining); + } + + return chunks; +} + +function createMentionPattern(username: string): RegExp { + return new RegExp(`@${escapeRegExp(username)}\\b`, 'gi'); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function parseTelegramUserId(userId: string): number { + const numericPart = userId.replace(/^telegram:/, ''); + const parsed = Number(numericPart); + + if (!Number.isInteger(parsed)) { + throw new Error(`Invalid Telegram userId: ${userId}`); + } + + return parsed; +} + +function isAbortError(err: unknown): boolean { + return err instanceof DOMException && err.name === 'AbortError'; +} + +export default telegramPlugin; diff --git a/plugins/channel/plugin-channel-telegram/src/pairing.ts b/plugins/channel/plugin-channel-telegram/src/pairing.ts new file mode 100644 index 0000000..b6df495 --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/src/pairing.ts @@ -0,0 +1,87 @@ +import type { ConfigManagerInterface, SecretsManagerInterface, Tool } from '@tinyclaw/types'; +import { buildChannelKeyName } from '@tinyclaw/types'; + +export const TELEGRAM_TOKEN_SECRET_KEY = buildChannelKeyName('telegram'); +export const TELEGRAM_ENABLED_CONFIG_KEY = 'channels.telegram.enabled'; +export const TELEGRAM_PLUGIN_ID = '@tinyclaw/plugin-channel-telegram'; + +export function createTelegramPairingTools( + secrets: SecretsManagerInterface, + configManager: ConfigManagerInterface, +): Tool[] { + return [ + { + name: 'telegram_pair', + description: + 'Pair Tiny Claw with a Telegram bot. ' + + 'Stores the bot token securely and enables the Telegram channel plugin. ' + + 'Create a bot with @BotFather, copy the token, then call tinyclaw_restart to connect it.', + parameters: { + type: 'object', + properties: { + token: { + type: 'string', + description: 'Telegram bot token from BotFather', + }, + }, + required: ['token'], + }, + async execute(args: Record): Promise { + const token = args.token as string; + if (!token || token.trim() === '') { + return 'Error: token must be a non-empty string.'; + } + + try { + await secrets.store(TELEGRAM_TOKEN_SECRET_KEY, token.trim()); + + configManager.set(TELEGRAM_ENABLED_CONFIG_KEY, true); + configManager.set('channels.telegram.tokenRef', TELEGRAM_TOKEN_SECRET_KEY); + + const current = configManager.get('plugins.enabled') ?? []; + if (!current.includes(TELEGRAM_PLUGIN_ID)) { + configManager.set('plugins.enabled', [...current, TELEGRAM_PLUGIN_ID]); + } + + return ( + 'Telegram bot paired successfully! ' + + 'Token stored securely and plugin enabled. ' + + 'Use the tinyclaw_restart tool now to connect the bot.' + ); + } catch (err) { + return `Error pairing Telegram: ${(err as Error).message}`; + } + }, + }, + { + name: 'telegram_unpair', + description: + 'Disconnect the Telegram bot and disable the Telegram channel plugin. ' + + 'The bot token is kept in secrets for safety. Call tinyclaw_restart after.', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + async execute(): Promise { + try { + configManager.set(TELEGRAM_ENABLED_CONFIG_KEY, false); + + const current = configManager.get('plugins.enabled') ?? []; + configManager.set( + 'plugins.enabled', + current.filter((id) => id !== TELEGRAM_PLUGIN_ID), + ); + + return ( + 'Telegram plugin disabled. ' + + 'Use the tinyclaw_restart tool now to apply the changes. ' + + 'The bot token is still stored in secrets.' + ); + } catch (err) { + return `Error unpairing Telegram: ${(err as Error).message}`; + } + }, + }, + ]; +} diff --git a/plugins/channel/plugin-channel-telegram/tests/index.test.ts b/plugins/channel/plugin-channel-telegram/tests/index.test.ts new file mode 100644 index 0000000..f068c88 --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/tests/index.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from 'bun:test'; +import { + normalizeTelegramText, + shouldHandleTelegramMessage, + splitIntoChunks, + default as telegramPlugin, +} from '../src/index.js'; + +describe('telegramPlugin metadata', () => { + test('has the correct id', () => { + expect(telegramPlugin.id).toBe('@tinyclaw/plugin-channel-telegram'); + }); + + test('has a human-readable name', () => { + expect(telegramPlugin.name).toBe('Telegram'); + }); + + test('type is channel', () => { + expect(telegramPlugin.type).toBe('channel'); + }); + + test('has a version string', () => { + expect(telegramPlugin.version).toBeDefined(); + expect(typeof telegramPlugin.version).toBe('string'); + }); + + test('has a description', () => { + expect(telegramPlugin.description).toBeDefined(); + expect(telegramPlugin.description.length).toBeGreaterThan(0); + }); +}); + +describe('getPairingTools', () => { + test('returns tools when called with mock managers', () => { + const mockSecrets = { + store: async () => {}, + check: async () => false, + retrieve: async () => null, + list: async () => [], + resolveProviderKey: async () => null, + close: async () => {}, + }; + const mockConfig = { + get: () => undefined, + has: () => false, + set: () => {}, + delete: () => {}, + reset: () => {}, + clear: () => {}, + store: {}, + size: 0, + path: ':memory:', + onDidChange: () => () => {}, + onDidAnyChange: () => () => {}, + close: () => {}, + }; + + // biome-ignore lint/suspicious/noExplicitAny: partial mock objects for testing + const tools = telegramPlugin.getPairingTools?.(mockSecrets as any, mockConfig as any); + expect(tools).toHaveLength(2); + expect(tools?.map((tool) => tool.name)).toEqual(['telegram_pair', 'telegram_unpair']); + }); +}); + +describe('stop', () => { + test('does not throw when called without start', async () => { + await expect(telegramPlugin.stop()).resolves.toBeUndefined(); + }); +}); + +describe('shouldHandleTelegramMessage', () => { + test('accepts private messages', () => { + expect( + shouldHandleTelegramMessage({ chat: { id: 1, type: 'private' } }, 'hello', 'tinyclaw_bot'), + ).toBe(true); + }); + + test('rejects group messages without a bot mention', () => { + expect( + shouldHandleTelegramMessage( + { chat: { id: -1, type: 'group' } }, + 'hello there', + 'tinyclaw_bot', + ), + ).toBe(false); + }); + + test('accepts group mentions', () => { + expect( + shouldHandleTelegramMessage( + { chat: { id: -1, type: 'supergroup' } }, + '@tinyclaw_bot hello there', + 'tinyclaw_bot', + ), + ).toBe(true); + }); + + test('rejects channel posts', () => { + expect( + shouldHandleTelegramMessage( + { chat: { id: -1, type: 'channel' } }, + '@tinyclaw_bot hello there', + 'tinyclaw_bot', + ), + ).toBe(false); + }); +}); + +describe('normalizeTelegramText', () => { + test('removes bot mentions and normalizes spacing', () => { + expect(normalizeTelegramText(' hey @tinyclaw_bot can you help? ', 'tinyclaw_bot')).toBe( + 'hey can you help?', + ); + }); + + test('preserves text when no username is available', () => { + expect(normalizeTelegramText(' hello there ', null)).toBe('hello there'); + }); + + test('removes repeated mentions case-insensitively', () => { + expect(normalizeTelegramText('@TinyClaw_Bot hi @tinyclaw_bot', 'tinyclaw_bot')).toBe('hi'); + }); +}); + +describe('splitIntoChunks', () => { + test('splits long messages near word boundaries', () => { + const chunks = splitIntoChunks('alpha beta gamma delta', 10); + expect(chunks).toEqual(['alpha beta', 'gamma', 'delta']); + }); + + test('returns the original message when already within the limit', () => { + expect(splitIntoChunks('hello', 10)).toEqual(['hello']); + }); + + test('returns empty array for empty string', () => { + expect(splitIntoChunks('', 10)).toEqual([]); + }); + + test('hard splits when no whitespace exists', () => { + const chunks = splitIntoChunks('a'.repeat(13), 5); + expect(chunks).toEqual(['aaaaa', 'aaaaa', 'aaa']); + }); + + test('ensures every chunk respects the limit', () => { + const text = 'The quick brown fox jumps over the lazy dog. '.repeat(8); + const maxLength = 30; + const chunks = splitIntoChunks(text, maxLength); + + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(maxLength); + } + }); +}); diff --git a/plugins/channel/plugin-channel-telegram/tests/pairing.test.ts b/plugins/channel/plugin-channel-telegram/tests/pairing.test.ts new file mode 100644 index 0000000..db36c35 --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/tests/pairing.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import type { ConfigManagerInterface, SecretsManagerInterface, Tool } from '@tinyclaw/types'; +import { + createTelegramPairingTools, + TELEGRAM_ENABLED_CONFIG_KEY, + TELEGRAM_PLUGIN_ID, + TELEGRAM_TOKEN_SECRET_KEY, +} from '../src/pairing.js'; + +function createMockSecrets(): SecretsManagerInterface & { stored: Map } { + const stored = new Map(); + return { + stored, + async store(key: string, value: string) { + stored.set(key, value); + }, + async check(key: string) { + return stored.has(key); + }, + async retrieve(key: string) { + return stored.get(key) ?? null; + }, + async list(_pattern?: string) { + return Array.from(stored.keys()); + }, + async resolveProviderKey(_provider: string) { + return null; + }, + async close() {}, + }; +} + +function createMockConfig(): ConfigManagerInterface & { data: Record } { + const data: Record = {}; + return { + data, + get(key: string, defaultValue?: V): V | undefined { + return (data[key] as V) ?? defaultValue; + }, + has(key: string) { + return key in data; + }, + set(keyOrObj: string | Record, value?: unknown) { + if (typeof keyOrObj === 'string') { + data[keyOrObj] = value; + } else { + Object.assign(data, keyOrObj); + } + }, + delete(key: string) { + delete data[key]; + }, + reset() {}, + clear() { + for (const key of Object.keys(data)) delete data[key]; + }, + get store() { + return { ...data }; + }, + get size() { + return Object.keys(data).length; + }, + get path() { + return ':memory:'; + }, + onDidChange() { + return () => {}; + }, + onDidAnyChange() { + return () => {}; + }, + close() {}, + }; +} + +function findTool(tools: Tool[], name: string): Tool { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) { + throw new Error(`Tool "${name}" not found`); + } + return tool; +} + +describe('createTelegramPairingTools', () => { + let secrets: ReturnType; + let config: ReturnType; + let tools: Tool[]; + + beforeEach(() => { + secrets = createMockSecrets(); + config = createMockConfig(); + tools = createTelegramPairingTools(secrets, config); + }); + + test('returns two tools', () => { + expect(tools).toHaveLength(2); + expect(tools.map((tool) => tool.name)).toEqual(['telegram_pair', 'telegram_unpair']); + }); + + test('stores token and enables the plugin on pair', async () => { + const tool = findTool(tools, 'telegram_pair'); + const result = await tool.execute({ token: 'telegram-token' }); + + expect(secrets.stored.get(TELEGRAM_TOKEN_SECRET_KEY)).toBe('telegram-token'); + expect(config.data[TELEGRAM_ENABLED_CONFIG_KEY]).toBe(true); + expect(config.data['channels.telegram.tokenRef']).toBe(TELEGRAM_TOKEN_SECRET_KEY); + expect(config.data['plugins.enabled']).toEqual([TELEGRAM_PLUGIN_ID]); + expect(result).toContain('paired successfully'); + }); + + test('trims whitespace from the token', async () => { + const tool = findTool(tools, 'telegram_pair'); + await tool.execute({ token: ' telegram-token ' }); + + expect(secrets.stored.get(TELEGRAM_TOKEN_SECRET_KEY)).toBe('telegram-token'); + }); + + test('keeps enabled plugins deduplicated', async () => { + config.data['plugins.enabled'] = ['@tinyclaw/plugin-other', TELEGRAM_PLUGIN_ID]; + + const tool = findTool(tools, 'telegram_pair'); + await tool.execute({ token: 'telegram-token' }); + + expect(config.data['plugins.enabled']).toEqual(['@tinyclaw/plugin-other', TELEGRAM_PLUGIN_ID]); + }); + + test('rejects empty tokens', async () => { + const tool = findTool(tools, 'telegram_pair'); + const result = await tool.execute({ token: ' ' }); + + expect(result).toContain('Error'); + expect(secrets.stored.size).toBe(0); + }); + + test('preserves other enabled plugins when pairing', async () => { + config.data['plugins.enabled'] = ['@tinyclaw/plugin-other']; + + const tool = findTool(tools, 'telegram_pair'); + await tool.execute({ token: 'telegram-token' }); + + expect(config.data['plugins.enabled']).toEqual(['@tinyclaw/plugin-other', TELEGRAM_PLUGIN_ID]); + }); + + test('handles secrets.store failure gracefully', async () => { + secrets.store = async () => { + throw new Error('disk full'); + }; + + const tool = findTool(tools, 'telegram_pair'); + const result = await tool.execute({ token: 'telegram-token' }); + + expect(result).toContain('Error pairing Telegram'); + expect(result).toContain('disk full'); + }); + + test('disables the plugin on unpair', async () => { + config.data[TELEGRAM_ENABLED_CONFIG_KEY] = true; + config.data['plugins.enabled'] = [TELEGRAM_PLUGIN_ID]; + + const tool = findTool(tools, 'telegram_unpair'); + const result = await tool.execute({}); + + expect(config.data[TELEGRAM_ENABLED_CONFIG_KEY]).toBe(false); + expect(config.data['plugins.enabled']).toEqual([]); + expect(result).toContain('disabled'); + }); + + test('keeps the token in secrets on unpair', async () => { + secrets.stored.set(TELEGRAM_TOKEN_SECRET_KEY, 'existing-token'); + config.data['plugins.enabled'] = [TELEGRAM_PLUGIN_ID]; + + const tool = findTool(tools, 'telegram_unpair'); + await tool.execute({}); + + expect(secrets.stored.get(TELEGRAM_TOKEN_SECRET_KEY)).toBe('existing-token'); + }); +}); + +describe('exported constants', () => { + test('TELEGRAM_TOKEN_SECRET_KEY follows channel naming convention', () => { + expect(TELEGRAM_TOKEN_SECRET_KEY).toBe('channel.telegram.token'); + }); + + test('TELEGRAM_ENABLED_CONFIG_KEY matches config schema', () => { + expect(TELEGRAM_ENABLED_CONFIG_KEY).toBe('channels.telegram.enabled'); + }); + + test('TELEGRAM_PLUGIN_ID is the npm package name', () => { + expect(TELEGRAM_PLUGIN_ID).toBe('@tinyclaw/plugin-channel-telegram'); + }); +}); diff --git a/plugins/channel/plugin-channel-telegram/tsconfig.json b/plugins/channel/plugin-channel-telegram/tsconfig.json new file mode 100644 index 0000000..c8c92cb --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/plugins/provider/README.md b/plugins/provider/README.md index 896be7d..dca6694 100644 --- a/plugins/provider/README.md +++ b/plugins/provider/README.md @@ -20,7 +20,7 @@ The startup flow is: 1. CLI loads plugin IDs from the `plugins.enabled` config array. 2. Plugin modules are imported dynamically by package name. 3. Pairing tools from provider (and channel) plugins are merged into the agent tool list. -4. For each enabled provider plugin, Tiny Claw calls `plugin.createProvider(secrets)` to obtain a `Provider` instance. +4. For each enabled provider plugin, Tiny Claw calls `plugin.createProvider(secrets, configManager)` to obtain a `Provider` instance. 5. The routing system maps query complexity tiers (simple, moderate, complex, reasoning) to provider IDs. 6. At query time, the router selects the appropriate provider based on the tier mapping. @@ -31,7 +31,10 @@ Provider plugins must default-export an object that satisfies `ProviderPlugin` f ```ts export interface ProviderPlugin extends PluginMeta { readonly type: 'provider'; - createProvider(secrets: SecretsManagerInterface): Promise; + createProvider( + secrets: SecretsManagerInterface, + configManager: ConfigManagerInterface, + ): Promise; getPairingTools?( secrets: SecretsManagerInterface, configManager: ConfigManagerInterface, @@ -59,7 +62,7 @@ Required fields on the plugin: | `description` | Short summary | | `type` | Must be `'provider'` | | `version` | SemVer string | -| `createProvider(secrets)` | Factory that returns a `Provider` instance | +| `createProvider(secrets, configManager)` | Factory that returns a `Provider` instance | Optional field: @@ -130,8 +133,15 @@ const plugin: ProviderPlugin = { type: 'provider', version: '0.1.0', - async createProvider(secrets: SecretsManagerInterface) { - return createMyProvider({ secrets }); + async createProvider( + secrets: SecretsManagerInterface, + configManager: ConfigManagerInterface, + ) { + return createMyProvider({ + secrets, + model: configManager.get('providers..model') ?? undefined, + baseUrl: configManager.get('providers..baseUrl') ?? undefined, + }); }, getPairingTools( @@ -294,6 +304,7 @@ configManager.set('routing.tierMapping.reasoning', ''); | Plugin | Path | Description | |--------|------|-------------| | **OpenAI** | `plugins/provider/plugin-provider-openai/` | OpenAI GPT models via raw fetch (no SDK) | +| **Ollama** | `plugins/provider/plugin-provider-ollama/` | Local Ollama and custom Ollama Cloud models | Key files in the OpenAI plugin: diff --git a/plugins/provider/plugin-provider-ollama/CHANGELOG.md b/plugins/provider/plugin-provider-ollama/CHANGELOG.md new file mode 100644 index 0000000..67e130d --- /dev/null +++ b/plugins/provider/plugin-provider-ollama/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- add Ollama provider plugin with local and custom cloud model support +- add conversational model listing and switching tools for the Ollama plugin \ No newline at end of file diff --git a/plugins/provider/plugin-provider-ollama/README.md b/plugins/provider/plugin-provider-ollama/README.md new file mode 100644 index 0000000..a245320 --- /dev/null +++ b/plugins/provider/plugin-provider-ollama/README.md @@ -0,0 +1,28 @@ +# @tinyclaw/plugin-provider-ollama + +Ollama provider plugin for Tiny Claw. It supports both local Ollama instances and custom Ollama Cloud models while keeping the built-in starter models reserved for the built-in provider. + +## What It Adds + +- Local Ollama support with a configurable base URL +- Ollama Cloud support for custom cloud models +- Conversational pairing, model listing, and model switching tools +- Filtering of built-in cloud starter models from the plugin's cloud model list + +## Conversational Tools + +- `ollama_pair` - pair local or cloud Ollama +- `ollama_model_list` - show supported Ollama models for the current mode +- `ollama_model_set` - switch the configured Ollama model or mode +- `ollama_unpair` - disable the plugin and restore built-in routing + +## Notes + +- Cloud mode reuses the existing `provider.ollama.apiKey` from the built-in setup by default +- You only need to pass `apiKey` if you want to replace that stored Ollama key +- Cloud model listing excludes the built-in starter models +- Use `tinyclaw_restart` after pairing or switching models + +## License + +GPLv3 diff --git a/plugins/provider/plugin-provider-ollama/package.json b/plugins/provider/plugin-provider-ollama/package.json new file mode 100644 index 0000000..4c2e64b --- /dev/null +++ b/plugins/provider/plugin-provider-ollama/package.json @@ -0,0 +1,39 @@ +{ + "name": "@tinyclaw/plugin-provider-ollama", + "version": "2.0.0", + "description": "Ollama provider plugin for Tiny Claw", + "license": "GPL-3.0", + "author": "Waren Gonzaga", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/warengonzaga/tinyclaw.git", + "directory": "plugins/provider/plugin-provider-ollama" + }, + "homepage": "https://github.com/warengonzaga/tinyclaw/tree/main/plugins/provider/plugin-provider-ollama#readme", + "bugs": { + "url": "https://github.com/warengonzaga/tinyclaw/issues" + }, + "keywords": [ + "tinyclaw", + "plugin", + "provider", + "ollama" + ], + "scripts": { + "build": "tsc -p tsconfig.json" + }, + "dependencies": { + "@tinyclaw/core": "workspace:*", + "@tinyclaw/logger": "workspace:*", + "@tinyclaw/types": "workspace:*" + } +} diff --git a/plugins/provider/plugin-provider-ollama/src/catalog.ts b/plugins/provider/plugin-provider-ollama/src/catalog.ts new file mode 100644 index 0000000..30ca32e --- /dev/null +++ b/plugins/provider/plugin-provider-ollama/src/catalog.ts @@ -0,0 +1,79 @@ +import { BUILTIN_MODEL_TAGS, DEFAULT_BASE_URL } from '@tinyclaw/core'; +import { buildProviderKeyName } from '@tinyclaw/types'; + +export const OLLAMA_PLUGIN_ID = '@tinyclaw/plugin-provider-ollama'; +export const OLLAMA_PROVIDER_ID = 'ollama'; +export const OLLAMA_SECRET_KEY = buildProviderKeyName(OLLAMA_PROVIDER_ID); +export const OLLAMA_MODEL_CONFIG_KEY = 'providers.ollama.model'; +export const OLLAMA_BASE_URL_CONFIG_KEY = 'providers.ollama.baseUrl'; +export const OLLAMA_MODE_CONFIG_KEY = 'providers.ollama.mode'; +export const OLLAMA_API_KEY_REF_CONFIG_KEY = 'providers.ollama.apiKeyRef'; +export const OLLAMA_LOCAL_BASE_URL = 'http://127.0.0.1:11434'; +export const OLLAMA_LOCAL_FALLBACK_MODEL = 'llama3.2:3b'; +export const OLLAMA_CLOUD_FALLBACK_MODEL = 'qwen3:32b'; + +export type OllamaProviderMode = 'local' | 'cloud'; + +interface OllamaTagItem { + name?: string; + model?: string; +} + +interface OllamaTagsResponse { + models?: OllamaTagItem[]; +} + +export function normalizeOllamaMode(mode: string | null | undefined): OllamaProviderMode { + return mode === 'cloud' ? 'cloud' : 'local'; +} + +export function getDefaultBaseUrl(mode: OllamaProviderMode): string { + return mode === 'cloud' ? DEFAULT_BASE_URL : OLLAMA_LOCAL_BASE_URL; +} + +export function getFallbackModel(mode: OllamaProviderMode): string { + return mode === 'cloud' ? OLLAMA_CLOUD_FALLBACK_MODEL : OLLAMA_LOCAL_FALLBACK_MODEL; +} + +export function shouldFilterBuiltinModel(mode: OllamaProviderMode, model: string): boolean { + return mode === 'cloud' && (BUILTIN_MODEL_TAGS as readonly string[]).includes(model); +} + +export function sortModels(models: string[]): string[] { + return [...new Set(models)].sort((left, right) => left.localeCompare(right)); +} + +export async function fetchOllamaModels(config: { + baseUrl: string; + mode: OllamaProviderMode; + apiKey?: string | null; +}): Promise { + const headers: Record = {}; + if (config.apiKey) { + headers.Authorization = `Bearer ${config.apiKey}`; + } + + const response = await fetch(`${config.baseUrl}/api/tags`, { headers }); + if (!response.ok) { + throw new Error(`Ollama model listing failed with HTTP ${response.status}`); + } + + const data = (await response.json()) as OllamaTagsResponse; + const models = (data.models ?? []) + .map((entry) => entry.name ?? entry.model ?? '') + .map((entry) => entry.trim()) + .filter(Boolean) + .filter((entry) => !shouldFilterBuiltinModel(config.mode, entry)); + + return sortModels(models); +} + +export function formatModelList(models: string[], currentModel?: string): string { + if (models.length === 0) { + return 'No supported models were found.'; + } + + return models + .map((model) => (model === currentModel ? `β€’ ${model} [current]` : `β€’ ${model}`)) + .join('\n'); +} diff --git a/plugins/provider/plugin-provider-ollama/src/index.ts b/plugins/provider/plugin-provider-ollama/src/index.ts new file mode 100644 index 0000000..eafb4d1 --- /dev/null +++ b/plugins/provider/plugin-provider-ollama/src/index.ts @@ -0,0 +1,31 @@ +import type { + ConfigManagerInterface, + ProviderPlugin, + SecretsManagerInterface, + Tool, +} from '@tinyclaw/types'; +import { createOllamaPairingTools } from './pairing.js'; +import { createOllamaPluginProvider } from './provider.js'; + +const ollamaPlugin: ProviderPlugin = { + id: '@tinyclaw/plugin-provider-ollama', + name: 'Ollama', + description: 'Local Ollama and custom Ollama Cloud models', + type: 'provider', + version: '2.0.0', + + async createProvider(secrets: SecretsManagerInterface, configManager: ConfigManagerInterface) { + return createOllamaPluginProvider({ + secrets, + mode: configManager.get<'local' | 'cloud'>('providers.ollama.mode') ?? 'local', + model: configManager.get('providers.ollama.model') ?? 'llama3.2:3b', + baseUrl: configManager.get('providers.ollama.baseUrl') ?? undefined, + }); + }, + + getPairingTools(secrets: SecretsManagerInterface, configManager: ConfigManagerInterface): Tool[] { + return createOllamaPairingTools(secrets, configManager); + }, +}; + +export default ollamaPlugin; diff --git a/plugins/provider/plugin-provider-ollama/src/pairing.ts b/plugins/provider/plugin-provider-ollama/src/pairing.ts new file mode 100644 index 0000000..a88b6a9 --- /dev/null +++ b/plugins/provider/plugin-provider-ollama/src/pairing.ts @@ -0,0 +1,332 @@ +import { DEFAULT_BASE_URL } from '@tinyclaw/core'; +import type { ConfigManagerInterface, SecretsManagerInterface, Tool } from '@tinyclaw/types'; +import { + fetchOllamaModels, + formatModelList, + getDefaultBaseUrl, + getFallbackModel, + normalizeOllamaMode, + OLLAMA_API_KEY_REF_CONFIG_KEY, + OLLAMA_BASE_URL_CONFIG_KEY, + OLLAMA_MODE_CONFIG_KEY, + OLLAMA_MODEL_CONFIG_KEY, + OLLAMA_PLUGIN_ID, + OLLAMA_PROVIDER_ID, + OLLAMA_SECRET_KEY, + type OllamaProviderMode, +} from './catalog.js'; + +function getSavedMode(configManager: ConfigManagerInterface): OllamaProviderMode { + return normalizeOllamaMode(configManager.get(OLLAMA_MODE_CONFIG_KEY)); +} + +function getSavedBaseUrl( + configManager: ConfigManagerInterface, + mode: OllamaProviderMode, + override?: string, +): string { + return ( + override?.trim() || + configManager.get(OLLAMA_BASE_URL_CONFIG_KEY) || + getDefaultBaseUrl(mode) + ); +} + +async function resolveRequestedModel(config: { + model: string | undefined; + mode: OllamaProviderMode; + baseUrl: string; + apiKey: string | null; +}): Promise { + const explicit = config.model?.trim(); + if (explicit) { + return explicit; + } + + try { + const models = await fetchOllamaModels({ + baseUrl: config.baseUrl, + mode: config.mode, + apiKey: config.apiKey, + }); + return models[0] ?? getFallbackModel(config.mode); + } catch { + return getFallbackModel(config.mode); + } +} + +function upsertPlugin(configManager: ConfigManagerInterface): void { + const current = configManager.get('plugins.enabled') ?? []; + if (!current.includes(OLLAMA_PLUGIN_ID)) { + configManager.set('plugins.enabled', [...current, OLLAMA_PLUGIN_ID]); + } +} + +function assignDefaultRouting(configManager: ConfigManagerInterface): void { + configManager.set('routing.tierMapping.complex', OLLAMA_PROVIDER_ID); + configManager.set('routing.tierMapping.reasoning', OLLAMA_PROVIDER_ID); +} + +export function createOllamaPairingTools( + secrets: SecretsManagerInterface, + configManager: ConfigManagerInterface, +): Tool[] { + return [ + { + name: 'ollama_pair', + description: + 'Pair Tiny Claw with the Ollama provider plugin. Supports local Ollama and custom Ollama Cloud models. ' + + 'Cloud pairing excludes the built-in Ollama Cloud starter models from selection. ' + + 'Reuses the existing built-in Ollama API key automatically for cloud mode unless you explicitly provide a replacement. ' + + 'Configures the model, enables the plugin, and routes complex/reasoning queries to Ollama.', + parameters: { + type: 'object', + properties: { + mode: { + type: 'string', + enum: ['local', 'cloud'], + description: 'Use local Ollama or Ollama Cloud. Default: local.', + }, + model: { + type: 'string', + description: + 'Model tag to use. If omitted, Tiny Claw will pick the first supported model it can discover.', + }, + baseUrl: { + type: 'string', + description: `Optional custom base URL. Defaults to local Ollama (${getDefaultBaseUrl('local')}) or Ollama Cloud (${DEFAULT_BASE_URL}).`, + }, + apiKey: { + type: 'string', + description: + 'Optional replacement Ollama API key for cloud mode. If omitted, Tiny Claw reuses the existing provider.ollama.apiKey from the built-in setup.', + }, + }, + required: [], + }, + async execute(args: Record): Promise { + const mode = normalizeOllamaMode(args.mode as string | undefined); + const baseUrl = getSavedBaseUrl(configManager, mode, args.baseUrl as string | undefined); + const providedApiKey = (args.apiKey as string | undefined)?.trim() || null; + const existingApiKey = mode === 'cloud' ? await secrets.retrieve(OLLAMA_SECRET_KEY) : null; + const effectiveApiKey = providedApiKey || existingApiKey; + + if (mode === 'cloud' && !effectiveApiKey) { + return ( + 'Error: cloud mode requires an API key. Provide apiKey or store one first in ' + + `${OLLAMA_SECRET_KEY}.` + ); + } + + const model = await resolveRequestedModel({ + model: args.model as string | undefined, + mode, + baseUrl, + apiKey: effectiveApiKey, + }); + + try { + if (providedApiKey) { + await secrets.store(OLLAMA_SECRET_KEY, providedApiKey); + } + + configManager.set({ + providers: { + ollama: { + mode, + model, + baseUrl, + apiKeyRef: mode === 'cloud' ? OLLAMA_SECRET_KEY : undefined, + }, + }, + }); + + upsertPlugin(configManager); + assignDefaultRouting(configManager); + + return ( + `Ollama provider paired successfully in ${mode} mode. ` + + `Model: ${model}. Base URL: ${baseUrl}. ` + + 'Use ollama_model_list to review supported models or ollama_model_set to switch later. ' + + 'Use the tinyclaw_restart tool now to apply the changes.' + ); + } catch (error) { + return `Error pairing Ollama: ${(error as Error).message}`; + } + }, + }, + { + name: 'ollama_model_list', + description: + 'List the models supported by the Ollama provider plugin. ' + + 'Defaults to the currently configured mode and base URL. In cloud mode, the built-in starter models are excluded and the stored built-in Ollama API key is reused by default.', + parameters: { + type: 'object', + properties: { + mode: { + type: 'string', + enum: ['local', 'cloud'], + description: 'Optional mode override. Defaults to the current Ollama provider mode.', + }, + baseUrl: { + type: 'string', + description: 'Optional base URL override.', + }, + apiKey: { + type: 'string', + description: + 'Optional temporary replacement API key for cloud mode. If omitted, Tiny Claw reuses provider.ollama.apiKey.', + }, + }, + required: [], + }, + async execute(args: Record): Promise { + const mode = normalizeOllamaMode( + (args.mode as string | undefined) ?? getSavedMode(configManager), + ); + const baseUrl = getSavedBaseUrl(configManager, mode, args.baseUrl as string | undefined); + const providedApiKey = (args.apiKey as string | undefined)?.trim() || null; + const storedApiKey = mode === 'cloud' ? await secrets.retrieve(OLLAMA_SECRET_KEY) : null; + + if (mode === 'cloud' && !providedApiKey && !storedApiKey) { + return `Error: cloud mode requires an API key in ${OLLAMA_SECRET_KEY} or via apiKey.`; + } + + try { + const models = await fetchOllamaModels({ + baseUrl, + mode, + apiKey: providedApiKey || storedApiKey, + }); + const currentModel = configManager.get(OLLAMA_MODEL_CONFIG_KEY) ?? undefined; + + return [ + `Ollama ${mode} models (${models.length})`, + `Base URL: ${baseUrl}`, + formatModelList(models, currentModel), + ].join('\n'); + } catch (error) { + return `Error listing Ollama models: ${(error as Error).message}`; + } + }, + }, + { + name: 'ollama_model_set', + description: + 'Update the Ollama provider plugin model, and optionally switch mode or base URL. ' + + 'Use this after pairing when the user wants a different local or cloud model. ' + + 'Cloud mode reuses the existing built-in Ollama API key unless you provide a replacement.', + parameters: { + type: 'object', + properties: { + model: { + type: 'string', + description: 'The Ollama model to set.', + }, + mode: { + type: 'string', + enum: ['local', 'cloud'], + description: 'Optional mode override. Defaults to the current mode.', + }, + baseUrl: { + type: 'string', + description: 'Optional base URL override.', + }, + apiKey: { + type: 'string', + description: + 'Optional replacement API key when switching to cloud mode. If omitted, Tiny Claw reuses provider.ollama.apiKey.', + }, + }, + required: ['model'], + }, + async execute(args: Record): Promise { + const model = (args.model as string | undefined)?.trim(); + if (!model) { + return 'Error: model is required.'; + } + + const mode = normalizeOllamaMode( + (args.mode as string | undefined) ?? getSavedMode(configManager), + ); + const baseUrl = getSavedBaseUrl(configManager, mode, args.baseUrl as string | undefined); + const providedApiKey = (args.apiKey as string | undefined)?.trim() || null; + + if (mode === 'cloud') { + const effectiveApiKey = providedApiKey || (await secrets.retrieve(OLLAMA_SECRET_KEY)); + if (!effectiveApiKey) { + return `Error: cloud mode requires an API key in ${OLLAMA_SECRET_KEY} or via apiKey.`; + } + + try { + const models = await fetchOllamaModels({ + baseUrl, + mode, + apiKey: effectiveApiKey, + }); + if (!models.includes(model)) { + return ( + `Model "${model}" is not available for Ollama ${mode}. ` + + 'Use ollama_model_list to see the supported models.' + ); + } + + if (providedApiKey) { + await secrets.store(OLLAMA_SECRET_KEY, providedApiKey); + } + } catch (error) { + return `Error validating Ollama model: ${(error as Error).message}`; + } + } + + configManager.set(OLLAMA_MODE_CONFIG_KEY, mode); + configManager.set(OLLAMA_BASE_URL_CONFIG_KEY, baseUrl); + configManager.set(OLLAMA_MODEL_CONFIG_KEY, model); + configManager.set( + OLLAMA_API_KEY_REF_CONFIG_KEY, + mode === 'cloud' ? OLLAMA_SECRET_KEY : undefined, + ); + + return ( + `Ollama provider updated to ${model} in ${mode} mode. ` + + 'Use the tinyclaw_restart tool now to apply the change.' + ); + }, + }, + { + name: 'ollama_unpair', + description: + 'Disable the Ollama provider plugin and restore any routing entries that point at it back to the built-in provider. ' + + 'Stored API keys are left intact for convenience.', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + async execute(): Promise { + try { + const current = configManager.get('plugins.enabled') ?? []; + configManager.set( + 'plugins.enabled', + current.filter((pluginId) => pluginId !== OLLAMA_PLUGIN_ID), + ); + + const tiers = ['simple', 'moderate', 'complex', 'reasoning'] as const; + for (const tier of tiers) { + const key = `routing.tierMapping.${tier}`; + if (configManager.get(key) === OLLAMA_PROVIDER_ID) { + configManager.set(key, 'ollama-cloud'); + } + } + + return ( + 'Ollama provider disabled. Any routing entries that pointed to it now fall back to the built-in provider. ' + + 'Use the tinyclaw_restart tool now to apply the changes.' + ); + } catch (error) { + return `Error unpairing Ollama: ${(error as Error).message}`; + } + }, + }, + ]; +} diff --git a/plugins/provider/plugin-provider-ollama/src/provider.ts b/plugins/provider/plugin-provider-ollama/src/provider.ts new file mode 100644 index 0000000..69fab66 --- /dev/null +++ b/plugins/provider/plugin-provider-ollama/src/provider.ts @@ -0,0 +1,220 @@ +import { logger } from '@tinyclaw/logger'; +import type { + LLMResponse, + Message, + Provider, + SecretsManagerInterface, + Tool, + ToolCall, +} from '@tinyclaw/types'; +import { + getDefaultBaseUrl, + normalizeOllamaMode, + OLLAMA_PROVIDER_ID, + type OllamaProviderMode, +} from './catalog.js'; + +export interface OllamaPluginProviderConfig { + secrets: SecretsManagerInterface; + model: string; + baseUrl?: string; + mode?: OllamaProviderMode; +} + +interface OllamaMessageResponse { + content?: string; + thinking?: string; + tool_calls?: { + function: { name: string; arguments: Record | string }; + }[]; +} + +interface OllamaChatResponse { + message?: OllamaMessageResponse; + choices?: Array<{ message?: OllamaMessageResponse }>; + response?: string; + content?: string; + text?: string; +} + +function toOllamaTools(tools: Tool[]): { + type: 'function'; + function: { name: string; description: string; parameters: Record }; +}[] { + return tools.map((tool) => ({ + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }, + })); +} + +function parseToolCalls( + raw: { function: { name: string; arguments: Record | string } }[], +): ToolCall[] { + return raw.map((toolCall) => ({ + id: crypto.randomUUID(), + name: toolCall.function.name, + arguments: + typeof toolCall.function.arguments === 'string' + ? (JSON.parse(toolCall.function.arguments) as Record) + : toolCall.function.arguments, + })); +} + +const TOOL_ACTION_KEYS = ['action', 'tool', 'name']; + +function extractToolCallFromText(text: string): ToolCall | null { + if (!text) return null; + + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start === -1 || end === -1 || end <= start) return null; + + try { + const parsed = JSON.parse(text.slice(start, end + 1)) as Record; + const actionKey = TOOL_ACTION_KEYS.find((key) => key in parsed); + const name = actionKey ? String(parsed[actionKey]) : ''; + if (!name) return null; + + const { action, tool, name: ignoredName, ...rest } = parsed; + void action; + void tool; + void ignoredName; + + return { + id: crypto.randomUUID(), + name, + arguments: rest, + }; + } catch { + return null; + } +} + +async function resolveApiKey( + secrets: SecretsManagerInterface, + mode: OllamaProviderMode, +): Promise { + if (mode === 'local') { + return null; + } + + return secrets.resolveProviderKey(OLLAMA_PROVIDER_ID); +} + +export function createOllamaPluginProvider(config: OllamaPluginProviderConfig): Provider { + const mode = normalizeOllamaMode(config.mode); + const baseUrl = config.baseUrl ?? getDefaultBaseUrl(mode); + const model = config.model; + const shortName = model.split(':')[0] || model; + + return { + id: OLLAMA_PROVIDER_ID, + name: mode === 'cloud' ? `Ollama Cloud (${shortName})` : `Ollama Local (${shortName})`, + + async chat(messages: Message[], tools?: Tool[]): Promise { + const apiKey = await resolveApiKey(config.secrets, mode); + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + try { + const body: Record = { + model, + messages, + stream: false, + }; + + if (tools?.length) { + body.tools = toOllamaTools(tools); + } + + const response = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => ''); + throw new Error( + `Ollama API error: ${response.status} ${response.statusText}` + + (errorBody ? ` β€” ${errorBody}` : ''), + ); + } + + const data = (await response.json()) as OllamaChatResponse; + const message = data.message ?? data.choices?.[0]?.message; + + if (message?.tool_calls?.length) { + return { + type: 'tool_calls', + content: message.content ?? undefined, + toolCalls: parseToolCalls(message.tool_calls), + }; + } + + const content = message?.content ?? data.response ?? data.content ?? data.text ?? ''; + if (content) { + return { type: 'text', content }; + } + + const toolCall = extractToolCallFromText(message?.thinking ?? ''); + if (toolCall) { + return { + type: 'tool_calls', + toolCalls: [toolCall], + }; + } + + return { type: 'text', content: '' }; + } catch (error) { + logger.error('Ollama provider plugin error:', error); + throw error; + } + }, + + async isAvailable(): Promise { + const apiKey = await resolveApiKey(config.secrets, mode); + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + try { + const response = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers, + body: JSON.stringify({ + model, + messages: [{ role: 'user', content: 'ping' }], + stream: false, + }), + }); + + if (response.status === 401 || response.status === 403) { + const body = await response.text().catch(() => ''); + throw new Error( + `Authentication failed (${response.status}): ${body || response.statusText}`, + ); + } + + return response.ok; + } catch (error) { + if (error instanceof Error && error.message.startsWith('Authentication failed')) { + throw error; + } + + return false; + } + }, + }; +} diff --git a/plugins/provider/plugin-provider-ollama/tests/pairing.test.ts b/plugins/provider/plugin-provider-ollama/tests/pairing.test.ts new file mode 100644 index 0000000..1dee400 --- /dev/null +++ b/plugins/provider/plugin-provider-ollama/tests/pairing.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; +import { + OLLAMA_LOCAL_BASE_URL, + OLLAMA_MODE_CONFIG_KEY, + OLLAMA_MODEL_CONFIG_KEY, + OLLAMA_PLUGIN_ID, + OLLAMA_SECRET_KEY, +} from '../src/catalog.js'; +import { createOllamaPairingTools } from '../src/pairing.js'; + +function createSecrets() { + const stored = new Map(); + + return { + stored, + async store(key: string, value: string) { + stored.set(key, value); + }, + async check(key: string) { + return stored.has(key); + }, + async retrieve(key: string) { + return stored.get(key) ?? null; + }, + async list() { + return [...stored.keys()]; + }, + async resolveProviderKey(providerName: string) { + return stored.get(`provider.${providerName}.apiKey`) ?? null; + }, + async close() {}, + }; +} + +function createConfig() { + const data: Record = {}; + + return { + data, + get(key: string) { + return data[key] as T | undefined; + }, + has(key: string) { + return key in data; + }, + set(keyOrObject: string | Record, value?: unknown) { + if (typeof keyOrObject === 'string') { + data[keyOrObject] = value; + return; + } + + const providers = keyOrObject.providers as + | Record> + | undefined; + if (providers?.ollama) { + for (const [entryKey, entryValue] of Object.entries(providers.ollama)) { + data[`providers.ollama.${entryKey}`] = entryValue; + } + } + }, + delete(key: string) { + delete data[key]; + }, + reset() {}, + clear() {}, + store: {}, + size: 0, + path: ':memory:', + onDidChange: () => () => {}, + onDidAnyChange: () => () => {}, + close: () => {}, + }; +} + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe('createOllamaPairingTools', () => { + test('pairs local Ollama with discovered model and enables plugin', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ models: [{ name: 'llama3.2:3b' }, { name: 'mistral:7b' }] }), + { status: 200 }, + ), + ), + ) as typeof fetch; + + const secrets = createSecrets(); + const config = createConfig(); + const pair = createOllamaPairingTools(secrets as never, config as never)[0]; + + const result = await pair.execute({ mode: 'local' }); + + expect(result).toContain('paired successfully'); + expect(config.data[OLLAMA_MODE_CONFIG_KEY]).toBe('local'); + expect(config.data[OLLAMA_MODEL_CONFIG_KEY]).toBe('llama3.2:3b'); + expect(config.data['providers.ollama.baseUrl']).toBe(OLLAMA_LOCAL_BASE_URL); + expect(config.data['plugins.enabled']).toEqual([OLLAMA_PLUGIN_ID]); + }); + + test('pairs cloud Ollama and filters built-in cloud models from listing', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + models: [ + { name: 'kimi-k2.5:cloud' }, + { name: 'gpt-oss:120b-cloud' }, + { name: 'qwen3:32b' }, + ], + }), + { status: 200 }, + ), + ), + ) as typeof fetch; + + const secrets = createSecrets(); + const config = createConfig(); + const [pair, list] = createOllamaPairingTools(secrets as never, config as never); + + const pairResult = await pair.execute({ mode: 'cloud', apiKey: 'ollama-key' }); + const listResult = await list.execute({ mode: 'cloud' }); + + expect(pairResult).toContain('qwen3:32b'); + expect(secrets.stored.get(OLLAMA_SECRET_KEY)).toBe('ollama-key'); + expect(listResult).toContain('qwen3:32b'); + expect(listResult).not.toContain('kimi-k2.5:cloud'); + expect(listResult).not.toContain('gpt-oss:120b-cloud'); + }); + + test('pairs cloud Ollama without re-entering the API key when one is already stored', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + models: [{ name: 'qwen3:32b' }], + }), + { status: 200 }, + ), + ), + ) as typeof fetch; + + const secrets = createSecrets(); + await secrets.store(OLLAMA_SECRET_KEY, 'existing-ollama-key'); + const config = createConfig(); + const pair = createOllamaPairingTools(secrets as never, config as never)[0]; + + const result = await pair.execute({ mode: 'cloud' }); + + expect(result).toContain('qwen3:32b'); + expect(secrets.stored.get(OLLAMA_SECRET_KEY)).toBe('existing-ollama-key'); + expect(config.data[OLLAMA_MODE_CONFIG_KEY]).toBe('cloud'); + }); + + test('updates the configured model after validation', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ models: [{ name: 'qwen3:32b' }, { name: 'gemma3:27b' }] }), { + status: 200, + }), + ), + ) as typeof fetch; + + const secrets = createSecrets(); + const config = createConfig(); + const tools = createOllamaPairingTools(secrets as never, config as never); + await tools[0].execute({ mode: 'cloud', apiKey: 'ollama-key', model: 'qwen3:32b' }); + + const result = await tools[2].execute({ mode: 'cloud', model: 'gemma3:27b' }); + + expect(result).toContain('gemma3:27b'); + expect(config.data[OLLAMA_MODEL_CONFIG_KEY]).toBe('gemma3:27b'); + }); + + test('removes plugin and resets tier mapping on unpair', async () => { + const secrets = createSecrets(); + const config = createConfig(); + config.data['plugins.enabled'] = [OLLAMA_PLUGIN_ID, '@tinyclaw/plugin-provider-openai']; + config.data['routing.tierMapping.complex'] = 'ollama'; + config.data['routing.tierMapping.reasoning'] = 'ollama'; + + const result = await createOllamaPairingTools(secrets as never, config as never)[3].execute({}); + + expect(result).toContain('Ollama provider disabled'); + expect(config.data['plugins.enabled']).toEqual(['@tinyclaw/plugin-provider-openai']); + expect(config.data['routing.tierMapping.complex']).toBe('ollama-cloud'); + expect(config.data['routing.tierMapping.reasoning']).toBe('ollama-cloud'); + }); +}); diff --git a/plugins/provider/plugin-provider-ollama/tests/provider.test.ts b/plugins/provider/plugin-provider-ollama/tests/provider.test.ts new file mode 100644 index 0000000..488ad71 --- /dev/null +++ b/plugins/provider/plugin-provider-ollama/tests/provider.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; +import { createOllamaPluginProvider } from '../src/provider.js'; + +function createSecrets(apiKey: string | null = null) { + return { + async store() {}, + async check() { + return apiKey !== null; + }, + async retrieve() { + return apiKey; + }, + async list() { + return []; + }, + async resolveProviderKey() { + return apiKey; + }, + async close() {}, + }; +} + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe('createOllamaPluginProvider', () => { + test('uses local mode without auth header', async () => { + const fetchMock = mock((_input: RequestInfo | URL, init?: RequestInit) => { + expect(init?.headers).toEqual({ 'Content-Type': 'application/json' }); + return Promise.resolve( + new Response(JSON.stringify({ message: { content: 'hello local' } }), { status: 200 }), + ); + }); + globalThis.fetch = fetchMock as typeof fetch; + + const provider = createOllamaPluginProvider({ + secrets: createSecrets() as never, + mode: 'local', + model: 'llama3.2:3b', + }); + + const response = await provider.chat([{ role: 'user', content: 'hi' }]); + + expect(response).toEqual({ type: 'text', content: 'hello local' }); + }); + + test('uses cloud mode with auth header', async () => { + const fetchMock = mock((_input: RequestInfo | URL, init?: RequestInit) => { + expect(init?.headers).toEqual({ + Authorization: 'Bearer ollama-key', + 'Content-Type': 'application/json', + }); + return Promise.resolve( + new Response(JSON.stringify({ message: { content: 'hello cloud' } }), { status: 200 }), + ); + }); + globalThis.fetch = fetchMock as typeof fetch; + + const provider = createOllamaPluginProvider({ + secrets: createSecrets('ollama-key') as never, + mode: 'cloud', + model: 'qwen3:32b', + baseUrl: 'https://ollama.com', + }); + + const response = await provider.chat([{ role: 'user', content: 'hi' }]); + + expect(response).toEqual({ type: 'text', content: 'hello cloud' }); + }); +}); diff --git a/plugins/provider/plugin-provider-ollama/tsconfig.json b/plugins/provider/plugin-provider-ollama/tsconfig.json new file mode 100644 index 0000000..c8c92cb --- /dev/null +++ b/plugins/provider/plugin-provider-ollama/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/plugins/provider/plugin-provider-openai/src/index.ts b/plugins/provider/plugin-provider-openai/src/index.ts index 649262f..d803c2f 100644 --- a/plugins/provider/plugin-provider-openai/src/index.ts +++ b/plugins/provider/plugin-provider-openai/src/index.ts @@ -28,8 +28,12 @@ const openaiPlugin: ProviderPlugin = { type: 'provider', version: '0.1.0', - async createProvider(secrets: SecretsManagerInterface) { - return createOpenAIProvider({ secrets }); + async createProvider(secrets: SecretsManagerInterface, configManager: ConfigManagerInterface) { + return createOpenAIProvider({ + secrets, + model: configManager.get('providers.openai.model') ?? undefined, + baseUrl: configManager.get('providers.openai.baseUrl') ?? undefined, + }); }, getPairingTools(secrets: SecretsManagerInterface, configManager: ConfigManagerInterface): Tool[] { diff --git a/src/cli/src/commands/plugin.ts b/src/cli/src/commands/plugin.ts new file mode 100644 index 0000000..0a59706 --- /dev/null +++ b/src/cli/src/commands/plugin.ts @@ -0,0 +1,309 @@ +/** + * Plugin Command + * + * CLI interface for managing Tiny Claw community plugins. + * + * Usage: + * tinyclaw plugin add Install a community plugin from npm + * tinyclaw plugin add --dry-run Validate without installing + * tinyclaw plugin remove Remove a community plugin + * tinyclaw plugin update [package] Update one or all community plugins + * tinyclaw plugin list List all plugins (official + community) + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { ConfigManager } from '@tinyclaw/config'; +import { + getCommunityPlugins, + installCommunityPlugin, + listCommunityPlugins, + removeCommunityPlugin, + validatePackageName, +} from '@tinyclaw/plugins'; +import { theme } from '../ui/theme.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function printUsage(): void { + console.log(); + console.log(` ${theme.label('Usage')}`); + console.log( + ` ${theme.cmd('tinyclaw plugin add')} Install a community plugin from npm`, + ); + console.log( + ` ${theme.cmd('tinyclaw plugin add --dry-run')} Validate without installing`, + ); + console.log( + ` ${theme.cmd('tinyclaw plugin remove')} Remove a community plugin`, + ); + console.log( + ` ${theme.cmd('tinyclaw plugin update')} [package] Update one or all community plugins`, + ); + console.log( + ` ${theme.cmd('tinyclaw plugin list')} List all plugins (official + community)`, + ); + console.log(); + console.log(` ${theme.label('Examples')}`); + console.log(` ${theme.dim('tinyclaw plugin add @acme/tinyclaw-plugin-telegram')}`); + console.log(` ${theme.dim('tinyclaw plugin add --dry-run tinyclaw-plugin-notion')}`); + console.log(` ${theme.dim('tinyclaw plugin remove @acme/tinyclaw-plugin-telegram')}`); + console.log(` ${theme.dim('tinyclaw plugin update')}`); + console.log(` ${theme.dim('tinyclaw plugin update tinyclaw-plugin-notion')}`); + console.log(` ${theme.dim('tinyclaw plugin list')}`); + console.log(); + console.log(` ${theme.label('Notes')}`); + console.log( + ` Community plugins are third-party packages and are ${theme.warn('unverified')}.`, + ); + console.log(` Official plugins (@tinyclaw/plugin-*) are managed via the monorepo.`); + console.log(); +} + +// --------------------------------------------------------------------------- +// Subcommands +// --------------------------------------------------------------------------- + +async function addPlugin( + packageName: string, + configManager: ConfigManager, + dryRun: boolean, +): Promise { + console.log(); + + if (dryRun) { + console.log(` ${theme.brand('Dry-run validation...')} ${theme.dim(packageName)}`); + console.log(); + + const validation = validatePackageName(packageName); + if (!validation) { + console.log(` ${theme.error('βœ–')} Invalid or disallowed package name: ${packageName}`); + console.log(); + return; + } + + // Fetch the package from npm registry to confirm it exists + const safeName = encodeURIComponent(validation.name); + try { + const res = await fetch(`https://registry.npmjs.org/${safeName}`, { + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) { + console.log(` ${theme.error('\u2716')} Package not found on npm: ${validation.name}`); + console.log(); + return; + } + const data = (await res.json()) as { + 'dist-tags'?: { latest?: string }; + description?: string; + }; + const latest = data['dist-tags']?.latest ?? 'unknown'; + console.log(` ${theme.success('βœ”')} Package is valid and exists on npm`); + console.log(); + console.log(` Name : ${theme.dim(validation.name)}`); + console.log(` Latest : ${theme.dim(latest)}`); + if (data.description) { + console.log(` Description : ${theme.dim(data.description)}`); + } + console.log(); + console.log(` Run without ${theme.cmd('--dry-run')} to install.`); + } catch { + console.log(` ${theme.warn('⚠')} Package name is valid but npm registry check failed.`); + } + console.log(); + return; + } + + console.log(` ${theme.brand('Installing plugin...')} ${theme.dim(packageName)}`); + console.log(); + + const result = await installCommunityPlugin(packageName, configManager); + + if (result.success && result.plugin) { + console.log(` ${theme.success('βœ”')} ${result.plugin.name} installed successfully`); + console.log(); + console.log(` ID : ${theme.dim(result.plugin.id)}`); + console.log(` Type : ${theme.dim(result.plugin.type)}`); + console.log(` Version : ${theme.dim(result.plugin.version)}`); + console.log(` Source : ${theme.warn('community (unverified)')}`); + console.log(); + console.log(` Run ${theme.cmd('tinyclaw start')} to activate the plugin.`); + } else { + console.log(` ${theme.error('βœ–')} ${result.message}`); + } + console.log(); +} + +async function removePlugin(packageName: string, configManager: ConfigManager): Promise { + console.log(); + console.log(` ${theme.brand('Removing plugin...')} ${theme.dim(packageName)}`); + console.log(); + + const result = await removeCommunityPlugin(packageName, configManager); + + if (result.success) { + console.log(` ${theme.success('βœ”')} ${result.message}`); + } else { + console.log(` ${theme.error('βœ–')} ${result.message}`); + } + console.log(); +} + +async function updatePlugins( + packageName: string | undefined, + configManager: ConfigManager, +): Promise { + const community = getCommunityPlugins(configManager); + + if (community.length === 0) { + console.log(); + console.log(` ${theme.dim('No community plugins installed. Nothing to update.')}`); + console.log(); + return; + } + + const targets = packageName ? [packageName] : community; + + if (packageName && !community.includes(packageName)) { + console.log(); + console.log( + ` ${theme.error('βœ–')} Plugin "${packageName}" is not a registered community plugin.`, + ); + console.log(); + return; + } + + console.log(); + console.log(` ${theme.brand('Updating community plugins...')}`); + console.log(); + + for (const id of targets) { + console.log(` ${theme.dim('↻')} ${id}`); + const proc = Bun.spawnSync(['bun', 'update', id], { + stdout: 'pipe', + stderr: 'pipe', + timeout: 60_000, + }); + if (proc.exitCode === 0) { + console.log(` ${theme.success('βœ”')} updated`); + } else { + const stderr = proc.stderr.toString().trim(); + console.log(` ${theme.error('βœ–')} failed${stderr ? `: ${stderr}` : ''}`); + } + } + + console.log(); + console.log(` Run ${theme.cmd('tinyclaw start')} to pick up new versions.`); + console.log(); +} + +async function listPlugins(configManager: ConfigManager): Promise { + const enabledIds = configManager.get('plugins.enabled') ?? []; + + console.log(); + console.log(` ${theme.label('Installed Plugins')}`); + console.log(); + + // Official plugins + const officialEnabled = enabledIds.filter((id) => id.startsWith('@tinyclaw/plugin-')); + console.log(` ${theme.label('Official')} ${theme.dim('(verified @tinyclaw/plugin-*)')}`); + + if (officialEnabled.length === 0) { + console.log(` ${theme.dim('No official plugins enabled')}`); + } else { + for (const id of officialEnabled) { + try { + const mod = await import(id); + const p = mod.default; + console.log( + ` ${theme.success('●')} ${p?.name ?? id} ${theme.dim(`v${p?.version ?? '?'}`)} ${theme.dim(`(${p?.type ?? '?'})`)}`, + ); + } catch { + console.log(` ${theme.warn('●')} ${id} ${theme.dim('(failed to load)')}`); + } + } + } + + console.log(); + + // Community plugins + const communityPlugins = await listCommunityPlugins(configManager); + console.log(` ${theme.label('Community')} ${theme.warn('(unverified)')}`); + + if (communityPlugins.length === 0) { + console.log(` ${theme.dim('No community plugins installed')}`); + console.log(); + console.log(` Install one with: ${theme.cmd('tinyclaw plugin add ')}`); + } else { + for (const cp of communityPlugins) { + const status = cp.enabled ? theme.success('enabled') : theme.dim('installed'); + console.log( + ` ${cp.enabled ? theme.success('●') : theme.dim('β—‹')} ${cp.name} ${theme.dim(`v${cp.version}`)} ${theme.dim(`(${cp.type})`)} [${status}]`, + ); + } + } + + console.log(); +} + +// --------------------------------------------------------------------------- +// Main export +// --------------------------------------------------------------------------- + +export async function pluginCommand(args: string[]): Promise { + const subcommand = args[0]; + + const dataDir = process.env.TINYCLAW_DATA_DIR || join(homedir(), '.tinyclaw'); + const configManager = await ConfigManager.create(dataDir); + + try { + switch (subcommand) { + case 'add': { + const dryRun = args.includes('--dry-run'); + const remaining = args.slice(1).filter((a) => a !== '--dry-run'); + const packageName = remaining[0]; + if (!packageName) { + console.log(theme.error(' βœ– Missing package name.')); + printUsage(); + process.exit(1); + } + await addPlugin(packageName, configManager, dryRun); + break; + } + + case 'remove': { + const packageName = args[1]; + if (!packageName) { + console.log(theme.error(' βœ– Missing package name.')); + printUsage(); + process.exit(1); + } + await removePlugin(packageName, configManager); + break; + } + + case 'list': { + await listPlugins(configManager); + break; + } + + case 'update': { + const target = args[1]; // optional β€” update all if omitted + await updatePlugins(target, configManager); + break; + } + + default: { + if (subcommand) { + console.log(theme.error(` βœ– Unknown subcommand: ${subcommand}`)); + } + printUsage(); + break; + } + } + } finally { + configManager.close(); + } +} diff --git a/src/cli/src/commands/purge.ts b/src/cli/src/commands/purge.ts index d05356c..574c579 100644 --- a/src/cli/src/commands/purge.ts +++ b/src/cli/src/commands/purge.ts @@ -20,8 +20,10 @@ import { join } from 'node:path'; import * as p from '@clack/prompts'; import { generateSoul, parseSeed } from '@tinyclaw/heartware'; import { setLogMode } from '@tinyclaw/logger'; +import { SecretsManager } from '@tinyclaw/secrets'; import { showBanner } from '../ui/banner.js'; import { theme } from '../ui/theme.js'; +import { isSecretsIntegrityError } from '../utils/secrets.js'; // --------------------------------------------------------------------------- // Path resolution @@ -47,6 +49,16 @@ async function dirExists(path: string): Promise { } } +async function isSecretsEngineStore(path: string): Promise { + const [hasDb, hasMeta, hasKeyfile] = await Promise.all([ + dirExists(join(path, 'store.db')), + dirExists(join(path, 'meta.json')), + dirExists(join(path, '.keyfile')), + ]); + + return hasDb && hasMeta && hasKeyfile; +} + /** * Try to read the soul name from the SEED.txt file. * Returns the suggested name if found, null otherwise. @@ -73,6 +85,8 @@ interface PurgeFlags { yes: boolean; } +type FsErrorWithCode = Error & { code?: string }; + function parseFlags(args: string[]): PurgeFlags { return { force: args.includes('--force'), @@ -81,6 +95,35 @@ function parseFlags(args: string[]): PurgeFlags { }; } +async function removeDirectoryWithRetry( + path: string, + options: { recursive: true; force: true; maxRetries: number; retryDelay: number }, +): Promise { + let lastError: unknown; + const totalAttempts = Math.max(options.maxRetries, 0) + 1; + + for (let attempt = 0; attempt < totalAttempts; attempt++) { + try { + // Pass only recursive/force to rm() β€” it does not retry internally when + // maxRetries/retryDelay are omitted, so retries happen exactly once here. + await rm(path, { recursive: options.recursive, force: options.force }); + return; + } catch (err) { + lastError = err; + const code = (err as FsErrorWithCode).code; + const shouldRetry = platform() === 'win32' && (code === 'EBUSY' || code === 'EPERM'); + + if (!shouldRetry || attempt === totalAttempts - 1) { + throw err; + } + + await Bun.sleep(options.retryDelay * (attempt + 1)); + } + } + + throw lastError; +} + // --------------------------------------------------------------------------- // Command // --------------------------------------------------------------------------- @@ -192,7 +235,7 @@ export async function purgeCommand(args: string[] = []): Promise { // Delete data directory if (dataExists) { try { - await rm(dataDir, rmOptions); + await removeDirectoryWithRetry(dataDir, rmOptions); // Verify deletion actually succeeded (locked files can cause silent partial removal) if (await dirExists(dataDir)) { errors.push( @@ -209,7 +252,24 @@ export async function purgeCommand(args: string[] = []): Promise { // Delete secrets (only with --force) if (flags.force && secretsExist) { try { - await rm(secretsDir, rmOptions); + const engineManagedStore = await isSecretsEngineStore(secretsDir); + + if (engineManagedStore) { + try { + const secretsManager = await SecretsManager.create({ path: secretsDir }); + await secretsManager.destroy(); + } catch (err) { + // If the store is already corrupt or half-initialized, fall back to + // raw directory removal so purge can still recover the installation. + if (!isSecretsIntegrityError(err)) { + // No-op: purge is explicitly destructive, so raw deletion remains valid. + } + await removeDirectoryWithRetry(secretsDir, rmOptions); + } + } else { + await removeDirectoryWithRetry(secretsDir, rmOptions); + } + if (await dirExists(secretsDir)) { errors.push( 'Secrets store: some files could not be removed (they may be locked by a running process)', diff --git a/src/cli/src/commands/setup-web.ts b/src/cli/src/commands/setup-web.ts index 701f1f5..9982702 100644 --- a/src/cli/src/commands/setup-web.ts +++ b/src/cli/src/commands/setup-web.ts @@ -16,6 +16,7 @@ import type { StreamCallback } from '@tinyclaw/types'; import { createWebUI } from '@tinyclaw/web'; import { RESTART_EXIT_CODE } from '../supervisor.js'; import { theme } from '../ui/theme.js'; +import { isSecretsIntegrityError, printSecretsIntegrityRecovery } from '../utils/secrets.js'; /** * Run the web-based setup flow. @@ -35,7 +36,20 @@ export async function webSetupCommand(): Promise { // --- Initialize engines ----------------------------------------------- - const secretsManager = await SecretsManager.create(); + let secretsManager: SecretsManager; + + try { + secretsManager = await SecretsManager.create(); + } catch (err: unknown) { + if (isSecretsIntegrityError(err)) { + printSecretsIntegrityRecovery('tinyclaw setup --web'); + process.exit(1); + return; + } + + throw err; + } + logger.info( 'Secrets engine initialized', { diff --git a/src/cli/src/commands/setup.ts b/src/cli/src/commands/setup.ts index de57bbe..d90083e 100644 --- a/src/cli/src/commands/setup.ts +++ b/src/cli/src/commands/setup.ts @@ -53,6 +53,7 @@ import { createWebUI } from '@tinyclaw/web'; import QRCode from 'qrcode'; import { showBanner } from '../ui/banner.js'; import { theme } from '../ui/theme.js'; +import { isSecretsIntegrityError, printSecretsIntegrityRecovery } from '../utils/secrets.js'; /** * Copy text to the system clipboard. @@ -205,7 +206,20 @@ export async function setupCommand(): Promise { showBanner(); - const secretsManager = await SecretsManager.create(); + let secretsManager: SecretsManager; + + try { + secretsManager = await SecretsManager.create(); + } catch (err: unknown) { + if (isSecretsIntegrityError(err)) { + printSecretsIntegrityRecovery('tinyclaw setup'); + process.exit(1); + return; + } + + throw err; + } + const configManager = await ConfigManager.create(); p.intro(theme.brand("Let's set up Tiny Claw")); diff --git a/src/cli/src/commands/start.ts b/src/cli/src/commands/start.ts index df8315f..df0f61a 100644 --- a/src/cli/src/commands/start.ts +++ b/src/cli/src/commands/start.ts @@ -49,7 +49,16 @@ import { getCompanionTouchActivity, wireNudgeToIntercom, } from '@tinyclaw/nudge'; -import { loadPlugins } from '@tinyclaw/plugins'; +import { + buildPluginUpdateContext, + checkPluginUpdates, + discoverPairingTools, + getCommunityPlugins, + installCommunityPlugin, + listCommunityPlugins, + loadPlugins, + removeCommunityPlugin, +} from '@tinyclaw/plugins'; import { createPulseScheduler } from '@tinyclaw/pulse'; import { createSessionQueue } from '@tinyclaw/queue'; import { ProviderOrchestrator, type ProviderTierConfig } from '@tinyclaw/router'; @@ -61,6 +70,7 @@ import type { Provider, StreamCallback, Tool } from '@tinyclaw/types'; import { createWebUI } from '@tinyclaw/web'; import { RESTART_EXIT_CODE } from '../supervisor.js'; import { theme } from '../ui/theme.js'; +import { isSecretsIntegrityError, printSecretsIntegrityRecovery } from '../utils/secrets.js'; /** * Run the agent start flow @@ -104,28 +114,10 @@ export async function startCommand(): Promise { try { secretsManager = await SecretsManager.create(); } catch (err: unknown) { - // Detect IntegrityError from @wgtechlabs/secrets-engine - // The HMAC stored in meta.json does not match the database contents. - // This may indicate file corruption, tampering, or a partial write. - if ( - err instanceof Error && - 'code' in err && - (err as { code: string }).code === 'INTEGRITY_ERROR' - ) { - const storePath = join(homedir(), '.secrets-engine'); - - console.log(); - console.log(theme.error(' βœ– Secrets store integrity check failed.')); - console.log(); - console.log(' The secrets store may have been corrupted or tampered with.'); - console.log(' This can happen due to disk errors, power loss, or external changes.'); - console.log(); - console.log(' To resolve, delete the store and re-run setup:'); - console.log(); - console.log(` 1. ${theme.cmd(`rm -rf ${storePath}`)}`); - console.log(` 2. ${theme.cmd('tinyclaw setup')}`); - console.log(); + if (isSecretsIntegrityError(err)) { + printSecretsIntegrityRecovery('tinyclaw setup'); process.exit(1); + return; } throw err; @@ -319,7 +311,7 @@ export async function startCommand(): Promise { for (const pp of plugins.providers) { try { - const provider = await pp.createProvider(secretsManager); + const provider = await pp.createProvider(secretsManager, configManager); pluginProviders.push(provider); logger.info(`Plugin provider initialized: ${pp.name} (${provider.id})`, undefined, { emoji: 'βœ…', @@ -339,20 +331,12 @@ export async function startCommand(): Promise { let activeProviderName = defaultProvider.name; let activeModelName = providerModel; - const primaryModel = configManager.get('providers.primary.model'); + const primaryProviderId = + configManager.get('providers.primary.providerId') ?? + configManager.get('providers.primary.model'); - if (primaryModel) { - // Find a plugin provider whose id matches the primary config. - // Convention: the provider ID from the plugin is used to look up matching. - - // Try to find a matching plugin provider by checking if any plugin - // provider's id is referenced in the tier mapping or matches a known pattern. - // For now, we look for a plugin provider whose model matches. - const matchingProvider = pluginProviders.find((pp) => { - // Check if this provider was configured with the primary model - // Plugin providers set their own id, so we check availability instead - return pp.id !== defaultProvider.id; - }); + if (primaryProviderId) { + const matchingProvider = pluginProviders.find((pp) => pp.id === primaryProviderId); if (matchingProvider) { try { @@ -360,12 +344,14 @@ export async function startCommand(): Promise { if (available) { routerDefaultProvider = matchingProvider; activeProviderName = matchingProvider.name; - activeModelName = primaryModel; + activeModelName = + configManager.get(`providers.${matchingProvider.id}.model`) ?? + matchingProvider.name; logger.info( 'Primary provider active, overriding built-in as default', { primary: matchingProvider.id, - model: primaryModel, + model: activeModelName, }, { emoji: 'βœ…' }, ); @@ -417,12 +403,33 @@ export async function startCommand(): Promise { ...createConfigTools(configManager), ]; - // Merge plugin pairing tools (channels + providers) - const pairingTools = [ + // Merge plugin pairing tools (channels + providers) from enabled plugins + const enabledPairingTools = [ ...plugins.channels.flatMap((ch) => ch.getPairingTools?.(secretsManager, configManager) ?? []), ...plugins.providers.flatMap((pp) => pp.getPairingTools?.(secretsManager, configManager) ?? []), ]; + // Discover pairing tools from installed-but-not-yet-enabled plugins. + // This solves the chicken-and-egg problem: the agent needs pairing tools + // (e.g. discord_pair) to activate plugins conversationally, but those tools + // were previously only loaded from already-enabled plugins. + const enabledIds = configManager.get('plugins.enabled') ?? []; + const discoveredPairingTools = await discoverPairingTools( + enabledIds, + secretsManager, + configManager, + ); + + if (discoveredPairingTools.length > 0) { + logger.info( + 'Discovered pairing tools from available plugins', + { count: discoveredPairingTools.length, tools: discoveredPairingTools.map((t) => t.name) }, + { emoji: 'πŸ”Œ' }, + ); + } + + const pairingTools = [...enabledPairingTools, ...discoveredPairingTools]; + // Create a temporary context for plugin tools that need AgentContext const baseContext = { db, @@ -649,7 +656,9 @@ export async function startCommand(): Promise { required: [], }, async execute() { - const currentPrimary = configManager.get('providers.primary.model'); + const currentPrimary = + configManager.get('providers.primary.providerId') ?? + configManager.get('providers.primary.model'); const lines: string[] = []; // Built-in provider @@ -673,6 +682,8 @@ export async function startCommand(): Promise { for (const pp of pluginProviders) { const isPrimary = routerDefaultProvider.id === pp.id; let status = 'available'; + const configuredModel = configManager.get(`providers.${pp.id}.model`); + const configuredMode = configManager.get(`providers.${pp.id}.mode`); try { const avail = await pp.isAvailable(); status = avail ? 'available' : 'unavailable'; @@ -682,6 +693,12 @@ export async function startCommand(): Promise { const primaryTag = isPrimary ? ' [PRIMARY]' : ''; lines.push(` β€’ ${pp.name} (${pp.id})${primaryTag}`); + if (configuredModel) { + lines.push(` Model: ${configuredModel}`); + } + if (configuredMode) { + lines.push(` Mode: ${configuredMode}`); + } lines.push(` Status: ${status}`); } @@ -763,9 +780,10 @@ export async function startCommand(): Promise { // Persist primary config configManager.set('providers.primary', { - model: target.id, - baseUrl: undefined, - apiKeyRef: undefined, + providerId: target.id, + model: configManager.get(`providers.${target.id}.model`) ?? target.id, + baseUrl: configManager.get(`providers.${target.id}.baseUrl`) ?? undefined, + apiKeyRef: configManager.get(`providers.${target.id}.apiKeyRef`) ?? undefined, }); logger.info(`Primary provider set: ${target.name} (${target.id})`, undefined, { @@ -802,7 +820,9 @@ export async function startCommand(): Promise { required: [], }, async execute() { - const currentPrimary = configManager.get('providers.primary.model'); + const currentPrimary = + configManager.get('providers.primary.providerId') ?? + configManager.get('providers.primary.model'); if (!currentPrimary) { return 'No primary provider is currently set. The built-in is already the default.'; @@ -825,6 +845,235 @@ export async function startCommand(): Promise { allTools.push(providerClearPrimaryTool); + // plugin_install tool β€” allows the agent to install community plugins conversationally + const pluginInstallTool: Tool = { + name: 'plugin_install', + description: + 'Install a community plugin from npm by package name. The user can paste ' + + 'an npm package name (e.g. "@acme/tinyclaw-plugin-telegram" or ' + + '"tinyclaw-plugin-notion") and this tool will install it, validate it is ' + + 'a valid Tiny Claw plugin, and register it. After successful installation, ' + + 'call tinyclaw_restart to activate the plugin. ' + + 'IMPORTANT: Always confirm with the user before installing. Warn them that ' + + 'community plugins are unverified third-party code that will execute on ' + + 'their machine. ' + + 'Official @tinyclaw/plugin-* packages are managed separately and cannot ' + + 'be installed through this tool.', + parameters: { + type: 'object', + properties: { + package_name: { + type: 'string', + description: 'The npm package name to install (e.g. "@acme/tinyclaw-plugin-telegram")', + }, + confirm: { + type: 'boolean', + description: + 'Must be true. Set this only after the user has explicitly confirmed they want to install this community plugin. ' + + 'Community plugins are unverified third-party code.', + }, + }, + required: ['package_name', 'confirm'], + }, + async execute(args) { + const packageName = (args.package_name as string)?.trim(); + if (!packageName) { + return 'Error: package_name is required. Ask the user for the npm package name.'; + } + if (args.confirm !== true) { + return ( + 'Error: You must confirm with the user before installing a community plugin. ' + + 'Warn them that community plugins are unverified third-party code that will execute on their machine, ' + + 'then call this tool again with confirm: true.' + ); + } + + const result = await installCommunityPlugin(packageName, configManager); + + if (result.success && result.plugin) { + return ( + `${result.message}\n\n` + + `Plugin details:\n` + + ` Name: ${result.plugin.name}\n` + + ` ID: ${result.plugin.id}\n` + + ` Type: ${result.plugin.type}\n` + + ` Version: ${result.plugin.version}\n` + + ` Source: community (unverified)\n\n` + + 'Call tinyclaw_restart to activate the plugin.' + ); + } + + return result.message; + }, + }; + + allTools.push(pluginInstallTool); + + // plugin_remove tool β€” allows the agent to remove community plugins + const pluginRemoveTool: Tool = { + name: 'plugin_remove', + description: + 'Remove a community plugin. This unregisters the plugin from config, ' + + 'removes it from the enabled list, and uninstalls the npm package. ' + + 'After removal, call tinyclaw_restart to apply changes. ' + + 'Only works for community plugins β€” official @tinyclaw/plugin-* ' + + 'packages cannot be removed through this tool.', + parameters: { + type: 'object', + properties: { + package_name: { + type: 'string', + description: 'The npm package name of the community plugin to remove', + }, + }, + required: ['package_name'], + }, + async execute(args) { + const packageName = (args.package_name as string)?.trim(); + if (!packageName) { + return 'Error: package_name is required.'; + } + + const result = await removeCommunityPlugin(packageName, configManager); + return result.message; + }, + }; + + allTools.push(pluginRemoveTool); + + // plugin_list tool β€” shows all plugins (official + community) + const pluginListTool: Tool = { + name: 'plugin_list', + description: + 'List all installed plugins β€” both official (@tinyclaw/plugin-*) and ' + + "community plugins. Shows each plugin's name, type, version, enabled " + + 'status, and source (official or community).', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + async execute() { + const enabledIds = configManager.get('plugins.enabled') ?? []; + const lines: string[] = []; + + // Official plugins β€” show enabled ones, plus note about discoverable ones + const officialEnabled = enabledIds.filter((id) => id.startsWith('@tinyclaw/plugin-')); + lines.push('Official Plugins (verified @tinyclaw/plugin-*):'); + if (officialEnabled.length > 0) { + for (const id of officialEnabled) { + try { + const mod = await import(id); + const p = mod.default; + lines.push( + ` β€’ ${p?.name ?? id} (${id}) v${p?.version ?? '?'} β€” ${p?.type ?? '?'} [enabled]`, + ); + } catch { + lines.push(` β€’ ${id} [enabled, but failed to load]`); + } + } + } else { + lines.push(' (none enabled)'); + } + lines.push( + ' Note: Official plugins not yet enabled are discovered automatically β€” ask me to pair one.', + ); + + lines.push(''); + + // Community plugins + const communityPlugins = await listCommunityPlugins(configManager); + lines.push('Community Plugins (unverified):'); + if (communityPlugins.length > 0) { + for (const cp of communityPlugins) { + const status = cp.enabled ? 'enabled' : 'installed'; + lines.push(` β€’ ${cp.name} (${cp.id}) v${cp.version} β€” ${cp.type} [${status}]`); + } + } else { + lines.push(' (none installed)'); + lines.push(''); + lines.push( + 'To install a community plugin, the user can provide an npm package name ' + + 'and you can use plugin_install to add it.', + ); + } + + return lines.join('\n'); + }, + }; + + allTools.push(pluginListTool); + + const discordStatusTool: Tool = { + name: 'discord_status', + description: + 'Check the real runtime status of the Discord channel plugin. ' + + 'Use this when the user asks whether the Discord bot is online, connected, or offline. ' + + 'Reports config state, whether the token exists, whether the Discord sender is registered, and the last runtime error if any.', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + async execute() { + const enabled = configManager.get('channels.discord.enabled') ?? false; + const tokenStored = await secretsManager.check('channel.discord.token'); + const pluginEnabled = (configManager.get('plugins.enabled') ?? []).includes( + '@tinyclaw/plugin-channel-discord', + ); + const registered = gateway.getRegisteredChannels().includes('discord'); + + let runtimeState = 'unavailable'; + let readyTag: string | null = null; + let lastError: string | null = null; + + try { + const mod = await import('@tinyclaw/plugin-channel-discord'); + if (typeof mod.getDiscordRuntimeStatus === 'function') { + const status = mod.getDiscordRuntimeStatus() as { + state: string; + readyTag: string | null; + lastError: string | null; + }; + runtimeState = status.state; + readyTag = status.readyTag; + lastError = status.lastError; + } + } catch (error) { + lastError = `Could not load Discord status helper: ${error instanceof Error ? error.message : String(error)}`; + } + + const lines = [ + 'Discord plugin status:', + ` Enabled in channels config: ${enabled ? 'yes' : 'no'}`, + ` Present in plugins.enabled: ${pluginEnabled ? 'yes' : 'no'}`, + ` Bot token stored: ${tokenStored ? 'yes' : 'no'}`, + ` Gateway sender registered: ${registered ? 'yes' : 'no'}`, + ` Runtime state: ${runtimeState}`, + ]; + + if (readyTag) { + lines.push(` Logged in as: ${readyTag}`); + } + + if (lastError) { + lines.push(` Last error: ${lastError}`); + } + + if (!registered || runtimeState === 'error') { + lines.push(' Summary: Discord is not currently online in this Tiny Claw runtime.'); + } else if (runtimeState === 'connected') { + lines.push(' Summary: Discord is connected in this Tiny Claw runtime.'); + } else { + lines.push(' Summary: Discord is configured but not yet confirmed online.'); + } + + return lines.join('\n'); + }, + }; + + allTools.push(discordStatusTool); + // --- Create delegation v2 subsystems ----------------------------------- const delegationResult = createDelegationTools({ @@ -879,6 +1128,18 @@ export async function startCommand(): Promise { logger.debug('Update check skipped', err); } + // Check for plugin updates (non-blocking, same pattern as core) + try { + const communityIds = getCommunityPlugins(configManager); + const pluginUpdateInfo = await checkPluginUpdates(dataDir, communityIds); + const pluginCtx = buildPluginUpdateContext(pluginUpdateInfo); + if (pluginCtx) { + updateContext = (updateContext ?? '') + pluginCtx; + } + } catch (err) { + logger.debug('Plugin update check skipped', err); + } + const context = { db, provider: routerDefaultProvider, @@ -1221,6 +1482,55 @@ export async function startCommand(): Promise { }, }); + // Register plugin update check nudge (every 6 hours, same cadence as core) + pulse.register({ + id: 'nudge-plugin-update-check', + schedule: '6h', + handler: async () => { + try { + const pluginUpdateInfo = await checkPluginUpdates( + dataDir, + getCommunityPlugins(configManager), + ); + if (!pluginUpdateInfo || pluginUpdateInfo.updatableCount === 0) return; + + const updatable = pluginUpdateInfo.plugins.filter((p) => p.updateAvailable); + + // Deduplicate: skip if a pending nudge already covers these plugins + const pending = nudgeEngine.pending(); + const alreadyQueued = pending.some((n) => n.category === 'plugin_update'); + if (alreadyQueued) return; + + const pluginList = updatable.map((p) => `${p.id} ${p.current} β†’ ${p.latest}`).join(', '); + + const ownerId = configManager.get('owner.ownerId') || 'web:default'; + nudgeEngine.schedule({ + userId: ownerId, + category: 'plugin_update', + content: `Plugin updates available: ${pluginList}`, + priority: 'low', + deliverAfter: 0, + metadata: { + updatableCount: pluginUpdateInfo.updatableCount, + plugins: updatable.map((p) => ({ + id: p.id, + current: p.current, + latest: p.latest, + })), + runtime: pluginUpdateInfo.runtime, + }, + }); + + logger.info('Nudge: plugin update scheduled', { + count: pluginUpdateInfo.updatableCount, + plugins: pluginList, + }); + } catch (err) { + logger.debug('Nudge: plugin update check skipped', err); + } + }, + }); + // --- Start channel plugins --------------------------------------------- const pluginRuntimeContext = { diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 1ebcaad..e73e32c 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -32,6 +32,7 @@ function showHelp(): void { ); console.log(` ${theme.cmd('start')} Start the Tiny Claw agent`); console.log(` ${theme.cmd('config')} Manage models, providers, and settings`); + console.log(` ${theme.cmd('plugin')} Manage community plugins (add, remove, list)`); console.log(` ${theme.cmd('seed')} Show your Tiny Claw's soul seed`); console.log(` ${theme.cmd('backup')} Export or import a .tinyclaw backup archive`); console.log( @@ -96,6 +97,12 @@ async function main(): Promise { break; } + case 'plugin': { + const { pluginCommand } = await import('./commands/plugin.js'); + await pluginCommand(args.slice(1)); + break; + } + case 'purge': { const { purgeCommand } = await import('./commands/purge.js'); await purgeCommand(args.slice(1)); diff --git a/src/cli/src/utils/secrets.ts b/src/cli/src/utils/secrets.ts new file mode 100644 index 0000000..f744447 --- /dev/null +++ b/src/cli/src/utils/secrets.ts @@ -0,0 +1,49 @@ +import { homedir, platform } from 'node:os'; +import { join } from 'node:path'; +import { theme } from '../ui/theme.js'; + +type ErrorWithCode = Error & { code?: string }; + +export function resolveSecretsStorePath(): string { + return process.env.TINYCLAW_SECRETS_DIR || join(homedir(), '.secrets-engine'); +} + +export function isSecretsIntegrityError(err: unknown): err is ErrorWithCode { + return err instanceof Error && 'code' in err && (err as ErrorWithCode).code === 'INTEGRITY_ERROR'; +} + +function quoteForPosixShell(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function quoteForPowerShell(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function getManualRemovalCommand(storePath: string): string { + if (platform() === 'win32') { + return `Remove-Item -Recurse -Force ${quoteForPowerShell(storePath)}`; + } + + return `rm -rf ${quoteForPosixShell(storePath)}`; +} + +export function printSecretsIntegrityRecovery(nextCommand: string): void { + const storePath = resolveSecretsStorePath(); + + console.log(); + console.log(theme.error(' βœ– Secrets store integrity check failed.')); + console.log(); + console.log(' The store HMAC no longer matches the database contents.'); + console.log(' This usually means corruption, a changed keyfile, or machine-identity drift.'); + console.log(); + console.log(' Fast recovery:'); + console.log(); + console.log(` 1. ${theme.cmd('tinyclaw purge --force --yes')}`); + console.log(` 2. ${theme.cmd(nextCommand)}`); + console.log(); + console.log(' Manual recovery:'); + console.log(); + console.log(` ${theme.cmd(getManualRemovalCommand(storePath))}`); + console.log(); +} diff --git a/src/cli/tests/commands/start.test.ts b/src/cli/tests/commands/start.test.ts index eed4d5c..ed7b9fa 100644 --- a/src/cli/tests/commands/start.test.ts +++ b/src/cli/tests/commands/start.test.ts @@ -8,6 +8,8 @@ import { afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'; +let capturedContext: Record | undefined; + // ── Mock @tinyclaw/secrets ─────────────────────────────────────────── const mockSecretsCheck = mock(() => Promise.resolve(true)); @@ -19,6 +21,7 @@ mock.module('@tinyclaw/secrets', () => ({ Promise.resolve({ check: mockSecretsCheck, close: mockSecretsClose, + destroy: mock(() => Promise.resolve()), storagePath: '/tmp/test-secrets', }), ), @@ -70,10 +73,19 @@ mock.module('@tinyclaw/core', () => ({ DEFAULT_MODEL: 'kimi-k2.5:cloud', DEFAULT_BASE_URL: 'https://ollama.com', BUILTIN_MODEL_TAGS: ['kimi-k2.5:cloud', 'gpt-oss:120b-cloud'], + checkForUpdate: mock(() => Promise.resolve(null)), + buildUpdateContext: mock(() => undefined), })); mock.module('@tinyclaw/plugins', () => ({ loadPlugins: mock(() => Promise.resolve({ channels: [], providers: [], tools: [] })), + discoverPairingTools: mock(() => Promise.resolve([])), + checkPluginUpdates: mock(() => Promise.resolve(null)), + buildPluginUpdateContext: mock(() => undefined), + getCommunityPlugins: mock(() => []), + installCommunityPlugin: mock(() => Promise.resolve({ success: false, message: 'mock' })), + removeCommunityPlugin: mock(() => Promise.resolve({ success: false, message: 'mock' })), + listCommunityPlugins: mock(() => Promise.resolve([])), })); mock.module('@tinyclaw/pulse', () => ({ @@ -184,6 +196,15 @@ mock.module('@tinyclaw/delegation', () => ({ tools: [], blackboard: { read: mock(() => null), write: mock(() => {}), list: mock(() => []) }, estimator: { estimate: mock(() => 30000) }, + lifecycle: {}, + templates: {}, + background: { + getAll: mock(() => []), + getUndelivered: mock(() => []), + markDelivered: mock(() => {}), + cancelAll: mock(() => {}), + cleanupStale: mock(() => 0), + }, })), createBlackboard: mock(() => ({ read: mock(() => null), @@ -257,10 +278,20 @@ mock.module('@tinyclaw/gateway', () => ({ unregister: mock(() => {}), send: mock(() => Promise.resolve({ success: true, channel: 'web', userId: 'web:owner' })), broadcast: mock(() => Promise.resolve([])), - getRegisteredChannels: mock(() => []), + getRegisteredChannels: mock(() => ['discord']), })), })); +const mockDiscordRuntimeStatus = mock(() => ({ + state: 'connected', + readyTag: 'Tiny Claw#1234', + lastError: null, +})); + +mock.module('@tinyclaw/plugin-channel-discord', () => ({ + getDiscordRuntimeStatus: mockDiscordRuntimeStatus, +})); + // ── Mock @tinyclaw/web ──────────────────────────────────────────────── mock.module('@tinyclaw/web', () => ({ @@ -289,7 +320,10 @@ mock.module('@tinyclaw/nudge', () => ({ })), wireNudgeToIntercom: mock(() => mock(() => {})), createNudgeTools: mock(() => []), - createCompanionJobs: mock(() => []), + createCompanionJobs: mock((args: Record) => { + capturedContext = args.context as Record; + return []; + }), getCompanionTouchActivity: mock(() => mock(() => {})), })); @@ -315,6 +349,7 @@ beforeEach(() => { originalArgv = [...process.argv]; consoleOutput = []; exitCode = undefined; + capturedContext = undefined; console.log = (...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); @@ -331,8 +366,15 @@ beforeEach(() => { if (key === 'providers.starterBrain.baseUrl') return 'https://ollama.com'; if (key === 'heartware.seed') return 42; if (key === 'owner.ownerId') return 'cli:owner'; + if (key === 'channels.discord.enabled') return true; + if (key === 'plugins.enabled') return ['@tinyclaw/plugin-channel-discord']; return undefined; }); + mockDiscordRuntimeStatus.mockImplementation(() => ({ + state: 'connected', + readyTag: 'Tiny Claw#1234', + lastError: null, + })); }); afterEach(() => { @@ -370,6 +412,57 @@ describe('startCommand', () => { await startCommand(); expect(mockGetStats).toHaveBeenCalled(); }); + + test('registers discord_status in the runtime tool list', async () => { + await startCommand(); + + const tools = capturedContext?.tools as Array<{ name: string }> | undefined; + expect(tools?.some((tool) => tool.name === 'discord_status')).toBe(true); + }); + + test('discord_status reports connected runtime state', async () => { + await startCommand(); + + const tools = capturedContext?.tools as + | Array<{ name: string; execute: (args: Record) => Promise }> + | undefined; + const discordStatusTool = tools?.find((tool) => tool.name === 'discord_status'); + + expect(discordStatusTool).toBeDefined(); + + const result = await discordStatusTool!.execute({}); + + expect(result).toContain('Enabled in channels config: yes'); + expect(result).toContain('Present in plugins.enabled: yes'); + expect(result).toContain('Bot token stored: yes'); + expect(result).toContain('Gateway sender registered: yes'); + expect(result).toContain('Runtime state: connected'); + expect(result).toContain('Logged in as: Tiny Claw#1234'); + expect(result).toContain('Summary: Discord is connected in this Tiny Claw runtime.'); + }); + + test('discord_status reports plugin helper load failures', async () => { + mockDiscordRuntimeStatus.mockImplementation(() => { + throw new Error('status helper unavailable'); + }); + + await startCommand(); + + const tools = capturedContext?.tools as + | Array<{ name: string; execute: (args: Record) => Promise }> + | undefined; + const discordStatusTool = tools?.find((tool) => tool.name === 'discord_status'); + + expect(discordStatusTool).toBeDefined(); + + const result = await discordStatusTool!.execute({}); + + expect(result).toContain('Runtime state: unavailable'); + expect(result).toContain( + 'Last error: Could not load Discord status helper: status helper unavailable', + ); + expect(result).toContain('Summary: Discord is configured but not yet confirmed online.'); + }); }); describe('startCommand β€” missing API key', () => { diff --git a/src/cli/tests/purge.test.ts b/src/cli/tests/purge.test.ts index 18654b1..a1936ce 100644 --- a/src/cli/tests/purge.test.ts +++ b/src/cli/tests/purge.test.ts @@ -197,4 +197,20 @@ describe('tinyclaw purge --force', () => { expect(stdout).toContain('Secrets were deleted'); }); + + test('falls back to raw deletion for a corrupt secrets store', async () => { + writeFileSync(join(tempSecretsDir, 'meta.json'), '{not-json'); + writeFileSync(join(tempSecretsDir, 'store.db'), ''); + writeFileSync(join(tempSecretsDir, '.keyfile'), 'broken-keyfile'); + + const { stdout, exitCode } = await runPurge(['--force', '--yes'], { + TINYCLAW_DATA_DIR: tempDataDir, + TINYCLAW_SECRETS_DIR: tempSecretsDir, + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain('Purge complete'); + expect(stdout).toContain('Secrets store deleted'); + expect(existsSync(tempSecretsDir)).toBe(false); + }); }); diff --git a/src/landing/index.html b/src/landing/index.html index b4d1899..5623c69 100644 --- a/src/landing/index.html +++ b/src/landing/index.html @@ -5,6 +5,11 @@ Tiny Claw β€” Your Autonomous AI Companion + + + + + diff --git a/src/landing/package.json b/src/landing/package.json index cc6e3c4..4d51d29 100644 --- a/src/landing/package.json +++ b/src/landing/package.json @@ -18,6 +18,6 @@ "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.1.18", "tailwindcss": "^4.1.18", - "vite": "^7.2.4" + "vite": "^8.0.0" } } diff --git a/src/landing/public/favicon/android-chrome-192x192.png b/src/landing/public/favicon/android-chrome-192x192.png new file mode 100644 index 0000000..9275559 Binary files /dev/null and b/src/landing/public/favicon/android-chrome-192x192.png differ diff --git a/src/landing/public/favicon/android-chrome-512x512.png b/src/landing/public/favicon/android-chrome-512x512.png new file mode 100644 index 0000000..53b79e5 Binary files /dev/null and b/src/landing/public/favicon/android-chrome-512x512.png differ diff --git a/src/landing/public/favicon/apple-touch-icon.png b/src/landing/public/favicon/apple-touch-icon.png new file mode 100644 index 0000000..2259dfa Binary files /dev/null and b/src/landing/public/favicon/apple-touch-icon.png differ diff --git a/src/landing/public/favicon/favicon-16x16.png b/src/landing/public/favicon/favicon-16x16.png new file mode 100644 index 0000000..f3de018 Binary files /dev/null and b/src/landing/public/favicon/favicon-16x16.png differ diff --git a/src/landing/public/favicon/favicon-32x32.png b/src/landing/public/favicon/favicon-32x32.png new file mode 100644 index 0000000..22d6cd7 Binary files /dev/null and b/src/landing/public/favicon/favicon-32x32.png differ diff --git a/src/landing/public/favicon/favicon.ico b/src/landing/public/favicon/favicon.ico new file mode 100644 index 0000000..2faf95a Binary files /dev/null and b/src/landing/public/favicon/favicon.ico differ diff --git a/src/landing/public/favicon/site.webmanifest b/src/landing/public/favicon/site.webmanifest new file mode 100644 index 0000000..a148244 --- /dev/null +++ b/src/landing/public/favicon/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Tiny Claw", + "short_name": "TinyClaw", + "icons": [ + { + "src": "/favicon/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/src/landing/public/tinyclaw_logo.png b/src/landing/public/tinyclaw_logo.png new file mode 100644 index 0000000..b8bbeff Binary files /dev/null and b/src/landing/public/tinyclaw_logo.png differ diff --git a/src/landing/src/App.svelte b/src/landing/src/App.svelte index 93193c2..03d076d 100644 --- a/src/landing/src/App.svelte +++ b/src/landing/src/App.svelte @@ -11,7 +11,7 @@