feat: add managed skills gating
This commit is contained in:
@@ -29,6 +29,14 @@ If a file is missing, CLAWDIS injects a single “missing file” marker line (a
|
|||||||
|
|
||||||
Pi’s embedded core tools (read/bash/edit/write and related internals) are defined in code and always available. `TOOLS.md` does **not** control which tools exist; it’s guidance for how *you* want them used.
|
Pi’s embedded core tools (read/bash/edit/write and related internals) are defined in code and always available. `TOOLS.md` does **not** control which tools exist; it’s guidance for how *you* want them used.
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
Clawdis loads skills from two locations (workspace wins on name conflict):
|
||||||
|
- Managed: `~/.clawdis/skills`
|
||||||
|
- Workspace: `<workspace>/skills`
|
||||||
|
|
||||||
|
Managed skills can be gated by config/env (see `skills.*` in `docs/configuration.md`).
|
||||||
|
|
||||||
## Sessions
|
## Sessions
|
||||||
|
|
||||||
Session transcripts are stored as JSONL at:
|
Session transcripts are stored as JSONL at:
|
||||||
|
|||||||
@@ -131,6 +131,32 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `skills` (managed skills config/env)
|
||||||
|
|
||||||
|
Configure **managed** skills (loaded from `~/.clawdis/skills`). Workspace skills always win on name conflicts.
|
||||||
|
|
||||||
|
Common fields per skill:
|
||||||
|
- `enabled`: set `false` to disable a managed skill even if it’s installed.
|
||||||
|
- `env`: environment variables injected for the agent run (only if not already set).
|
||||||
|
- `apiKey`: optional convenience for skills that declare a primary env var (e.g. `nano-banana-pro` → `GEMINI_API_KEY`).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
skills: {
|
||||||
|
"nano-banana-pro": {
|
||||||
|
apiKey: "GEMINI_KEY_HERE",
|
||||||
|
env: {
|
||||||
|
GEMINI_API_KEY: "GEMINI_KEY_HERE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
peekaboo: { enabled: true },
|
||||||
|
sag: { enabled: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### `browser` (clawd-managed Chrome)
|
### `browser` (clawd-managed Chrome)
|
||||||
|
|
||||||
Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server.
|
Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server.
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ Example:
|
|||||||
- Start here:
|
- Start here:
|
||||||
- [Configuration](./configuration.md)
|
- [Configuration](./configuration.md)
|
||||||
- [Clawd personal assistant setup](./clawd.md)
|
- [Clawd personal assistant setup](./clawd.md)
|
||||||
|
- [Skills](./skills.md)
|
||||||
- [Workspace templates](./templates/AGENTS.md)
|
- [Workspace templates](./templates/AGENTS.md)
|
||||||
- [Gateway runbook](./gateway.md)
|
- [Gateway runbook](./gateway.md)
|
||||||
- [Nodes (iOS/Android)](./nodes.md)
|
- [Nodes (iOS/Android)](./nodes.md)
|
||||||
|
|||||||
107
docs/skills.md
Normal file
107
docs/skills.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
summary: "Skills: managed vs workspace, gating rules, and config/env wiring"
|
||||||
|
read_when:
|
||||||
|
- Adding or modifying skills
|
||||||
|
- Changing skill gating or load rules
|
||||||
|
---
|
||||||
|
<!-- {% raw %} -->
|
||||||
|
# Skills (Clawdis)
|
||||||
|
|
||||||
|
Clawdis uses **AgentSkills-compatible** skill folders to teach the agent how to use tools. Each skill is a directory containing a `SKILL.md` with YAML frontmatter and instructions. Clawdis loads **managed skills** plus **workspace skills**, and filters them at load time based on environment, config, and binary presence.
|
||||||
|
|
||||||
|
## Locations and precedence
|
||||||
|
|
||||||
|
Skills are loaded from **two** places:
|
||||||
|
|
||||||
|
1) **Managed skills**: `~/.clawdis/skills`
|
||||||
|
2) **Workspace skills**: `<workspace>/skills`
|
||||||
|
|
||||||
|
If a skill name conflicts, the **workspace** version wins (user overrides managed).
|
||||||
|
|
||||||
|
## Format (AgentSkills + Pi-compatible)
|
||||||
|
|
||||||
|
`SKILL.md` must include at least:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: nano-banana-pro
|
||||||
|
description: Generate or edit images via Gemini 3 Pro Image
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- We follow the AgentSkills spec for layout/intent.
|
||||||
|
- The parser used by the embedded agent supports **single-line** frontmatter keys only.
|
||||||
|
- `metadata` should be a **single-line JSON object**.
|
||||||
|
- Use `{baseDir}` in instructions to reference the skill folder path.
|
||||||
|
|
||||||
|
## Gating (load-time filters)
|
||||||
|
|
||||||
|
Clawdis **filters skills at load time** using `metadata` (single-line JSON):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: nano-banana-pro
|
||||||
|
description: Generate or edit images via Gemini 3 Pro Image
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["uv"],"env":["GEMINI_API_KEY"],"config":["browser.enabled"]},"primaryEnv":"GEMINI_API_KEY"}}
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields under `metadata.clawdis`:
|
||||||
|
- `always: true` — always include the skill (skip other gates).
|
||||||
|
- `requires.bins` — list; each must exist on `PATH`.
|
||||||
|
- `requires.env` — list; env var must exist **or** be provided in config.
|
||||||
|
- `requires.config` — list of `clawdis.json` paths that must be truthy.
|
||||||
|
- `primaryEnv` — env var name associated with `skills.<name>.apiKey`.
|
||||||
|
|
||||||
|
If no `metadata.clawdis` is present, the skill is always eligible (unless disabled in config).
|
||||||
|
|
||||||
|
## Config overrides (`~/.clawdis/clawdis.json`)
|
||||||
|
|
||||||
|
Managed skills can be toggled and supplied with env values:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
skills: {
|
||||||
|
"nano-banana-pro": {
|
||||||
|
enabled: true,
|
||||||
|
apiKey: "GEMINI_KEY_HERE",
|
||||||
|
env: {
|
||||||
|
GEMINI_API_KEY: "GEMINI_KEY_HERE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
peekaboo: { enabled: true },
|
||||||
|
sag: { enabled: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: if the skill name contains hyphens, quote the key (JSON5 allows quoted keys).
|
||||||
|
|
||||||
|
Config keys match the **skill name**. We don’t require a custom `skillKey`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- `enabled: false` disables the managed skill even if installed.
|
||||||
|
- `env`: injected **only if** the variable isn’t already set in the process.
|
||||||
|
- `apiKey`: convenience for skills that declare `metadata.clawdis.primaryEnv`.
|
||||||
|
|
||||||
|
## Environment injection (per agent run)
|
||||||
|
|
||||||
|
When an agent run starts, Clawdis:
|
||||||
|
1) Reads skill metadata.
|
||||||
|
2) Applies any `skills.<key>.env` or `skills.<key>.apiKey` to `process.env`.
|
||||||
|
3) Builds the system prompt with **eligible** skills.
|
||||||
|
4) Restores the original environment after the run ends.
|
||||||
|
|
||||||
|
This is **scoped to the agent run**, not a global shell environment.
|
||||||
|
|
||||||
|
## Session snapshot (performance)
|
||||||
|
|
||||||
|
Clawdis snapshots the eligible skills **when a session starts** and reuses that list for subsequent turns in the same session. Changes to skills or config take effect on the next new session.
|
||||||
|
|
||||||
|
## Managed skills lifecycle
|
||||||
|
|
||||||
|
Managed skills are owned by Clawdis (not user-editable). Workspace skills are user-owned and override managed ones by name. The macOS app or installer should copy bundled skills into `~/.clawdis/skills` on install/update.
|
||||||
|
|
||||||
|
---
|
||||||
|
<!-- {% endraw %} -->
|
||||||
@@ -41,9 +41,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||||
"@homebridge/ciao": "^1.3.4",
|
"@homebridge/ciao": "^1.3.4",
|
||||||
"@mariozechner/pi-agent-core": "^0.21.0",
|
"@mariozechner/pi-agent-core": "^0.24.5",
|
||||||
"@mariozechner/pi-ai": "^0.21.0",
|
"@mariozechner/pi-ai": "^0.24.5",
|
||||||
"@mariozechner/pi-coding-agent": "^0.21.0",
|
"@mariozechner/pi-coding-agent": "^0.24.5",
|
||||||
"@sinclair/typebox": "^0.34.41",
|
"@sinclair/typebox": "^0.34.41",
|
||||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
|
|||||||
15
skills/agent-tools/SKILL.md
Normal file
15
skills/agent-tools/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: agent-tools
|
||||||
|
description: Utility toolkit for automations and MCP-friendly scripts.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["agent-tools"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# agent-tools
|
||||||
|
|
||||||
|
Use `agent-tools` for helper utilities and automations. Start with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-tools --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer small, composable commands.
|
||||||
19
skills/bird/SKILL.md
Normal file
19
skills/bird/SKILL.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: bird
|
||||||
|
description: X/Twitter CLI to tweet, reply, read threads, and search.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["bird"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# bird
|
||||||
|
|
||||||
|
Use `bird` to interact with X/Twitter.
|
||||||
|
|
||||||
|
- Tweet: `bird tweet "..."`
|
||||||
|
- Reply: `bird reply <tweet-id> "..."`
|
||||||
|
- Read: `bird read <tweet-id>`
|
||||||
|
- Thread: `bird thread <tweet-id>`
|
||||||
|
- Search: `bird search "query"`
|
||||||
|
- Mentions: `bird mentions`
|
||||||
|
- Whoami: `bird whoami`
|
||||||
|
|
||||||
|
Confirm before posting publicly.
|
||||||
15
skills/blucli/SKILL.md
Normal file
15
skills/blucli/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: blucli
|
||||||
|
description: Control BluOS players from the CLI.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["blucli"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# blucli
|
||||||
|
|
||||||
|
Use `blucli` to control BluOS players. Start with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blucli --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm target player before changing playback.
|
||||||
15
skills/camsnap/SKILL.md
Normal file
15
skills/camsnap/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: camsnap
|
||||||
|
description: Capture frames or clips from RTSP/ONVIF cameras.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["camsnap"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# camsnap
|
||||||
|
|
||||||
|
Use `camsnap` to grab frames or clips from configured cameras. Start with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
camsnap --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer small test captures before longer clips.
|
||||||
18
skills/clawdis-browser/SKILL.md
Normal file
18
skills/clawdis-browser/SKILL.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: clawdis-browser
|
||||||
|
description: Control clawd's dedicated browser (tabs, snapshots, actions) via the clawdis CLI.
|
||||||
|
metadata: {"clawdis":{"requires":{"config":["browser.enabled"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# Clawdis Browser
|
||||||
|
|
||||||
|
Use the clawd-managed Chrome/Chromium instance through `clawdis browser` commands.
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
- Status/start/stop: `clawdis browser status|start|stop`
|
||||||
|
- Tabs: `clawdis browser tabs|open <url>|focus <id>|close <id>`
|
||||||
|
- Snapshot/screenshot: `clawdis browser snapshot --format ai|aria`, `clawdis browser screenshot [--full-page]`
|
||||||
|
- Actions: `clawdis browser click|type|hover|drag|select|upload|press|wait|navigate|back|evaluate|run`
|
||||||
|
|
||||||
|
If disabled, ask the user to enable `browser.enabled` in `~/.clawdis/clawdis.json`.
|
||||||
22
skills/clawdis-canvas/SKILL.md
Normal file
22
skills/clawdis-canvas/SKILL.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: clawdis-canvas
|
||||||
|
description: Drive the Clawdis Canvas panel (present, eval, snapshot, A2UI) via the clawdis CLI.
|
||||||
|
metadata: {"clawdis":{"always":true}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# Clawdis Canvas
|
||||||
|
|
||||||
|
Use Canvas to render HTML/JS or A2UI surfaces and capture snapshots.
|
||||||
|
|
||||||
|
## Core commands
|
||||||
|
|
||||||
|
- Show/hide: `clawdis canvas present [--node <id>] [--target <path>]`, `clawdis canvas hide`
|
||||||
|
- JS eval: `clawdis canvas eval --js "..."`
|
||||||
|
- Snapshot: `clawdis canvas snapshot`
|
||||||
|
|
||||||
|
## A2UI
|
||||||
|
|
||||||
|
- Push JSONL: `clawdis canvas a2ui push --jsonl /path/to/file.jsonl`
|
||||||
|
- Reset: `clawdis canvas a2ui reset`
|
||||||
|
|
||||||
|
If targeting remote nodes, use the canvas host (LAN/tailnet) and keep HTML under `~/clawd/canvas`.
|
||||||
15
skills/eightctl/SKILL.md
Normal file
15
skills/eightctl/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: eightctl
|
||||||
|
description: Control sleep workflows with eightctl.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["eightctl"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# eightctl
|
||||||
|
|
||||||
|
Use `eightctl` for sleep/wake automation. Start with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eightctl --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm before scheduling disruptive actions.
|
||||||
15
skills/gemini-cli/SKILL.md
Normal file
15
skills/gemini-cli/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: gemini-cli
|
||||||
|
description: Use the Gemini CLI for fast Q&A and generation.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["gemini"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# Gemini CLI
|
||||||
|
|
||||||
|
Use `gemini` for quick prompts. Start with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gemini --help
|
||||||
|
```
|
||||||
|
|
||||||
|
If auth is required, set the provider's API key in the environment.
|
||||||
15
skills/gog/SKILL.md
Normal file
15
skills/gog/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: gog
|
||||||
|
description: Google Suite CLI for Gmail, Calendar, Drive, and Contacts.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["gog"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# gog
|
||||||
|
|
||||||
|
Use `gog` for Gmail/Calendar/Drive/Contacts. Start with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gog --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Ask for confirmation before sending emails or creating events.
|
||||||
15
skills/imsg/SKILL.md
Normal file
15
skills/imsg/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: imsg
|
||||||
|
description: Send or read iMessage/SMS using the imsg CLI.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["imsg"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# imsg
|
||||||
|
|
||||||
|
Use `imsg` for iMessage/SMS. Start with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
imsg --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm recipients and message text before sending.
|
||||||
15
skills/mcporter/SKILL.md
Normal file
15
skills/mcporter/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: mcporter
|
||||||
|
description: Manage MCP servers (install, list, sync) with mcporter.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["mcporter"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# mcporter
|
||||||
|
|
||||||
|
Use the `mcporter` CLI to install, list, and sync MCP servers. Start with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mcporter --help
|
||||||
|
```
|
||||||
|
|
||||||
|
If the tool is missing, ask the user to install it from the Tools tab.
|
||||||
42
skills/nano-banana-pro/SKILL.md
Normal file
42
skills/nano-banana-pro/SKILL.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: nano-banana-pro
|
||||||
|
description: Generate or edit images via Gemini 3 Pro Image (Nano Banana Pro).
|
||||||
|
metadata: {"clawdis":{"requires":{"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY"}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# Nano Banana Pro Image Generation & Editing
|
||||||
|
|
||||||
|
Generate new images or edit existing ones using Google's Nano Banana Pro API.
|
||||||
|
|
||||||
|
## Usage (always run from the current working directory)
|
||||||
|
|
||||||
|
**Generate new image:**
|
||||||
|
```bash
|
||||||
|
uv run {baseDir}/scripts/generate_image.py --prompt "your image description" --filename "output-name.png" [--resolution 1K|2K|4K]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edit existing image:**
|
||||||
|
```bash
|
||||||
|
uv run {baseDir}/scripts/generate_image.py --prompt "editing instructions" --filename "output-name.png" --input-image "path/to/input.png" [--resolution 1K|2K|4K]
|
||||||
|
```
|
||||||
|
|
||||||
|
## API key
|
||||||
|
|
||||||
|
The script uses:
|
||||||
|
1) `GEMINI_API_KEY` environment variable
|
||||||
|
2) `--api-key` argument (optional)
|
||||||
|
|
||||||
|
If the key is missing, check `skills."nano-banana-pro".apiKey` or `skills."nano-banana-pro".env.GEMINI_API_KEY` in `~/.clawdis/clawdis.json`, or ask the user to provide one.
|
||||||
|
|
||||||
|
## Resolution
|
||||||
|
|
||||||
|
- `1K` (default), `2K`, `4K`
|
||||||
|
- Map user intent: low/1080 → `1K`, medium/2K → `2K`, high/ultra/4K → `4K`
|
||||||
|
|
||||||
|
## Filename
|
||||||
|
|
||||||
|
Use `{timestamp}-{short-name}.png` (yyyy-mm-dd-hh-mm-ss, lowercase, hyphens).
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Do **not** read the image back; just report the saved path.
|
||||||
167
skills/nano-banana-pro/scripts/generate_image.py
Executable file
167
skills/nano-banana-pro/scripts/generate_image.py
Executable file
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = [
|
||||||
|
# "google-genai>=1.0.0",
|
||||||
|
# "pillow>=10.0.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
"""
|
||||||
|
Generate images using Google's Nano Banana Pro (Gemini 3 Pro Image) API.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_key(provided_key: str | None) -> str | None:
|
||||||
|
"""Get API key from argument first, then environment."""
|
||||||
|
if provided_key:
|
||||||
|
return provided_key
|
||||||
|
return os.environ.get("GEMINI_API_KEY")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Generate images using Nano Banana Pro (Gemini 3 Pro Image)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--prompt", "-p",
|
||||||
|
required=True,
|
||||||
|
help="Image description/prompt"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--filename", "-f",
|
||||||
|
required=True,
|
||||||
|
help="Output filename (e.g., sunset-mountains.png)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--input-image", "-i",
|
||||||
|
help="Optional input image path for editing/modification"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--resolution", "-r",
|
||||||
|
choices=["1K", "2K", "4K"],
|
||||||
|
default="1K",
|
||||||
|
help="Output resolution: 1K (default), 2K, or 4K"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--api-key", "-k",
|
||||||
|
help="Gemini API key (overrides GEMINI_API_KEY env var)"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Get API key
|
||||||
|
api_key = get_api_key(args.api_key)
|
||||||
|
if not api_key:
|
||||||
|
print("Error: No API key provided.", file=sys.stderr)
|
||||||
|
print("Please either:", file=sys.stderr)
|
||||||
|
print(" 1. Provide --api-key argument", file=sys.stderr)
|
||||||
|
print(" 2. Set GEMINI_API_KEY environment variable", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Import here after checking API key to avoid slow import on error
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
|
# Initialise client
|
||||||
|
client = genai.Client(api_key=api_key)
|
||||||
|
|
||||||
|
# Set up output path
|
||||||
|
output_path = Path(args.filename)
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Load input image if provided
|
||||||
|
input_image = None
|
||||||
|
output_resolution = args.resolution
|
||||||
|
if args.input_image:
|
||||||
|
try:
|
||||||
|
input_image = PILImage.open(args.input_image)
|
||||||
|
print(f"Loaded input image: {args.input_image}")
|
||||||
|
|
||||||
|
# Auto-detect resolution if not explicitly set by user
|
||||||
|
if args.resolution == "1K": # Default value
|
||||||
|
# Map input image size to resolution
|
||||||
|
width, height = input_image.size
|
||||||
|
max_dim = max(width, height)
|
||||||
|
if max_dim >= 3000:
|
||||||
|
output_resolution = "4K"
|
||||||
|
elif max_dim >= 1500:
|
||||||
|
output_resolution = "2K"
|
||||||
|
else:
|
||||||
|
output_resolution = "1K"
|
||||||
|
print(f"Auto-detected resolution: {output_resolution} (from input {width}x{height})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading input image: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Build contents (image first if editing, prompt only if generating)
|
||||||
|
if input_image:
|
||||||
|
contents = [input_image, args.prompt]
|
||||||
|
print(f"Editing image with resolution {output_resolution}...")
|
||||||
|
else:
|
||||||
|
contents = args.prompt
|
||||||
|
print(f"Generating image with resolution {output_resolution}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.models.generate_content(
|
||||||
|
model="gemini-3-pro-image-preview",
|
||||||
|
contents=contents,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
response_modalities=["TEXT", "IMAGE"],
|
||||||
|
image_config=types.ImageConfig(
|
||||||
|
image_size=output_resolution
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process response and convert to PNG
|
||||||
|
image_saved = False
|
||||||
|
for part in response.parts:
|
||||||
|
if part.text is not None:
|
||||||
|
print(f"Model response: {part.text}")
|
||||||
|
elif part.inline_data is not None:
|
||||||
|
# Convert inline data to PIL Image and save as PNG
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
# inline_data.data is already bytes, not base64
|
||||||
|
image_data = part.inline_data.data
|
||||||
|
if isinstance(image_data, str):
|
||||||
|
# If it's a string, it might be base64
|
||||||
|
import base64
|
||||||
|
image_data = base64.b64decode(image_data)
|
||||||
|
|
||||||
|
image = PILImage.open(BytesIO(image_data))
|
||||||
|
|
||||||
|
# Ensure RGB mode for PNG (convert RGBA to RGB with white background if needed)
|
||||||
|
if image.mode == 'RGBA':
|
||||||
|
rgb_image = PILImage.new('RGB', image.size, (255, 255, 255))
|
||||||
|
rgb_image.paste(image, mask=image.split()[3])
|
||||||
|
rgb_image.save(str(output_path), 'PNG')
|
||||||
|
elif image.mode == 'RGB':
|
||||||
|
image.save(str(output_path), 'PNG')
|
||||||
|
else:
|
||||||
|
image.convert('RGB').save(str(output_path), 'PNG')
|
||||||
|
image_saved = True
|
||||||
|
|
||||||
|
if image_saved:
|
||||||
|
full_path = output_path.resolve()
|
||||||
|
print(f"\nImage saved: {full_path}")
|
||||||
|
else:
|
||||||
|
print("Error: No image was generated in the response.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error generating image: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
15
skills/openai-whisper/SKILL.md
Normal file
15
skills/openai-whisper/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: openai-whisper
|
||||||
|
description: Local speech-to-text using OpenAI Whisper CLI.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["whisper"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenAI Whisper (CLI)
|
||||||
|
|
||||||
|
Use `whisper` for local speech-to-text. Start with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
whisper --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer small files first; keep outputs in the current working directory.
|
||||||
15
skills/openhue/SKILL.md
Normal file
15
skills/openhue/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: openhue
|
||||||
|
description: Control Philips Hue lights and scenes via CLI.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["openhue"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenHue CLI
|
||||||
|
|
||||||
|
Use `openhue` to control Hue lights/scenes. Start with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openhue --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer listing lights/scenes before toggling.
|
||||||
15
skills/oracle/SKILL.md
Normal file
15
skills/oracle/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: oracle
|
||||||
|
description: Run a second-model review or debug session with the oracle CLI.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["oracle"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# oracle
|
||||||
|
|
||||||
|
Use `oracle` to run a second-model review or debugging pass.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
oracle --help
|
||||||
|
```
|
||||||
|
|
||||||
|
If the binary is missing, use `npx -y @steipete/oracle --help`.
|
||||||
20
skills/peekaboo/SKILL.md
Normal file
20
skills/peekaboo/SKILL.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: peekaboo
|
||||||
|
description: Capture and inspect macOS UI via the Peekaboo CLI.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["peekaboo"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# Peekaboo
|
||||||
|
|
||||||
|
Fast UI capture and inspection.
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
- Capture: `peekaboo capture`
|
||||||
|
- Inspect: `peekaboo see`
|
||||||
|
- Click: `peekaboo click`
|
||||||
|
- List windows: `peekaboo list`
|
||||||
|
- Tool info: `peekaboo tools`
|
||||||
|
- Permissions: `peekaboo permissions status`
|
||||||
|
|
||||||
|
Requires Screen Recording + Accessibility permissions.
|
||||||
15
skills/qmd/SKILL.md
Normal file
15
skills/qmd/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: qmd
|
||||||
|
description: Search notes/content with qmd (BM25 + vectors + rerank).
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["qmd"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# qmd
|
||||||
|
|
||||||
|
Use `qmd` to search local content. Start with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qmd --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Use concise queries and refine with filters if available.
|
||||||
15
skills/sag/SKILL.md
Normal file
15
skills/sag/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: sag
|
||||||
|
description: ElevenLabs text-to-speech with mac-style say UX.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["sag"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# sag
|
||||||
|
|
||||||
|
Use `sag` to speak or stream text to speakers. Start with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sag --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the voice/speaker before long output.
|
||||||
17
skills/sonoscli/SKILL.md
Normal file
17
skills/sonoscli/SKILL.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
name: sonoscli
|
||||||
|
description: Control Sonos speakers (discover/status/play/volume/group).
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["sonos"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sonos CLI
|
||||||
|
|
||||||
|
Use `sonos` to control Sonos speakers.
|
||||||
|
|
||||||
|
- Discover: `sonos discover`
|
||||||
|
- Status: `sonos status`
|
||||||
|
- Playback: `sonos play|pause|stop`
|
||||||
|
- Volume: `sonos volume set <0-100>`
|
||||||
|
- Group: `sonos group <leader> <member>`
|
||||||
|
|
||||||
|
If SSDP fails, specify `--ip <speaker-ip>`.
|
||||||
15
skills/spotify-player/SKILL.md
Normal file
15
skills/spotify-player/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: spotify-player
|
||||||
|
description: Control Spotify playback from the terminal.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["spotify-player"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# spotify-player
|
||||||
|
|
||||||
|
Use `spotify-player` to search, queue, and control playback.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
spotify-player --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the device/target if multiple are available.
|
||||||
15
skills/wacli/SKILL.md
Normal file
15
skills/wacli/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: wacli
|
||||||
|
description: WhatsApp CLI for syncing, searching, and sending messages.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["wacli"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# wacli
|
||||||
|
|
||||||
|
Use `wacli` to sync/search/send WhatsApp messages. Start with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wacli --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm before sending messages.
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
SessionManager,
|
SessionManager,
|
||||||
SettingsManager,
|
SettingsManager,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
import type { ClawdisConfig } from "../config/config.js";
|
||||||
import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
|
import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
|
||||||
import {
|
import {
|
||||||
createToolDebouncer,
|
createToolDebouncer,
|
||||||
@@ -43,7 +44,13 @@ import {
|
|||||||
createClawdisCodingTools,
|
createClawdisCodingTools,
|
||||||
sanitizeContentBlocksImages,
|
sanitizeContentBlocksImages,
|
||||||
} from "./pi-tools.js";
|
} from "./pi-tools.js";
|
||||||
import { buildWorkspaceSkillsPrompt } from "./skills.js";
|
import {
|
||||||
|
applySkillEnvOverrides,
|
||||||
|
applySkillEnvOverridesFromSnapshot,
|
||||||
|
buildWorkspaceSkillSnapshot,
|
||||||
|
type SkillSnapshot,
|
||||||
|
loadWorkspaceSkillEntries,
|
||||||
|
} from "./skills.js";
|
||||||
import { buildAgentSystemPrompt } from "./system-prompt.js";
|
import { buildAgentSystemPrompt } from "./system-prompt.js";
|
||||||
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
|
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
|
||||||
|
|
||||||
@@ -200,6 +207,8 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
sessionFile: string;
|
sessionFile: string;
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
|
config?: ClawdisConfig;
|
||||||
|
skillsSnapshot?: SkillSnapshot;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -244,8 +253,30 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let restoreSkillEnv: (() => void) | undefined;
|
||||||
process.chdir(resolvedWorkspace);
|
process.chdir(resolvedWorkspace);
|
||||||
try {
|
try {
|
||||||
|
const skillEntries = params.skillsSnapshot
|
||||||
|
? undefined
|
||||||
|
: loadWorkspaceSkillEntries(resolvedWorkspace, {
|
||||||
|
config: params.config,
|
||||||
|
});
|
||||||
|
const skillsSnapshot =
|
||||||
|
params.skillsSnapshot ??
|
||||||
|
buildWorkspaceSkillSnapshot(resolvedWorkspace, {
|
||||||
|
config: params.config,
|
||||||
|
entries: skillEntries,
|
||||||
|
});
|
||||||
|
restoreSkillEnv = params.skillsSnapshot
|
||||||
|
? applySkillEnvOverridesFromSnapshot({
|
||||||
|
snapshot: params.skillsSnapshot,
|
||||||
|
config: params.config,
|
||||||
|
})
|
||||||
|
: applySkillEnvOverrides({
|
||||||
|
skills: skillEntries ?? [],
|
||||||
|
config: params.config,
|
||||||
|
});
|
||||||
|
|
||||||
const bootstrapFiles =
|
const bootstrapFiles =
|
||||||
await loadWorkspaceBootstrapFiles(resolvedWorkspace);
|
await loadWorkspaceBootstrapFiles(resolvedWorkspace);
|
||||||
const systemPrompt = buildAgentSystemPrompt({
|
const systemPrompt = buildAgentSystemPrompt({
|
||||||
@@ -258,8 +289,7 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
})),
|
})),
|
||||||
defaultThinkLevel: params.thinkLevel,
|
defaultThinkLevel: params.thinkLevel,
|
||||||
});
|
});
|
||||||
const systemPromptWithSkills =
|
const systemPromptWithSkills = systemPrompt + skillsSnapshot.prompt;
|
||||||
systemPrompt + buildWorkspaceSkillsPrompt(resolvedWorkspace);
|
|
||||||
|
|
||||||
const sessionManager = new SessionManager(false, params.sessionFile);
|
const sessionManager = new SessionManager(false, params.sessionFile);
|
||||||
const settingsManager = new SettingsManager();
|
const settingsManager = new SettingsManager();
|
||||||
@@ -576,6 +606,7 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
restoreSkillEnv?.();
|
||||||
process.chdir(prevCwd);
|
process.chdir(prevCwd);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,9 +24,42 @@ description: Does demo things
|
|||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
const prompt = buildWorkspaceSkillsPrompt(workspaceDir);
|
const prompt = buildWorkspaceSkillsPrompt(workspaceDir, {
|
||||||
|
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||||
|
});
|
||||||
expect(prompt).toContain("demo-skill");
|
expect(prompt).toContain("demo-skill");
|
||||||
expect(prompt).toContain("Does demo things");
|
expect(prompt).toContain("Does demo things");
|
||||||
expect(prompt).toContain(path.join(skillDir, "SKILL.md"));
|
expect(prompt).toContain(path.join(skillDir, "SKILL.md"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("filters skills based on env/config gates", async () => {
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
|
||||||
|
const skillDir = path.join(workspaceDir, "skills", "nano-banana-pro");
|
||||||
|
await fs.mkdir(skillDir, { recursive: true });
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(skillDir, "SKILL.md"),
|
||||||
|
`---
|
||||||
|
name: nano-banana-pro
|
||||||
|
description: Generates images
|
||||||
|
metadata: {"clawdis":{"requires":{"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY"}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# Nano Banana
|
||||||
|
`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingPrompt = buildWorkspaceSkillsPrompt(workspaceDir, {
|
||||||
|
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||||
|
config: { skills: { "nano-banana-pro": { apiKey: "" } } },
|
||||||
|
});
|
||||||
|
expect(missingPrompt).not.toContain("nano-banana-pro");
|
||||||
|
|
||||||
|
const enabledPrompt = buildWorkspaceSkillsPrompt(workspaceDir, {
|
||||||
|
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||||
|
config: { skills: { "nano-banana-pro": { apiKey: "test-key" } } },
|
||||||
|
});
|
||||||
|
expect(enabledPrompt).toContain("nano-banana-pro");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,395 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type Skill,
|
||||||
|
type SkillFrontmatter,
|
||||||
formatSkillsForPrompt,
|
formatSkillsForPrompt,
|
||||||
loadSkillsFromDir,
|
loadSkillsFromDir,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
export function buildWorkspaceSkillsPrompt(workspaceDir: string): string {
|
import type { ClawdisConfig, SkillConfig } from "../config/config.js";
|
||||||
const skillsDir = path.join(workspaceDir, "skills");
|
import { CONFIG_DIR } from "../utils.js";
|
||||||
const skills = loadSkillsFromDir({
|
|
||||||
dir: skillsDir,
|
type ClawdisSkillMetadata = {
|
||||||
source: "clawdis-workspace",
|
always?: boolean;
|
||||||
});
|
skillKey?: string;
|
||||||
return formatSkillsForPrompt(skills);
|
primaryEnv?: string;
|
||||||
|
requires?: {
|
||||||
|
bins?: string[];
|
||||||
|
env?: string[];
|
||||||
|
config?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type SkillEntry = {
|
||||||
|
skill: Skill;
|
||||||
|
frontmatter: SkillFrontmatter;
|
||||||
|
clawdis?: ClawdisSkillMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkillSnapshot = {
|
||||||
|
prompt: string;
|
||||||
|
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getFrontmatterValue(
|
||||||
|
frontmatter: SkillFrontmatter,
|
||||||
|
key: string,
|
||||||
|
): string | undefined {
|
||||||
|
const raw = frontmatter[key];
|
||||||
|
return typeof raw === "string" ? raw : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripQuotes(value: string): string {
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
return value.slice(1, -1);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatter(content: string): SkillFrontmatter {
|
||||||
|
const frontmatter: SkillFrontmatter = {};
|
||||||
|
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
if (!normalized.startsWith("---")) return frontmatter;
|
||||||
|
const endIndex = normalized.indexOf("\n---", 3);
|
||||||
|
if (endIndex === -1) return frontmatter;
|
||||||
|
const block = normalized.slice(4, endIndex);
|
||||||
|
for (const line of block.split("\n")) {
|
||||||
|
const match = line.match(/^([\w-]+):\s*(.*)$/);
|
||||||
|
if (!match) continue;
|
||||||
|
const key = match[1];
|
||||||
|
const value = stripQuotes(match[2].trim());
|
||||||
|
if (!key || !value) continue;
|
||||||
|
frontmatter[key] = value;
|
||||||
|
}
|
||||||
|
return frontmatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringList(input: unknown): string[] {
|
||||||
|
if (!input) return [];
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return input.map((value) => String(value).trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (typeof input === "string") {
|
||||||
|
return input
|
||||||
|
.split(",")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTruthy(value: unknown): boolean {
|
||||||
|
if (value === undefined || value === null) return false;
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (typeof value === "number") return value !== 0;
|
||||||
|
if (typeof value === "string") return value.trim().length > 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
||||||
|
"browser.enabled": true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveConfigPath(config: ClawdisConfig | undefined, pathStr: string) {
|
||||||
|
const parts = pathStr.split(".").filter(Boolean);
|
||||||
|
let current: unknown = config;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (typeof current !== "object" || current === null) return undefined;
|
||||||
|
current = (current as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfigPathTruthy(
|
||||||
|
config: ClawdisConfig | undefined,
|
||||||
|
pathStr: string,
|
||||||
|
): boolean {
|
||||||
|
const value = resolveConfigPath(config, pathStr);
|
||||||
|
if (value === undefined && pathStr in DEFAULT_CONFIG_VALUES) {
|
||||||
|
return DEFAULT_CONFIG_VALUES[pathStr] === true;
|
||||||
|
}
|
||||||
|
return isTruthy(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSkillConfig(
|
||||||
|
config: ClawdisConfig | undefined,
|
||||||
|
skillKey: string,
|
||||||
|
): SkillConfig | undefined {
|
||||||
|
const skills = config?.skills;
|
||||||
|
if (!skills || typeof skills !== "object") return undefined;
|
||||||
|
const entry = (skills as Record<string, SkillConfig | undefined>)[skillKey];
|
||||||
|
if (!entry || typeof entry !== "object") return undefined;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBinary(bin: string): boolean {
|
||||||
|
const pathEnv = process.env.PATH ?? "";
|
||||||
|
const parts = pathEnv.split(path.delimiter).filter(Boolean);
|
||||||
|
for (const part of parts) {
|
||||||
|
const candidate = path.join(part, bin);
|
||||||
|
try {
|
||||||
|
fs.accessSync(candidate, fs.constants.X_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// keep scanning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveClawdisMetadata(
|
||||||
|
frontmatter: SkillFrontmatter,
|
||||||
|
): ClawdisSkillMetadata | undefined {
|
||||||
|
const raw = getFrontmatterValue(frontmatter, "metadata");
|
||||||
|
if (!raw) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { clawdis?: unknown };
|
||||||
|
if (!parsed || typeof parsed !== "object") return undefined;
|
||||||
|
const clawdis = (parsed as { clawdis?: unknown }).clawdis;
|
||||||
|
if (!clawdis || typeof clawdis !== "object") return undefined;
|
||||||
|
const clawdisObj = clawdis as Record<string, unknown>;
|
||||||
|
const requiresRaw =
|
||||||
|
typeof clawdisObj.requires === "object" && clawdisObj.requires !== null
|
||||||
|
? (clawdisObj.requires as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
always:
|
||||||
|
typeof clawdisObj.always === "boolean"
|
||||||
|
? clawdisObj.always
|
||||||
|
: undefined,
|
||||||
|
skillKey:
|
||||||
|
typeof clawdisObj.skillKey === "string"
|
||||||
|
? clawdisObj.skillKey
|
||||||
|
: undefined,
|
||||||
|
primaryEnv:
|
||||||
|
typeof clawdisObj.primaryEnv === "string"
|
||||||
|
? clawdisObj.primaryEnv
|
||||||
|
: undefined,
|
||||||
|
requires: requiresRaw
|
||||||
|
? {
|
||||||
|
bins: normalizeStringList(requiresRaw.bins),
|
||||||
|
env: normalizeStringList(requiresRaw.env),
|
||||||
|
config: normalizeStringList(requiresRaw.config),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSkillKey(skill: Skill, entry?: SkillEntry): string {
|
||||||
|
return entry?.clawdis?.skillKey ?? skill.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIncludeSkill(params: {
|
||||||
|
entry: SkillEntry;
|
||||||
|
config?: ClawdisConfig;
|
||||||
|
}): boolean {
|
||||||
|
const { entry, config } = params;
|
||||||
|
const skillKey = resolveSkillKey(entry.skill, entry);
|
||||||
|
const skillConfig = resolveSkillConfig(config, skillKey);
|
||||||
|
|
||||||
|
if (skillConfig?.enabled === false) return false;
|
||||||
|
if (entry.clawdis?.always === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredBins = entry.clawdis?.requires?.bins ?? [];
|
||||||
|
if (requiredBins.length > 0) {
|
||||||
|
for (const bin of requiredBins) {
|
||||||
|
if (!hasBinary(bin)) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredEnv = entry.clawdis?.requires?.env ?? [];
|
||||||
|
if (requiredEnv.length > 0) {
|
||||||
|
for (const envName of requiredEnv) {
|
||||||
|
if (process.env[envName]) continue;
|
||||||
|
if (skillConfig?.env?.[envName]) continue;
|
||||||
|
if (
|
||||||
|
skillConfig?.apiKey &&
|
||||||
|
entry.clawdis?.primaryEnv === envName
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredConfig = entry.clawdis?.requires?.config ?? [];
|
||||||
|
if (requiredConfig.length > 0) {
|
||||||
|
for (const configPath of requiredConfig) {
|
||||||
|
if (!isConfigPathTruthy(config, configPath)) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSkillEntries(
|
||||||
|
entries: SkillEntry[],
|
||||||
|
config?: ClawdisConfig,
|
||||||
|
): SkillEntry[] {
|
||||||
|
return entries.filter((entry) => shouldIncludeSkill({ entry, config }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySkillEnvOverrides(params: {
|
||||||
|
skills: SkillEntry[];
|
||||||
|
config?: ClawdisConfig;
|
||||||
|
}) {
|
||||||
|
const { skills, config } = params;
|
||||||
|
const updates: Array<{ key: string; prev: string | undefined }> = [];
|
||||||
|
|
||||||
|
for (const entry of skills) {
|
||||||
|
const skillKey = resolveSkillKey(entry.skill, entry);
|
||||||
|
const skillConfig = resolveSkillConfig(config, skillKey);
|
||||||
|
if (!skillConfig) continue;
|
||||||
|
|
||||||
|
if (skillConfig.env) {
|
||||||
|
for (const [envKey, envValue] of Object.entries(skillConfig.env)) {
|
||||||
|
if (!envValue || process.env[envKey]) continue;
|
||||||
|
updates.push({ key: envKey, prev: process.env[envKey] });
|
||||||
|
process.env[envKey] = envValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryEnv = entry.clawdis?.primaryEnv;
|
||||||
|
if (primaryEnv && skillConfig.apiKey && !process.env[primaryEnv]) {
|
||||||
|
updates.push({ key: primaryEnv, prev: process.env[primaryEnv] });
|
||||||
|
process.env[primaryEnv] = skillConfig.apiKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const update of updates) {
|
||||||
|
if (update.prev === undefined) delete process.env[update.key];
|
||||||
|
else process.env[update.key] = update.prev;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySkillEnvOverridesFromSnapshot(params: {
|
||||||
|
snapshot?: SkillSnapshot;
|
||||||
|
config?: ClawdisConfig;
|
||||||
|
}) {
|
||||||
|
const { snapshot, config } = params;
|
||||||
|
if (!snapshot) return () => {};
|
||||||
|
const updates: Array<{ key: string; prev: string | undefined }> = [];
|
||||||
|
|
||||||
|
for (const skill of snapshot.skills) {
|
||||||
|
const skillConfig = resolveSkillConfig(config, skill.name);
|
||||||
|
if (!skillConfig) continue;
|
||||||
|
|
||||||
|
if (skillConfig.env) {
|
||||||
|
for (const [envKey, envValue] of Object.entries(skillConfig.env)) {
|
||||||
|
if (!envValue || process.env[envKey]) continue;
|
||||||
|
updates.push({ key: envKey, prev: process.env[envKey] });
|
||||||
|
process.env[envKey] = envValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skill.primaryEnv && skillConfig.apiKey && !process.env[skill.primaryEnv]) {
|
||||||
|
updates.push({ key: skill.primaryEnv, prev: process.env[skill.primaryEnv] });
|
||||||
|
process.env[skill.primaryEnv] = skillConfig.apiKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const update of updates) {
|
||||||
|
if (update.prev === undefined) delete process.env[update.key];
|
||||||
|
else process.env[update.key] = update.prev;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSkillEntries(
|
||||||
|
workspaceDir: string,
|
||||||
|
opts?: {
|
||||||
|
config?: ClawdisConfig;
|
||||||
|
managedSkillsDir?: string;
|
||||||
|
},
|
||||||
|
): SkillEntry[] {
|
||||||
|
const managedSkillsDir =
|
||||||
|
opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
|
||||||
|
const workspaceSkillsDir = path.join(workspaceDir, "skills");
|
||||||
|
|
||||||
|
const managedSkills = loadSkillsFromDir({
|
||||||
|
dir: managedSkillsDir,
|
||||||
|
source: "clawdis-managed",
|
||||||
|
}).skills;
|
||||||
|
const workspaceSkills = loadSkillsFromDir({
|
||||||
|
dir: workspaceSkillsDir,
|
||||||
|
source: "clawdis-workspace",
|
||||||
|
}).skills;
|
||||||
|
|
||||||
|
const merged = new Map<string, Skill>();
|
||||||
|
for (const skill of managedSkills) merged.set(skill.name, skill);
|
||||||
|
for (const skill of workspaceSkills) merged.set(skill.name, skill);
|
||||||
|
|
||||||
|
const skillEntries: SkillEntry[] = Array.from(merged.values()).map((skill) => {
|
||||||
|
let frontmatter: SkillFrontmatter = {};
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(skill.filePath, "utf-8");
|
||||||
|
frontmatter = parseFrontmatter(raw);
|
||||||
|
} catch {
|
||||||
|
// ignore malformed skills
|
||||||
|
}
|
||||||
|
return { skill, frontmatter, clawdis: resolveClawdisMetadata(frontmatter) };
|
||||||
|
});
|
||||||
|
return skillEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkspaceSkillSnapshot(
|
||||||
|
workspaceDir: string,
|
||||||
|
opts?: {
|
||||||
|
config?: ClawdisConfig;
|
||||||
|
managedSkillsDir?: string;
|
||||||
|
entries?: SkillEntry[];
|
||||||
|
},
|
||||||
|
): SkillSnapshot {
|
||||||
|
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
|
||||||
|
const eligible = filterSkillEntries(skillEntries, opts?.config);
|
||||||
|
return {
|
||||||
|
prompt: formatSkillsForPrompt(eligible.map((entry) => entry.skill)),
|
||||||
|
skills: eligible.map((entry) => ({
|
||||||
|
name: entry.skill.name,
|
||||||
|
primaryEnv: entry.clawdis?.primaryEnv,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkspaceSkillsPrompt(
|
||||||
|
workspaceDir: string,
|
||||||
|
opts?: {
|
||||||
|
config?: ClawdisConfig;
|
||||||
|
managedSkillsDir?: string;
|
||||||
|
entries?: SkillEntry[];
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
|
||||||
|
const eligible = filterSkillEntries(skillEntries, opts?.config);
|
||||||
|
return formatSkillsForPrompt(eligible.map((entry) => entry.skill));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadWorkspaceSkillEntries(
|
||||||
|
workspaceDir: string,
|
||||||
|
opts?: {
|
||||||
|
config?: ClawdisConfig;
|
||||||
|
managedSkillsDir?: string;
|
||||||
|
},
|
||||||
|
): SkillEntry[] {
|
||||||
|
return loadSkillEntries(workspaceDir, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterWorkspaceSkillEntries(
|
||||||
|
entries: SkillEntry[],
|
||||||
|
config?: ClawdisConfig,
|
||||||
|
): SkillEntry[] {
|
||||||
|
return filterSkillEntries(entries, config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||||
ensureAgentWorkspace,
|
ensureAgentWorkspace,
|
||||||
} from "../agents/workspace.js";
|
} from "../agents/workspace.js";
|
||||||
|
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||||
import { type ClawdisConfig, loadConfig } from "../config/config.js";
|
import { type ClawdisConfig, loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
@@ -659,17 +660,48 @@ export async function getReplyFromConfig(
|
|||||||
sessionId: sessionId ?? crypto.randomUUID(),
|
sessionId: sessionId ?? crypto.randomUUID(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
const skillSnapshot =
|
||||||
|
isFirstTurnInSession || !current.skillsSnapshot
|
||||||
|
? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg })
|
||||||
|
: current.skillsSnapshot;
|
||||||
sessionEntry = {
|
sessionEntry = {
|
||||||
...current,
|
...current,
|
||||||
sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(),
|
sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
systemSent: true,
|
systemSent: true,
|
||||||
|
skillsSnapshot: skillSnapshot,
|
||||||
};
|
};
|
||||||
sessionStore[sessionKey] = sessionEntry;
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
await saveSessionStore(storePath, sessionStore);
|
await saveSessionStore(storePath, sessionStore);
|
||||||
systemSent = true;
|
systemSent = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const skillsSnapshot =
|
||||||
|
sessionEntry?.skillsSnapshot ??
|
||||||
|
(isFirstTurnInSession
|
||||||
|
? undefined
|
||||||
|
: buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg }));
|
||||||
|
if (
|
||||||
|
skillsSnapshot &&
|
||||||
|
sessionStore &&
|
||||||
|
sessionKey &&
|
||||||
|
!isFirstTurnInSession &&
|
||||||
|
!sessionEntry?.skillsSnapshot
|
||||||
|
) {
|
||||||
|
const current = sessionEntry ?? {
|
||||||
|
sessionId: sessionId ?? crypto.randomUUID(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
sessionEntry = {
|
||||||
|
...current,
|
||||||
|
sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
skillsSnapshot,
|
||||||
|
};
|
||||||
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
|
await saveSessionStore(storePath, sessionStore);
|
||||||
|
}
|
||||||
|
|
||||||
const prefixedBody = transcribedText
|
const prefixedBody = transcribedText
|
||||||
? [prefixedBodyBase, `Transcript:\n${transcribedText}`]
|
? [prefixedBodyBase, `Transcript:\n${transcribedText}`]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -709,6 +741,8 @@ export async function getReplyFromConfig(
|
|||||||
sessionId: sessionIdFinal,
|
sessionId: sessionIdFinal,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
|
config: cfg,
|
||||||
|
skillsSnapshot,
|
||||||
prompt: commandBody,
|
prompt: commandBody,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||||
ensureAgentWorkspace,
|
ensureAgentWorkspace,
|
||||||
} from "../agents/workspace.js";
|
} from "../agents/workspace.js";
|
||||||
|
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||||
import { chunkText } from "../auto-reply/chunk.js";
|
import { chunkText } from "../auto-reply/chunk.js";
|
||||||
import type { MsgContext } from "../auto-reply/templating.js";
|
import type { MsgContext } from "../auto-reply/templating.js";
|
||||||
import {
|
import {
|
||||||
@@ -205,10 +206,31 @@ export async function agentCommand(
|
|||||||
persistedVerbose ??
|
persistedVerbose ??
|
||||||
(agentCfg?.verboseDefault as VerboseLevel | undefined);
|
(agentCfg?.verboseDefault as VerboseLevel | undefined);
|
||||||
|
|
||||||
|
const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot;
|
||||||
|
const skillsSnapshot = needsSkillsSnapshot
|
||||||
|
? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg })
|
||||||
|
: sessionEntry?.skillsSnapshot;
|
||||||
|
|
||||||
|
if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) {
|
||||||
|
const current = sessionEntry ?? {
|
||||||
|
sessionId,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const next: SessionEntry = {
|
||||||
|
...current,
|
||||||
|
sessionId,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
skillsSnapshot,
|
||||||
|
};
|
||||||
|
sessionStore[sessionKey] = next;
|
||||||
|
await saveSessionStore(storePath, sessionStore);
|
||||||
|
sessionEntry = next;
|
||||||
|
}
|
||||||
|
|
||||||
// Persist explicit /command overrides to the session store when we have a key.
|
// Persist explicit /command overrides to the session store when we have a key.
|
||||||
if (sessionStore && sessionKey) {
|
if (sessionStore && sessionKey) {
|
||||||
const entry = sessionEntry ??
|
const entry =
|
||||||
sessionStore[sessionKey] ?? { sessionId, updatedAt: Date.now() };
|
sessionStore[sessionKey] ?? sessionEntry ?? { sessionId, updatedAt: Date.now() };
|
||||||
const next: SessionEntry = { ...entry, sessionId, updatedAt: Date.now() };
|
const next: SessionEntry = { ...entry, sessionId, updatedAt: Date.now() };
|
||||||
if (thinkOverride) {
|
if (thinkOverride) {
|
||||||
if (thinkOverride === "off") delete next.thinkingLevel;
|
if (thinkOverride === "off") delete next.thinkingLevel;
|
||||||
@@ -245,6 +267,8 @@ export async function agentCommand(
|
|||||||
sessionId,
|
sessionId,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
|
config: cfg,
|
||||||
|
skillsSnapshot,
|
||||||
prompt: body,
|
prompt: body,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ export type GatewayConfig = {
|
|||||||
controlUi?: GatewayControlUiConfig;
|
controlUi?: GatewayControlUiConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SkillConfig = {
|
||||||
|
enabled?: boolean;
|
||||||
|
apiKey?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export type ClawdisConfig = {
|
export type ClawdisConfig = {
|
||||||
identity?: {
|
identity?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -168,6 +175,7 @@ export type ClawdisConfig = {
|
|||||||
discovery?: DiscoveryConfig;
|
discovery?: DiscoveryConfig;
|
||||||
canvasHost?: CanvasHostConfig;
|
canvasHost?: CanvasHostConfig;
|
||||||
gateway?: GatewayConfig;
|
gateway?: GatewayConfig;
|
||||||
|
skills?: Record<string, SkillConfig>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// New branding path (preferred)
|
// New branding path (preferred)
|
||||||
@@ -349,6 +357,17 @@ const ClawdisSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
skills: z
|
||||||
|
.record(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
env: z.record(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ConfigValidationIssue = {
|
export type ConfigValidationIssue = {
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ export type SessionEntry = {
|
|||||||
lastTo?: string;
|
lastTo?: string;
|
||||||
// Optional flag to mirror Mac app UI and future sync states.
|
// Optional flag to mirror Mac app UI and future sync states.
|
||||||
syncing?: boolean | string;
|
syncing?: boolean | string;
|
||||||
|
skillsSnapshot?: SessionSkillSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionSkillSnapshot = {
|
||||||
|
prompt: string;
|
||||||
|
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveSessionTranscriptsDir(): string {
|
export function resolveSessionTranscriptsDir(): string {
|
||||||
@@ -125,6 +131,7 @@ export async function updateLastRoute(params: {
|
|||||||
model: existing?.model,
|
model: existing?.model,
|
||||||
contextTokens: existing?.contextTokens,
|
contextTokens: existing?.contextTokens,
|
||||||
syncing: existing?.syncing,
|
syncing: existing?.syncing,
|
||||||
|
skillsSnapshot: existing?.skillsSnapshot,
|
||||||
lastChannel: channel,
|
lastChannel: channel,
|
||||||
lastTo: to?.trim() ? to.trim() : undefined,
|
lastTo: to?.trim() ? to.trim() : undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||||
ensureAgentWorkspace,
|
ensureAgentWorkspace,
|
||||||
} from "../agents/workspace.js";
|
} from "../agents/workspace.js";
|
||||||
|
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||||
import { chunkText } from "../auto-reply/chunk.js";
|
import { chunkText } from "../auto-reply/chunk.js";
|
||||||
import { normalizeThinkLevel } from "../auto-reply/thinking.js";
|
import { normalizeThinkLevel } from "../auto-reply/thinking.js";
|
||||||
import type { CliDeps } from "../cli/deps.js";
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
@@ -204,6 +205,21 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
|
|
||||||
const commandBody = base;
|
const commandBody = base;
|
||||||
|
|
||||||
|
const needsSkillsSnapshot =
|
||||||
|
cronSession.isNewSession || !cronSession.sessionEntry.skillsSnapshot;
|
||||||
|
const skillsSnapshot = needsSkillsSnapshot
|
||||||
|
? buildWorkspaceSkillSnapshot(workspaceDir, { config: params.cfg })
|
||||||
|
: cronSession.sessionEntry.skillsSnapshot;
|
||||||
|
if (needsSkillsSnapshot && skillsSnapshot) {
|
||||||
|
cronSession.sessionEntry = {
|
||||||
|
...cronSession.sessionEntry,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
skillsSnapshot,
|
||||||
|
};
|
||||||
|
cronSession.store[params.sessionKey] = cronSession.sessionEntry;
|
||||||
|
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||||
|
}
|
||||||
|
|
||||||
// Persist systemSent before the run, mirroring the inbound auto-reply behavior.
|
// Persist systemSent before the run, mirroring the inbound auto-reply behavior.
|
||||||
if (isFirstTurnInSession) {
|
if (isFirstTurnInSession) {
|
||||||
cronSession.sessionEntry.systemSent = true;
|
cronSession.sessionEntry.systemSent = true;
|
||||||
@@ -223,6 +239,8 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
sessionId: cronSession.sessionEntry.sessionId,
|
sessionId: cronSession.sessionEntry.sessionId,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
|
config: params.cfg,
|
||||||
|
skillsSnapshot,
|
||||||
prompt: commandBody,
|
prompt: commandBody,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
|
|||||||
@@ -3857,6 +3857,7 @@ export async function startGatewayServer(
|
|||||||
thinkingLevel: entry?.thinkingLevel,
|
thinkingLevel: entry?.thinkingLevel,
|
||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
systemSent: entry?.systemSent,
|
systemSent: entry?.systemSent,
|
||||||
|
skillsSnapshot: entry?.skillsSnapshot,
|
||||||
lastChannel: entry?.lastChannel,
|
lastChannel: entry?.lastChannel,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user