Initial commit: OpenClaw Skill Collection

6 custom skills (assign-task, dispatch-webhook, daily-briefing,
task-capture, qmd-brain, tts-voice) with technical documentation.
Compatible with Claude Code, OpenClaw, Codex CLI, and OpenCode.
This commit is contained in:
2026-03-13 10:58:30 +08:00
commit 4c966a3ad2
884 changed files with 140761 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
---
name: assign-task
description: 分析使用者任務,判斷類型,分派給對應的 VPSClaude Code 或 OpenCode並追蹤執行狀態。
triggers:
- "幫我開發"
- "建立專案"
- "寫程式"
- "實作功能"
- "分派任務"
- "assign task"
- "新任務"
tools:
- web_fetch
- exec
- memory
---
# Assign Task Skill
## 功能說明
分析使用者描述的任務,自動判斷應分派給哪個 VPS呼叫 `dispatch-webhook` 執行,並回報狀態。
## 分派規則
| 任務類型 | 目標 | 說明 |
|---------|------|------|
| 主要專案開發 | VPS-A (Claude Code) | 核心業務邏輯、架構設計 |
| 其他專案 / 實驗性功能 | VPS-B (OpenCode) | 輔助工具、腳本、研究 |
| 文件撰寫 | VPS-B | README、技術文件 |
| Code Review | VPS-A | 需要 Claude Opus 審查 |
## 執行流程
```
1. 解析使用者意圖(任務類型、優先級、專案名稱)
2. 判斷分派目標VPS-A or VPS-B
3. 呼叫 dispatch-webhook skill 發送任務
4. 等待回應async 模式下記錄 task_id
5. 回報使用者:「✅ 任務已分派給 VPS-Atask_id: xxx」
```
## 輸入格式
使用者自然語言描述,例如:
- 「幫我開發一個 Python API 來處理 webhook」
- 「建立一個 React 前端,連接現有的後端 API」
## 輸出格式
```
✅ 任務已分派
📋 任務摘要:[任務描述摘要]
🎯 分派目標VPS-A / VPS-B
🤖 執行模型Claude Code Opus / OpenCode Codex
🆔 Task ID[uuid]
⏱️ 預計完成:[估計時間]
執行中,完成後會通知你。
```
## 設定(環境變數)
`~/.openclaw/workspace/.env` 或 openclaw.json env 區塊設定:
```
VPS_A_WEBHOOK_URL=https://vps-a.example.com/webhook/openclaw
VPS_A_WEBHOOK_TOKEN=<shared-secret>
VPS_B_WEBHOOK_URL=https://vps-b.example.com/webhook/openclaw
VPS_B_WEBHOOK_TOKEN=<shared-secret>
CALLBACK_BASE_URL=https://oclaw.nature.edu.kg/webhook/callback
```

View File

@@ -0,0 +1,94 @@
/**
* assign-task skill
* 分析任務,判斷目標 VPS呼叫 dispatch-webhook 執行
*/
import { randomUUID } from 'crypto';
// 任務分派規則
const DISPATCH_RULES = [
{
target: 'vps-a',
label: 'VPS-A (Claude Code)',
model: 'Claude Code Opus',
keywords: ['主要專案', '核心功能', '架構', 'code review', 'review', '審查', 'API', '後端', 'backend'],
},
{
target: 'vps-b',
label: 'VPS-B (OpenCode)',
model: 'OpenCode Codex',
keywords: ['腳本', 'script', '工具', 'tool', '文件', 'doc', 'readme', '實驗', '前端', 'frontend', 'react', 'vue'],
},
];
function determineTarget(description: string): typeof DISPATCH_RULES[0] {
const lower = description.toLowerCase();
for (const rule of DISPATCH_RULES) {
if (rule.keywords.some(k => lower.includes(k.toLowerCase()))) {
return rule;
}
}
// 預設分派到 VPS-A
return DISPATCH_RULES[0];
}
function determinePriority(description: string): string {
const lower = description.toLowerCase();
if (lower.includes('緊急') || lower.includes('urgent') || lower.includes('asap')) return 'high';
if (lower.includes('之後') || lower.includes('有空') || lower.includes('低優先')) return 'low';
return 'normal';
}
export async function handler(ctx: any) {
const { message, env, callSkill } = ctx;
const description = message.text || message.content || '';
if (!description) {
return { reply: '❌ 請描述你想要執行的任務。' };
}
const taskId = randomUUID();
const target = determineTarget(description);
const priority = determinePriority(description);
// 建立任務 payload
const task = {
task_id: taskId,
type: 'project_development',
description: description.slice(0, 2000),
priority,
target: target.target,
callback_url: `${env.CALLBACK_BASE_URL || 'https://oclaw.nature.edu.kg/webhook/callback'}`,
created_at: new Date().toISOString(),
};
// 呼叫 dispatch-webhook skill
let dispatchResult;
try {
dispatchResult = await callSkill('dispatch-webhook', {
target: target.target,
payload: task,
webhookUrl: target.target === 'vps-a' ? env.VPS_A_WEBHOOK_URL : env.VPS_B_WEBHOOK_URL,
webhookToken: target.target === 'vps-a' ? env.VPS_A_WEBHOOK_TOKEN : env.VPS_B_WEBHOOK_TOKEN,
});
} catch (err: any) {
return {
reply: `❌ Webhook 發送失敗:${err.message}\n\nTask ID: \`${taskId}\`(可稍後重試)`,
};
}
const priorityLabel = { high: '🔴 緊急', normal: '🟡 一般', low: '🟢 低優先' }[priority] || priority;
return {
reply: `✅ 任務已分派
📋 **任務摘要**${description.slice(0, 100)}${description.length > 100 ? '...' : ''}
🎯 **分派目標**${target.label}
🤖 **執行模型**${target.model}
⚡ **優先級**${priorityLabel}
🆔 **Task ID**\`${taskId}\`
執行中,完成後會通知你。`,
metadata: { taskId, target: target.target, priority },
};
}

