diff --git a/packages/openclaw-bridge/src/index.ts b/packages/openclaw-bridge/src/index.ts index b60046d..b3c6ade 100644 --- a/packages/openclaw-bridge/src/index.ts +++ b/packages/openclaw-bridge/src/index.ts @@ -4,12 +4,32 @@ * - Forwards task submissions to OpenClaw via WebSocket RPC * - Returns status, session list, and metrics * - Reports heartbeat to IT0 agent-service + * - Injects SKILL.md files into the container volume (POST /skill-inject) + * + * Skill injection flow: + * iAgent (agent-service) receives user request → calls POST /api/v1/agent/instances/:id/skills + * → agent-service forwards to this bridge's POST /skill-inject + * → bridge writes SKILL.md to ~/.openclaw/skills/{name}/ + * → OpenClaw gateway file watcher picks it up within 250ms + * → bridge deletes the current OpenClaw session (sessions.delete RPC) so the + * next user message opens a fresh session that loads the new skill directory + * + * Skill directory layout (inside the container volume): + * $OPENCLAW_HOME/skills/ + * └── {skill-slug}/ + * └── SKILL.md ← YAML frontmatter (name, description) + markdown body */ import 'dotenv/config'; import express from 'express'; import * as crypto from 'crypto'; +import * as fs from 'fs/promises'; +import * as path from 'path'; import { OpenClawClient } from './openclaw-client'; +// Skills live in the mounted OpenClaw home dir +const OPENCLAW_HOME = process.env.OPENCLAW_HOME ?? '/home/node/.openclaw'; +const SKILLS_DIR = path.join(OPENCLAW_HOME, 'skills'); + const PORT = parseInt(process.env.BRIDGE_PORT ?? '3000', 10); const OPENCLAW_GATEWAY = process.env.OPENCLAW_GATEWAY_URL ?? 'ws://127.0.0.1:18789'; const OPENCLAW_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN ?? ''; @@ -150,6 +170,59 @@ app.post('/task-async', async (req, res) => { }); }); +// Inject a skill into this OpenClaw instance. +// +// Writes SKILL.md to ~/.openclaw/skills/{name}/ so the gateway's file watcher +// picks it up within 250ms. Then optionally deletes the caller's current session +// so the next message starts a fresh session — new sessions pick up newly added +// skill directories immediately, giving the user a seamless experience. +// +// Request body: +// name — skill directory name (slug, e.g. "it0-weather") +// content — full SKILL.md content (markdown with YAML frontmatter) +// sessionKey — (optional) current session key to delete after injection +// +// Response: { ok, injected, sessionCleared } +app.post('/skill-inject', async (req, res) => { + const { name, content, sessionKey } = req.body ?? {}; + if (!name || typeof name !== 'string' || !content || typeof content !== 'string') { + res.status(400).json({ error: 'name and content are required strings' }); + return; + } + // Prevent path traversal + if (name.includes('/') || name.includes('..') || name.includes('\\')) { + res.status(400).json({ error: 'invalid skill name' }); + return; + } + + const skillDir = path.join(SKILLS_DIR, name); + try { + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf8'); + console.log(`[bridge] Skill injected: ${name} → ${skillDir}`); + } catch (err: any) { + console.error(`[bridge] Failed to write skill ${name}:`, err.message); + res.status(500).json({ error: `Failed to write skill: ${err.message}` }); + return; + } + + // Delete the caller's current session so the next message opens a fresh session + // that picks up the newly added skill directory. + let sessionCleared = false; + if (sessionKey && typeof sessionKey === 'string' && ocClient.isConnected()) { + try { + await ocClient.rpc('sessions.delete', { sessionKey }, 5_000); + sessionCleared = true; + console.log(`[bridge] Session cleared after skill inject: ${sessionKey}`); + } catch (err: any) { + // Non-fatal — skill is injected, session will naturally expire or be replaced + console.warn(`[bridge] Could not delete session ${sessionKey}: ${err.message}`); + } + } + + res.json({ ok: true, injected: true, sessionCleared }); +}); + // List sessions app.get('/sessions', async (_req, res) => { if (!ocClient.isConnected()) { diff --git a/packages/services/agent-service/src/agent.module.ts b/packages/services/agent-service/src/agent.module.ts index 948abc4..1c02dde 100644 --- a/packages/services/agent-service/src/agent.module.ts +++ b/packages/services/agent-service/src/agent.module.ts @@ -16,7 +16,6 @@ import { ClaudeApiEngine } from './infrastructure/engines/claude-api/claude-api- import { ClaudeAgentSdkEngine } from './infrastructure/engines/claude-agent-sdk'; import { ToolExecutor } from './infrastructure/engines/claude-api/tool-executor'; import { CommandGuardService } from './infrastructure/guards/command-guard.service'; -import { SkillManagerService } from './domain/services/skill-manager.service'; import { StandingOrderExtractorService } from './domain/services/standing-order-extractor'; import { AllowedToolsResolverService } from './domain/services/allowed-tools-resolver.service'; import { SessionRepository } from './infrastructure/repositories/session.repository'; @@ -80,7 +79,6 @@ import { SystemPromptBuilder } from './infrastructure/engines/claude-code-cli/sy ClaudeAgentSdkEngine, ToolExecutor, CommandGuardService, - SkillManagerService, StandingOrderExtractorService, AllowedToolsResolverService, ConversationContextService, diff --git a/packages/services/agent-service/src/domain/services/skill-manager.service.ts b/packages/services/agent-service/src/domain/services/skill-manager.service.ts deleted file mode 100644 index 6ee68e2..0000000 --- a/packages/services/agent-service/src/domain/services/skill-manager.service.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import * as fs from 'fs'; -import * as path from 'path'; - -export interface Skill { - name: string; - description: string; - promptTemplate: string; - allowedTools?: string[]; - maxRiskLevel?: number; -} - -@Injectable() -export class SkillManagerService { - private readonly logger = new Logger(SkillManagerService.name); - private skills = new Map(); - - constructor(private readonly configService: ConfigService) { - this.loadSkills(); - } - - private loadSkills(): void { - const skillsDir = this.configService.get( - 'SKILLS_DIR', - path.join(process.cwd(), 'skills'), - ); - - if (!fs.existsSync(skillsDir)) { - this.logger.warn(`Skills directory not found: ${skillsDir}`); - return; - } - - const files = fs.readdirSync(skillsDir).filter((f) => f.endsWith('.md')); - - for (const file of files) { - try { - const content = fs.readFileSync(path.join(skillsDir, file), 'utf-8'); - const skill = this.parseSkillFile(content); - if (skill) { - this.skills.set(skill.name, skill); - this.logger.log(`Loaded skill: ${skill.name}`); - } - } catch (err: any) { - this.logger.error(`Failed to load skill from ${file}: ${err.message}`); - } - } - - this.logger.log(`Loaded ${this.skills.size} skill(s)`); - } - - private parseSkillFile(content: string): Skill | null { - const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); - if (!frontMatterMatch) return null; - - const frontMatter = frontMatterMatch[1]; - const promptTemplate = frontMatterMatch[2].trim(); - - const name = this.extractField(frontMatter, 'name'); - const description = this.extractField(frontMatter, 'description'); - - if (!name || !description) return null; - - const allowedToolsRaw = this.extractField(frontMatter, 'allowedTools'); - const allowedTools = allowedToolsRaw - ? allowedToolsRaw.split(',').map((t) => t.trim()) - : undefined; - - const maxRiskLevelRaw = this.extractField(frontMatter, 'maxRiskLevel'); - const maxRiskLevel = maxRiskLevelRaw ? parseInt(maxRiskLevelRaw, 10) : undefined; - - return { name, description, promptTemplate, allowedTools, maxRiskLevel }; - } - - private extractField(frontMatter: string, field: string): string | undefined { - const match = frontMatter.match(new RegExp(`^${field}:\\s*(.+)$`, 'm')); - return match ? match[1].trim() : undefined; - } - - getSkill(name: string): Skill | undefined { - return this.skills.get(name); - } - - listSkills(): Skill[] { - return Array.from(this.skills.values()); - } - - buildSkillPrompt(skillName: string, context: Record): string { - const skill = this.skills.get(skillName); - if (!skill) { - throw new Error(`Skill not found: ${skillName}`); - } - - let prompt = skill.promptTemplate; - for (const [key, value] of Object.entries(context)) { - prompt = prompt.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value); - } - return prompt; - } -} diff --git a/packages/services/agent-service/src/infrastructure/engines/claude-code-cli/system-prompt-builder.ts b/packages/services/agent-service/src/infrastructure/engines/claude-code-cli/system-prompt-builder.ts index e53f8a2..cbafa75 100644 --- a/packages/services/agent-service/src/infrastructure/engines/claude-code-cli/system-prompt-builder.ts +++ b/packages/services/agent-service/src/infrastructure/engines/claude-code-cli/system-prompt-builder.ts @@ -8,7 +8,6 @@ export interface SystemPromptContext { userId?: string; userEmail?: string; serverContext?: string; - skills?: string[]; riskBoundary?: string; additionalInstructions?: string; /** Voice session ID — when set, uses the internal wget-based OAuth trigger endpoint @@ -17,9 +16,16 @@ export interface SystemPromptContext { } /** - * Builds system prompts for Claude CLI sessions. + * Builds system prompts for Claude CLI and voice sessions. * Generates and optionally saves system prompt files that provide * context about the tenant, server environment, and operational boundaries. + * + * Note: tenant skill content (from the `skills` DB table) is NOT injected here. + * The ClaudeAgentSdkEngine handles skill injection directly by querying + * `it0_t_{tenantId}.skills` and appending them to the system prompt at + * task-execution time (see claude-agent-sdk-engine.ts → loadTenantSkills / + * buildSystemPromptWithSkills). This builder is only used for CLI and voice + * sessions where skills are not currently loaded. */ @Injectable() export class SystemPromptBuilder { @@ -95,13 +101,6 @@ export class SystemPromptBuilder { parts.push(`\nServer Environment:\n${context.serverContext}`); } - // Available skills - if (context.skills && context.skills.length > 0) { - parts.push( - `\nAvailable Skills: ${context.skills.join(', ')}`, - ); - } - // Risk boundary if (context.riskBoundary) { parts.push(`\nRisk Boundary:\n${context.riskBoundary}`); diff --git a/packages/services/agent-service/src/interfaces/rest/controllers/agent-instance.controller.ts b/packages/services/agent-service/src/interfaces/rest/controllers/agent-instance.controller.ts index 3f2a005..ddd447f 100644 --- a/packages/services/agent-service/src/interfaces/rest/controllers/agent-instance.controller.ts +++ b/packages/services/agent-service/src/interfaces/rest/controllers/agent-instance.controller.ts @@ -140,6 +140,61 @@ export class AgentInstanceController { return { ok: true }; } + /** + * Inject a skill into a running OpenClaw instance. + * + * iAgent calls this (via internal wget) when a user asks to add a capability + * to their 小龙虾. The bridge writes SKILL.md to the container volume and + * resets the current OpenClaw session so the new skill takes effect immediately. + * + * Body: + * name — skill slug, e.g. "it0-weather" (no slashes) + * content — full SKILL.md content (markdown with YAML frontmatter) + * sessionKey — (optional) active OpenClaw session key to reset after injection + * format: "agent:main:dt-" for DingTalk users + */ + @Post(':id/skills') + async injectSkill( + @Param('id') id: string, + @Body() body: { name: string; content: string; sessionKey?: string }, + ) { + const inst = await this.instanceRepo.findById(id); + if (!inst) throw new NotFoundException(`Instance ${id} not found`); + if (inst.status !== 'running') { + throw new BadRequestException(`Instance ${id} is not running (status: ${inst.status})`); + } + if (!body.name || !body.content) { + throw new BadRequestException('name and content are required'); + } + if (body.content.length > 100_000) { + throw new BadRequestException('content too large (max 100KB)'); + } + if (!inst.serverHost || !inst.hostPort) { + throw new BadRequestException(`Instance ${id} has no bridge address (still deploying?)`); + } + + const bridgeUrl = `http://${inst.serverHost}:${inst.hostPort}`; + const payload = JSON.stringify({ + name: body.name, + content: body.content, + ...(body.sessionKey ? { sessionKey: body.sessionKey } : {}), + }); + + const resp = await fetch(`${bridgeUrl}/skill-inject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + signal: AbortSignal.timeout(10_000), + }); + + if (!resp.ok) { + const err = await resp.text().catch(() => resp.statusText); + throw new BadRequestException(`Bridge rejected skill injection: ${err}`); + } + + return resp.json(); + } + @Delete(':id') async remove(@Param('id') id: string) { const inst = await this.instanceRepo.findById(id);