View File

@@ -0,0 +1,85 @@
---
name: daily-briefing
description: 每日早安簡報:整合今日行程、天氣、待辦事項,以繁體中文發送 Telegram 摘要。
triggers:
- "今天行程"
- "早安簡報"
- "今日摘要"
- "daily briefing"
tools:
- web_fetch
- web_search
- exec
- memory
---
# Daily Briefing Skill
## 功能說明
每日自動(或手動觸發)生成早安簡報,包含:
1. 🌤️ 今日天氣(台灣地區)
2. 📅 今日行程(來自 workspace/SCHEDULE.md 或 Google Calendar
3. ✅ 待辦事項(來自 workspace/TODO.md
4. 💡 今日重點提醒
5. 📊 昨日工作回顧(選配)
## 設定
`workspace/USER.md` 設定:
```markdown
## 個人設定
- 城市:台北
- 時區Asia/Taipei
- 簡報語言:繁體中文
- 天氣 API Key<openweathermap-api-key>(選配)
```
## 輸出格式範例
```
☀️ **早安2026-02-20 週五**
🌤️ **今日天氣(台北)**
氣溫 16-22°C多雲偶晴東北風 2-3 級
穿著建議:可帶薄外套
📅 **今日行程**
• 09:00 - 週會(視訊)
• 14:00 - 客戶簡報
• 16:30 - Code Review
✅ **待辦事項3 項)**
• [ ] 完成 API 文件
• [ ] 回覆客戶 email
• [ ] 更新 deploy 腳本
💡 **今日提醒**
• SSL 憑證 90 天後到期2026-05-20
• 本週 sprint 截止日2026-02-21
有什麼想先處理的嗎?
```
## Cron 設定
```bash
# 每日 08:00 自動觸發
sudo openclaw cron add \
--name "daily-briefing" \
--cron "0 8 * * *" \
--timezone "Asia/Taipei" \
--session main \
--system-event "請執行 daily-briefing skill生成今日早安簡報並發送到 Telegram"
```
## 擴充Google Calendar 整合
若要連接 Google Calendar在 workspace/TOOLS.md 記錄:
```
Google Calendar API:
- Service Account: <path-to-credentials.json>
- Calendar ID: primary
```
然後 agent 可透過 Google Calendar API 抓取今日事件。

View File

@@ -0,0 +1,125 @@
/**
* daily-briefing skill
* 生成每日早安簡報
*/
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
function getWeekday(date: Date): string {
const days = ['日', '一', '二', '三', '四', '五', '六'];
return `${days[date.getDay()]}`;
}
function formatDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
async function fetchWeather(city: string, apiKey?: string): Promise<string> {
if (!apiKey) {
// 無 API key使用 web_search 取得天氣資訊
return '(天氣資訊需設定 OpenWeatherMap API Key或由 agent 透過 web_search 查詢)';
}
try {
const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${apiKey}&units=metric&lang=zh_tw`;
const res = await fetch(url);
const data = await res.json() as any;
if (data.main) {
const temp = `${Math.round(data.main.temp_min)}-${Math.round(data.main.temp_max)}°C`;
const desc = data.weather?.[0]?.description || '';
return `氣溫 ${temp}${desc}`;
}
} catch {
// ignore
}
return '天氣資訊暫時無法取得';
}
function readWorkspaceFile(workspace: string, filename: string): string {
const path = join(workspace, filename);
if (existsSync(path)) {
return readFileSync(path, 'utf-8');
}
return '';
}
function parseTodos(todoContent: string): string[] {
if (!todoContent) return [];
return todoContent
.split('\n')
.filter(line => line.match(/^[-*]\s*\[\s*\]/))
.map(line => line.replace(/^[-*]\s*/, ''))
.slice(0, 10);
}
function parseTodaySchedule(scheduleContent: string, dateStr: string): string[] {
if (!scheduleContent) return [];
const lines = scheduleContent.split('\n');
const results: string[] = [];
let inToday = false;
for (const line of lines) {
if (line.includes(dateStr)) { inToday = true; continue; }
if (inToday && line.match(/^#{1,3}\s/)) break;
if (inToday && line.trim() && line.match(/^\d{2}:\d{2}/)) {
results.push(line.trim());
}
}
return results;
}
export async function handler(ctx: any) {
const workspace = ctx.env?.OPENCLAW_WORKSPACE || process.env.HOME + '/.openclaw/workspace';
const now = new Date();
const dateStr = formatDate(now);
const weekday = getWeekday(now);
// 讀取設定
const userMd = readWorkspaceFile(workspace, 'USER.md');
const cityMatch = userMd.match(/城市[:]\s*(.+)/);
const city = cityMatch?.[1]?.trim() || '台北';
const apiKeyMatch = userMd.match(/天氣 API Key[:]\s*(.+)/);
const weatherApiKey = apiKeyMatch?.[1]?.trim();
// 讀取待辦與行程
const todoContent = readWorkspaceFile(workspace, 'TODO.md');
const scheduleContent = readWorkspaceFile(workspace, 'SCHEDULE.md');
const memoryContent = readWorkspaceFile(workspace, `memory/${dateStr}.md`);
const todos = parseTodos(todoContent);
const schedule = parseTodaySchedule(scheduleContent, dateStr);
const weatherInfo = await fetchWeather(city, weatherApiKey);
// 組裝簡報
const sections: string[] = [];
sections.push(`☀️ **早安!${dateStr} ${weekday}**\n`);
sections.push(`🌤️ **今日天氣(${city}**\n${weatherInfo}`);
if (schedule.length > 0) {
sections.push(`📅 **今日行程**\n${schedule.map(s => `${s}`).join('\n')}`);
} else {
sections.push(`📅 **今日行程**\n• 暫無排程`);
}
if (todos.length > 0) {
sections.push(`✅ **待辦事項(${todos.length} 項)**\n${todos.map(t => `${t}`).join('\n')}`);
} else {
sections.push(`✅ **待辦事項**\n• 今日無待辦,保持輕鬆!`);
}
if (memoryContent) {
sections.push(`📝 **昨日記錄**\n${memoryContent.slice(0, 200)}...`);
}
sections.push(`\n有什麼想先處理的嗎`);
return {
reply: sections.join('\n\n'),
metadata: { date: dateStr, city, todoCount: todos.length, scheduleCount: schedule.length },
};
}

View File

@@ -0,0 +1,114 @@
---
name: dispatch-webhook
description: 發送任務 Payload 到指定 VPS 的 Webhook endpoint支援同步等待與非同步模式。
triggers: []
tools:
- web_fetch
- exec
internal: true
---
# Dispatch Webhook Skill
## 功能說明
底層 Webhook 發送工具,由 `assign-task` 呼叫。
負責發送任務到 VPS-A 或 VPS-B處理認證與錯誤重試。
## Webhook 協議
### 請求格式OpenClaw → VPS
```http
POST <VPS_WEBHOOK_URL>
Authorization: Bearer <token>
Content-Type: application/json
X-OpenClaw-Version: 1.0
{
"task_id": "uuid",
"type": "project_development",
"description": "",
"priority": "high|normal|low",
"callback_url": "https://oclaw.nature.edu.kg/webhook/callback",
"created_at": "ISO8601"
}
```
### 即時回應格式VPS → OpenClawHTTP 202
```json
{
"task_id": "uuid",
"status": "accepted",
"message": "任務已接受,開始執行"
}
```
### Callback 格式任務完成後VPS → OpenClaw
```http
POST <callback_url>
Authorization: Bearer <callback_token>
Content-Type: application/json
{
"task_id": "uuid",
"status": "completed|failed",
"summary": "",
"artifacts": ["file1.py", "docs/README.md"],
"error": null
}
```
## VPS 端接收端實作範例
### Node.js / Express
```javascript
app.post('/webhook/openclaw', (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (token !== process.env.OPENCLAW_WEBHOOK_TOKEN) {
return res.status(401).json({ error: 'Unauthorized' });
}
const task = req.body;
res.status(202).json({ task_id: task.task_id, status: 'accepted' });
// 非同步執行任務
processTask(task).then(result => {
// 回傳結果給 OpenClaw
fetch(task.callback_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${CALLBACK_TOKEN}` },
body: JSON.stringify({ task_id: task.task_id, status: 'completed', ...result }),
});
});
});
```
### 給 Claude Code 的提示詞VPS-A
任務接收後PM agent 用這個提示詞啟動工作:
```
你是專案經理,收到來自 OpenClaw 的任務指派。
Task ID: {task_id}
任務描述: {description}
優先級: {priority}
請:
1. 分析需求,制定實作計劃
2. 分派子任務給適合的模型Sonnet 寫程式Haiku 做測試)
3. 整合結果,確保品質
4. 完成後透過 callback_url 回報結果
```
## 錯誤處理
| 錯誤 | 處理方式 |
|------|---------|
| 連線失敗 | 重試 3 次,間隔 5s |
| 401 Unauthorized | 立即失敗,提示設定 token |
| 超時(> 30s | 返回 accepted等待 callback |
| VPS 回傳 500 | 記錄錯誤,通知使用者 |

View File

@@ -0,0 +1,86 @@
/**
* dispatch-webhook skill
* 發送 Webhook 到 VPS處理重試與錯誤
*/
interface DispatchInput {
target: 'vps-a' | 'vps-b';
payload: Record<string, unknown>;
webhookUrl: string;
webhookToken: string;
timeoutMs?: number;
retries?: number;
}
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
export async function handler(ctx: any) {
const input: DispatchInput = ctx.input || ctx.params;
if (!input.webhookUrl) {
throw new Error(`${input.target.toUpperCase()} Webhook URL 未設定。請在環境變數設定 VPS_A_WEBHOOK_URL 或 VPS_B_WEBHOOK_URL`);
}
if (!input.webhookToken) {
throw new Error(`${input.target.toUpperCase()} Webhook Token 未設定`);
}
const timeoutMs = input.timeoutMs ?? 30000;
const maxRetries = input.retries ?? 3;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetchWithTimeout(
input.webhookUrl,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${input.webhookToken}`,
'X-OpenClaw-Version': '1.0',
'X-OpenClaw-Task-Id': String(input.payload.task_id || ''),
},
body: JSON.stringify(input.payload),
},
timeoutMs
);
if (response.status === 401) {
throw new Error('Webhook Token 驗證失敗401請確認 VPS_WEBHOOK_TOKEN 設定正確');
}
if (!response.ok && response.status !== 202) {
const body = await response.text().catch(() => '');
throw new Error(`VPS 回應錯誤 ${response.status}${body.slice(0, 200)}`);
}
const result = await response.json().catch(() => ({ status: 'accepted' }));
return {
success: true,
status: result.status || 'accepted',
task_id: input.payload.task_id,
target: input.target,
attempt,
};
} catch (err: any) {
lastError = err;
if (err.message?.includes('401') || err.message?.includes('Token')) {
break; // 認證錯誤不重試
}
if (attempt < maxRetries) {
await new Promise(r => setTimeout(r, 5000 * attempt)); // 指數退避
}
}
}
throw lastError || new Error('Webhook 發送失敗');
}

78
skills/qmd-brain/SKILL.md Normal file
View File

@@ -0,0 +1,78 @@
---
name: qmd-brain
description: 第二大腦知識庫搜尋與索引。用 qmdBM25+向量混合)搜尋本地 markdown 文件,並透過 PostgreSQL pgvector 進行深度語意搜尋。
triggers:
- "搜尋"
- "查找"
- "找資料"
- "recall"
- "記憶"
- "之前說過"
- "知識庫"
- "找到"
- "我之前"
- "幫我找"
- "查一下"
- "有沒有"
- "搜一下"
- "brain search"
- "qmd"
tools:
- exec
- memory
---
# qmd-brain Skill
## 功能說明
第二大腦Second Brain知識庫搜尋工具整合兩層搜尋
| 層次 | 工具 | 特色 |
|------|------|------|
| Layer 1 | qmd (BM25 全文搜尋) | 快速關鍵字比對,本地 SQLite |
| Layer 2 | embed_to_pg (語意搜尋) | 向量相似度PostgreSQL pgvector |
## 觸發範例
```
使用者:「幫我找關於 nginx 設定的資料」
→ qmd search "nginx 設定" → 返回相關文件段落
使用者:「之前有記過 Telegram bot 的設定嗎?」
→ embed_to_pg search "Telegram bot token 設定" → 語意搜尋
使用者:「更新知識庫索引」
→ qmd embed + embed_to_pg embed
使用者:「查知識庫統計」
→ qmd collection list + embed_to_pg stats
```
## 搜尋策略
1. **關鍵字搜尋**qmd search適合確定的詞彙、指令、設定名稱
2. **語意搜尋**embed_to_pg search適合概念性問題、模糊記憶
3. **混合搜尋**:先用 qmd 快速篩選,再用 pgvector 重排
## 輸出格式
搜尋結果包含:
- 文件來源(檔案路徑)
- 相關段落(前 200 字)
- 相似度分數
## 重要路徑
```
qmd 索引: ~/.cache/qmd/index.sqlite
pgvector DB postgresql://qmd_user@localhost/qmd_brain
embed 腳本: /home/selig/apps/qmd-pg/embed_to_pg.py
qmd collectionsselig-home (/home/selig)
```
## 每日排程
凌晨 02:00 自動執行:
1. `qmd embed`(更新 BM25 + 本地向量索引)
2. `embed_to_pg embed`(更新 PostgreSQL 向量庫)

196
skills/qmd-brain/handler.ts Normal file
View File

@@ -0,0 +1,196 @@
/**
* qmd-brain skill
* 第二大腦知識庫BM25 全文搜尋 + PostgreSQL pgvector 語意搜尋
*
* 依賴:
* - qmd CLI (npm install -g @tobilu/qmd)
* - embed_to_pg.py (Python venv at /home/selig/apps/qmd-pg/)
*/
import { execSync, exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
const QMD_CMD = '/home/selig/.nvm/versions/node/v24.13.1/bin/qmd';
const EMBED_PY = '/home/selig/apps/qmd-pg/venv/bin/python3 /home/selig/apps/qmd-pg/embed_to_pg.py';
const MAX_SEARCH_LEN = 1500; // 回覆中搜尋結果最大字數
interface SearchResult {
source: string;
chunk: number;
text: string;
similarity?: number;
}
interface QmdResult {
path: string;
text?: string;
score?: number;
}
/** 執行 qmd BM25 全文搜尋 */
async function qmdSearch(query: string, topK = 5): Promise<string> {
try {
const { stdout } = await execAsync(
`${QMD_CMD} search ${JSON.stringify(query)} --output markdown --limit ${topK}`,
{ timeout: 15000, env: { ...process.env, HOME: '/home/selig' } }
);
return stdout.trim() || '(無結果)';
} catch (e: any) {
return `qmd search 錯誤: ${e.message?.split('\n')[0]}`;
}
}
/** 執行 PostgreSQL 向量語意搜尋 */
async function pgSearch(query: string, topK = 5): Promise<SearchResult[]> {
try {
const { stdout } = await execAsync(
`${EMBED_PY} search ${JSON.stringify(query)} --top-k ${topK} --json`,
{ timeout: 20000 }
);
return JSON.parse(stdout) as SearchResult[];
} catch (e: any) {
return [];
}
}
/** 格式化 pgvector 搜尋結果 */
function formatPgResults(results: SearchResult[]): string {
if (!results.length) return '(向量庫無相關結果)';
return results.map((r, i) => {
const fname = r.source.split('/').pop() || r.source;
const snippet = r.text.slice(0, 200).replace(/\n/g, ' ');
const score = r.similarity ? `${(r.similarity * 100).toFixed(1)}%` : '';
return `**[${i + 1}] ${fname}** ${score}\n> ${snippet}...`;
}).join('\n\n');
}
/** 觸發向量索引更新 */
async function triggerEmbed(): Promise<string> {
try {
// 背景執行,不等待完成
exec(
`${QMD_CMD} embed 2>&1 >> /tmp/qmd-embed.log & ${EMBED_PY} embed 2>&1 >> /tmp/qmd-embed.log &`,
{ env: { ...process.env, HOME: '/home/selig' } }
);
return '✅ 索引更新已在背景啟動,約需 1-5 分鐘完成。';
} catch (e: any) {
return `❌ 索引啟動失敗: ${e.message}`;
}
}
/** 取得向量庫統計 */
async function getStats(): Promise<string> {
const results: string[] = [];
// qmd collection list
try {
const { stdout } = await execAsync(
`${QMD_CMD} collection list`,
{ timeout: 5000, env: { ...process.env, HOME: '/home/selig' } }
);
results.push(`**qmd Collections:**\n\`\`\`\n${stdout.trim()}\n\`\`\``);
} catch (e: any) {
results.push(`qmd: ${e.message?.split('\n')[0]}`);
}
// pgvector stats
try {
const { stdout } = await execAsync(
`${EMBED_PY} stats`,
{ timeout: 10000 }
);
results.push(`**PostgreSQL pgvector:**\n\`\`\`\n${stdout.trim()}\n\`\`\``);
} catch (e: any) {
results.push(`pgvector: ${e.message?.split('\n')[0]}`);
}
return results.join('\n\n');
}
/** 判斷使用者意圖 */
function detectIntent(message: string): 'search' | 'embed' | 'stats' | 'vsearch' {
const lower = message.toLowerCase();
if (lower.match(/更新|重新索引|rebuild|index|embed|掃描/)) return 'embed';
if (lower.match(/統計|stat|幾個|多少|collection/)) return 'stats';
if (lower.match(/語意|向量|vsearch|概念|類似/)) return 'vsearch';
return 'search';
}
/** 從訊息提取搜尋關鍵字 */
function extractQuery(message: string): string {
return message
.replace(/^(搜尋|查找|找資料|幫我找|查一下|搜一下|找到|有沒有|之前說過|我之前|recall|brain search|qmd)[:]?\s*/i, '')
.replace(/(請|幫我|的資料|的內容|相關|嗎||\?)/g, '')
.trim() || message.trim();
}
// ─── 主 handler ─────────────────────────────────────────────────────────────
export async function handler(ctx: any) {
const message = ctx.message?.text || ctx.message?.content || '';
const intent = detectIntent(message);
const query = extractQuery(message);
// 更新索引
if (intent === 'embed') {
const result = await triggerEmbed();
return { reply: `🧠 **第二大腦索引更新**\n\n${result}` };
}
// 統計
if (intent === 'stats') {
const stats = await getStats();
return { reply: `📊 **知識庫統計**\n\n${stats}` };
}
// 語意搜尋(只用 pgvector
if (intent === 'vsearch') {
if (!query) {
return { reply: '請提供搜尋關鍵字,例如:「語意搜尋 Telegram 機器人設定」' };
}
const results = await pgSearch(query, 5);
const formatted = formatPgResults(results);
return {
reply: `🔍 **語意搜尋**${query}\n\n${formatted}`,
metadata: { query, results_count: results.length, engine: 'pgvector' },
};
}
// 混合搜尋BM25 + 向量)
if (!query) {
return { reply: '請提供搜尋關鍵字,例如:「搜尋 nginx 設定」' };
}
// 並行執行兩種搜尋
const [bm25Result, vectorResults] = await Promise.all([
qmdSearch(query, 3),
pgSearch(query, 3),
]);
const vectorFormatted = formatPgResults(vectorResults);
const hasVector = vectorResults.length > 0 && !vectorFormatted.includes('無相關結果');
let reply = `🧠 **第二大腦搜尋**${query}\n\n`;
reply += `### 全文搜尋 (BM25)\n${bm25Result}`;
if (hasVector) {
reply += `\n\n### 語意搜尋 (向量)\n${vectorFormatted}`;
}
// 截斷過長回覆
if (reply.length > MAX_SEARCH_LEN) {
reply = reply.slice(0, MAX_SEARCH_LEN) + '\n\n...(結果已截斷,輸入「語意搜尋 [關鍵字]」可專注向量搜尋)';
}
return {
reply,
metadata: {
query,
bm25_chars: bm25Result.length,
vector_results: vectorResults.length,
},
};
}

View File

@@ -0,0 +1,64 @@
---
name: task-capture
description: 快速將使用者說的事情記錄到 TODO.md支援優先級、標籤、截止日。
triggers:
- "記住"
- "記一下"
- "待辦"
- "todo"
- "提醒我"
- "別忘了"
- "加到清單"
tools:
- exec
- memory
---
# Task Capture Skill
## 功能說明
快速捕捉待辦事項,記錄到 `workspace/TODO.md`,支援:
- 優先級標記(🔴 緊急 / 🟡 一般 / 🟢 低優先)
- 標籤(#工作 #個人 #專案
- 截止日期(`due: 2026-02-25`
- 自動分類
## 使用方式
```
使用者:「記一下要 review PR #42明天下班前」
Agent 記錄:- [ ] 🟡 review PR #42 due:2026-02-21 #工作
回覆:「✅ 已記錄review PR #42截止明天下班前
```
## TODO.md 格式
```markdown
# TODO
## 🔴 緊急
- [ ] 修復 production 的 nginx 錯誤 due:2026-02-20 #緊急
## 🟡 進行中
- [ ] 完成 CLIProxyAPI 文件 due:2026-02-22 #工作
- [ ] 回覆客戶報告 #工作
## 🟢 稍後
- [ ] 整理 skill 庫 #個人
- [ ] 看 OpenClaw 官方文件 #學習
## ✅ 已完成
- [x] 安裝 OpenClaw ✅ 2026-02-19
- [x] 設定 Telegram Bot ✅ 2026-02-19
```
## 相關指令
```
使用者:「顯示我的待辦」 → 列出 TODO.md 未完成項目
使用者:「完成第一項」 → 將第一項標為 [x]
使用者:「清除已完成」 → 移除 [x] 項目
```

View File

@@ -0,0 +1,123 @@
/**
* task-capture skill
* 快速記錄待辦事項到 TODO.md
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
function getDateStr(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
function detectPriority(text: string): { emoji: string; label: string } {
const lower = text.toLowerCase();
if (lower.includes('緊急') || lower.includes('urgent') || lower.includes('asap') || lower.includes('立刻')) {
return { emoji: '🔴', label: '緊急' };
}
if (lower.includes('之後') || lower.includes('有空') || lower.includes('低優先') || lower.includes('不急')) {
return { emoji: '🟢', label: '低優先' };
}
return { emoji: '🟡', label: '一般' };
}
function detectDueDate(text: string): string | null {
// 明天
if (text.includes('明天')) {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
return `due:${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
}
// 下週
if (text.includes('下週') || text.includes('下周')) {
const next = new Date();
next.setDate(next.getDate() + 7);
return `due:${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, '0')}-${String(next.getDate()).padStart(2, '0')}`;
}
// 明確日期 YYYY-MM-DD
const match = text.match(/(\d{4}-\d{2}-\d{2})/);
if (match) return `due:${match[1]}`;
return null;
}
function detectTags(text: string): string[] {
const tags: string[] = [];
if (text.match(/專案|project|開發|dev|code|PR|review/i)) tags.push('#工作');
if (text.match(/個人|私人|自己|家/)) tags.push('#個人');
if (text.match(/學習|看|讀|研究|study/i)) tags.push('#學習');
// 保留原有 #tag
const existing = text.match(/#[\w\u4e00-\u9fa5]+/g) || [];
tags.push(...existing);
return [...new Set(tags)];
}
function cleanTaskText(text: string): string {
return text
.replace(/^(記住|記一下|待辦|todo|提醒我|別忘了|加到清單)[:]?\s*/i, '')
.replace(/(明天|下週|下周|緊急|urgent|asap)/gi, '')
.replace(/#[\w\u4e00-\u9fa5]+/g, '')
.replace(/\s+/g, ' ')
.trim();
}
export async function handler(ctx: any) {
const workspace = ctx.env?.OPENCLAW_WORKSPACE || process.env.HOME + '/.openclaw/workspace';
const message = ctx.message?.text || ctx.message?.content || '';
const todoPath = join(workspace, 'TODO.md');
if (!message) {
return { reply: '❌ 請告訴我要記錄什麼。' };
}
const taskText = cleanTaskText(message);
if (!taskText) {
return { reply: '❌ 無法識別待辦內容,請再說清楚一點。' };
}
const priority = detectPriority(message);
const dueDate = detectDueDate(message);
const tags = detectTags(message);
// 組裝 TODO 項目
const parts = [`- [ ] ${priority.emoji} ${taskText}`];
if (dueDate) parts.push(dueDate);
if (tags.length > 0) parts.push(tags.join(' '));
const todoLine = parts.join(' ');
// 讀取或建立 TODO.md
let content = '';
if (existsSync(todoPath)) {
content = readFileSync(todoPath, 'utf-8');
} else {
content = `# TODO\n\n## 🔴 緊急\n\n## 🟡 進行中\n\n## 🟢 稍後\n\n## ✅ 已完成\n`;
}
// 插入到對應優先級區段
const sectionMap: Record<string, string> = {
'緊急': '## 🔴 緊急',
'一般': '## 🟡 進行中',
'低優先': '## 🟢 稍後',
};
const section = sectionMap[priority.label];
if (content.includes(section)) {
content = content.replace(section, `${section}\n${todoLine}`);
} else {
content += `\n${todoLine}\n`;
}
writeFileSync(todoPath, content, 'utf-8');
const dueDateDisplay = dueDate ? `(截止:${dueDate.replace('due:', '')}` : '';
return {
reply: `✅ 已記錄
${priority.emoji} **${taskText}**${dueDateDisplay}
${tags.length > 0 ? `標籤:${tags.join(' ')}` : ''}
輸入「顯示我的待辦」查看完整清單。`,
metadata: { taskText, priority: priority.label, dueDate, tags },
};
}

56
skills/tts-voice/SKILL.md Normal file
View File

@@ -0,0 +1,56 @@
---
name: tts-voice
description: 文字轉語音LuxTTS Voice Cloning。將文字合成為 48kHz 高品質語音,支援語音克隆。
triggers:
- "文字轉語音"
- "tts"
- "語音合成"
- "唸出來"
- "說出來"
- "轉語音"
- "voice"
tools:
- exec
---
# tts-voice Skill
## 功能說明
透過本機 LuxTTS 服務port 7860將文字合成為語音。支援
- 預設參考音訊快速合成
- 自訂語速speed、步數steps、溫度t_shift
## 觸發範例
```
使用者「tts 你好,歡迎來到我的頻道」
→ 使用預設參考音訊合成語音,回傳 wav 檔
使用者:「文字轉語音 Hello world」
→ 合成英文語音
使用者:「語音合成 慢速 這是一段測試」
→ speed=0.8 慢速合成
使用者:「唸出來 快速 今天天氣真好」
→ speed=1.3 快速合成
```
## 參數
| 修飾詞 | 效果 |
|--------|------|
| 慢速 / slow | speed=0.8 |
| 快速 / fast | speed=1.3 |
| 高品質 / hq | num_steps=6, t_shift=0.95 |
| (無修飾)| speed=1.0, num_steps=4, t_shift=0.9 |
## 技術細節
- 服務LuxTTSFastAPIport 7860systemd user service
- 認證session cookie自動取得
- 參考音訊:`~/LuxTTS/ref_speech.wav`
- 輸出48kHz wav存放 `/tmp/tts_output_*.wav`
- CPU 模式合成約 15-20 秒

192
skills/tts-voice/handler.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* tts-voice skill
* 文字轉語音:透過本機 LuxTTS API 合成語音
*
* 依賴:
* - LuxTTS server running on port 7860 (systemd user service)
* - ~/LuxTTS/.env (LUXTTS_USER, LUXTTS_PASS)
* - curl CLI
*/
import { execSync } from 'child_process';
import { readFileSync, existsSync, unlinkSync } from 'fs';
const LUXTTS_BASE = 'http://localhost:7860';
const REF_AUDIO = '/home/selig/LuxTTS/ref_speech.wav';
const ENV_PATH = '/home/selig/LuxTTS/.env';
const COOKIE_JAR = '/tmp/luxtts-skill-cookie';
// Trigger keywords to strip from user message
const TRIGGER_WORDS = [
'文字轉語音', '語音合成', '唸出來', '說出來', '轉語音',
'tts', 'voice',
];
// Speed/quality modifiers
const MODIFIERS: Record<string, Partial<TtsParams>> = {
'慢速': { speed: 0.8 },
'slow': { speed: 0.8 },
'快速': { speed: 1.3 },
'fast': { speed: 1.3 },
'高品質': { numSteps: 6, tShift: 0.95 },
'hq': { numSteps: 6, tShift: 0.95 },
};
interface TtsParams {
speed: number;
numSteps: number;
tShift: number;
}
/** Read credentials from .env */
function loadCredentials(): { user: string; pass: string } {
try {
const content = readFileSync(ENV_PATH, 'utf-8');
const user = content.match(/^LUXTTS_USER=(.+)$/m)?.[1]?.trim() || 'admin';
const pass = content.match(/^LUXTTS_PASS=(.+)$/m)?.[1]?.trim() || '';
return { user, pass };
} catch {
return { user: 'admin', pass: '' };
}
}
/** Ensure we have a valid session cookie */
function ensureCookie(): boolean {
const { user, pass } = loadCredentials();
if (!pass) return false;
try {
execSync(
`curl -s -o /dev/null -w "%{http_code}" -c ${COOKIE_JAR} ` +
`-d "username=${user}&password=${pass}" ` +
`${LUXTTS_BASE}/luxtts/login`,
{ timeout: 10000 }
);
return existsSync(COOKIE_JAR);
} catch {
return false;
}
}
/** Check if LuxTTS service is alive */
function healthCheck(): boolean {
try {
const result = execSync(
`curl -s -o /dev/null -w "%{http_code}" ${LUXTTS_BASE}/luxtts/api/health`,
{ timeout: 5000 }
).toString().trim();
return result === '200';
} catch {
return false;
}
}
/** Extract text and modifiers from user message */
function parseMessage(message: string): { text: string; params: TtsParams } {
let cleaned = message;
const params: TtsParams = { speed: 1.0, numSteps: 4, tShift: 0.9 };
// Remove trigger words
for (const trigger of TRIGGER_WORDS) {
const re = new RegExp(trigger, 'gi');
cleaned = cleaned.replace(re, '');
}
// Extract and apply modifiers
for (const [mod, overrides] of Object.entries(MODIFIERS)) {
const re = new RegExp(mod, 'gi');
if (re.test(cleaned)) {
cleaned = cleaned.replace(re, '');
Object.assign(params, overrides);
}
}
// Clean up
cleaned = cleaned.replace(/^[\s:,、]+/, '').replace(/[\s:,、]+$/, '').trim();
return { text: cleaned, params };
}
/** Call LuxTTS API to generate speech */
function generateSpeech(text: string, params: TtsParams): string | null {
const timestamp = Date.now();
const outPath = `/tmp/tts_output_${timestamp}.wav`;
try {
const httpCode = execSync(
`curl -s -o ${outPath} -w "%{http_code}" ` +
`-b ${COOKIE_JAR} ` +
`-X POST ${LUXTTS_BASE}/luxtts/api/tts ` +
`-F "ref_audio=@${REF_AUDIO}" ` +
`-F "text=${text.replace(/"/g, '\\"')}" ` +
`-F "num_steps=${params.numSteps}" ` +
`-F "t_shift=${params.tShift}" ` +
`-F "speed=${params.speed}"`,
{ timeout: 120000 } // 2 min timeout for CPU synthesis
).toString().trim();
if (httpCode === '200' && existsSync(outPath)) {
return outPath;
}
// Clean up failed output
if (existsSync(outPath)) unlinkSync(outPath);
return null;
} catch {
if (existsSync(outPath)) unlinkSync(outPath);
return null;
}
}
// ─── 主 handler ─────────────────────────────────────────────────────────────
export async function handler(ctx: any) {
const message = ctx.message?.text || ctx.message?.content || '';
if (!message.trim()) {
return { reply: '請提供要合成的文字例如「tts 你好,歡迎來到我的頻道」' };
}
// Parse user input
const { text, params } = parseMessage(message);
if (!text) {
return { reply: '請提供要合成的文字例如「tts 你好,歡迎來到我的頻道」' };
}
// Health check
if (!healthCheck()) {
return { reply: '❌ LuxTTS 服務未啟動,請先執行:`systemctl --user start luxtts`' };
}
// Ensure authentication
if (!ensureCookie()) {
return { reply: '❌ LuxTTS 認證失敗,請檢查 ~/LuxTTS/.env 的帳密設定' };
}
// Build parameter description
const paramDesc: string[] = [];
if (params.speed !== 1.0) paramDesc.push(`語速 ${params.speed}x`);
if (params.numSteps !== 4) paramDesc.push(`步數 ${params.numSteps}`);
const paramStr = paramDesc.length ? `${paramDesc.join('、')}` : '';
// Generate
const wavPath = generateSpeech(text, params);
if (!wavPath) {
return {
reply: `❌ 語音合成失敗,請稍後再試。`,
metadata: { text, params, error: true },
};
}
return {
reply: `🔊 語音合成完成${paramStr}\n\n📝 文字:${text}\n📂 檔案:\`${wavPath}\``,
metadata: {
text,
params,
output: wavPath,
},
files: [wavPath],
};
}