tools and skills, etc in the containers

This commit is contained in:
2026-02-24 01:24:28 +00:00
parent 071e2decc3
commit d6ffe43a11
16 changed files with 996 additions and 66 deletions
+8 -1
View File
@@ -114,7 +114,14 @@ function buildSchema(inputs: Record<string, ToolParam>): TSchema {
switch (param.type) {
case 'enum': {
const values = param.values ?? [];
// values can be a string[] from deeper YAML parsing,
// or a comma-separated string like "single,batch" from flat YAML
const raw = param.values;
const values = Array.isArray(raw)
? raw
: typeof raw === 'string'
? raw.split(',').map((v) => v.trim())
: [];
schema = Type.Union(values.map((v) => Type.Literal(v)), {
description: param.description,
});
+7 -4
View File
@@ -1,13 +1,15 @@
---
name: Convert To MP3
description: Convert audio files to MP3 320kbps, preserving metadata.
version: 1
version: 2
author: pastilhas
tags:
- audio
- conversion
skills:
- convert-audio-to-mp3
tools:
- convert_audio_to_mp3
trigger:
- type: file
extensions:
@@ -39,6 +41,7 @@ Convert audio files to MP3 320kbps, preserving metadata.
## Steps
1. Determine whether `file_path` points to a single audio file or a directory.
2. If it is a single file, use the Convert Audio To MP3 skill's single-file script to convert it. The source file is deleted on success.
3. If it is a directory, use the Convert Audio To MP3 skill's batch script with the directory name as the artist name. All audio files within subdirectories are converted recursively.
4. Verify the conversion completed successfully and report the result to the user.
2. Call `convert_audio_to_mp3` with the appropriate mode:
- Single file: `mode="single"`, `path=file_path`
- Directory: `mode="batch"`, `path=file_path`
3. Report the result to the user.
+31
View File
@@ -0,0 +1,31 @@
---
name: convert_audio_to_mp3
label: Convert Audio to MP3
description: Convert audio files to MP3 320kbps using ffmpeg, preserving metadata. Supports single file conversion with real-time percentage progress, and batch conversion of an entire artist directory with per-file progress. Use when the user wants to convert FLAC, WAV, OGG, or other audio formats to MP3.
language: typescript
inputs:
mode:
type: enum
values: single,batch
description: "single: convert one file. batch: convert all audio files recursively in a directory"
path:
type: string
description: "single mode: absolute path to the audio file. batch mode: absolute path to the artist directory"
---
# Convert Audio to MP3
Converts audio to MP3 320kbps CBR via libmp3lame, preserving all metadata tags.
## Modes
### single
Converts one file. Reports ffmpeg percentage progress in real time. Deletes source on success.
### batch
Scans a directory recursively for all supported audio files. Reports per-file progress.
- All succeed → deletes all source files
- Any failure → deletes all successfully created MP3s for a clean retry
## Supported formats
flac, wav, ogg, wma, aac, m4a, opus, aiff, aif, ape, wv, alac, dsf, dff
+267
View File
@@ -0,0 +1,267 @@
import { readdirSync, existsSync, unlinkSync, statSync } from 'node:fs';
import { join, extname, basename } from 'node:path';
const AUDIO_EXTS = new Set([
'flac', 'wav', 'ogg', 'wma', 'aac', 'm4a', 'opus',
'aiff', 'aif', 'ape', 'wv', 'alac', 'dsf', 'dff',
]);
type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void;
function update(onUpdate: OnUpdate | undefined, text: string): void {
onUpdate?.({ content: [{ type: 'text', text }] });
}
async function getDuration(filePath: string): Promise<number | null> {
const proc = Bun.spawn(
['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath],
{ stdout: 'pipe', stderr: 'pipe' },
);
const text = await new Response(proc.stdout).text();
await proc.exited;
const n = parseFloat(text.trim());
return isNaN(n) ? null : n;
}
function scanAudioFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...scanAudioFiles(full));
} else if (entry.isFile()) {
const ext = extname(entry.name).slice(1).toLowerCase();
if (AUDIO_EXTS.has(ext)) files.push(full);
}
}
return files;
}
type ConvertResult = {
outputFile: string;
success: boolean;
skipped?: boolean;
error?: string;
};
async function convertFile(
inputFile: string,
duration: number | null,
label: string,
onUpdate: OnUpdate | undefined,
): Promise<ConvertResult> {
const ext = extname(inputFile);
const outputFile = inputFile.slice(0, -ext.length) + '.mp3';
if (existsSync(outputFile)) {
return { outputFile, success: true, skipped: true };
}
const proc = Bun.spawn(
[
'ffmpeg',
'-i', inputFile,
'-progress', 'pipe:1', // progress data → stdout
'-nostats',
'-loglevel', 'error', // only errors → stderr
'-codec:a', 'libmp3lame',
'-b:a', '320k',
outputFile,
'-y',
],
{ stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
);
// Parse -progress output from stdout for real-time percentage
const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
const decoder = new TextDecoder();
let buf = '';
let lastPercent = -1;
(async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop() ?? '';
for (const line of lines) {
const m = line.match(/^out_time_us=(\d+)$/);
if (m && duration) {
const pct = Math.min(100, Math.round((parseInt(m[1]!, 10) / 1_000_000 / duration) * 100));
if (pct >= lastPercent + 5) {
lastPercent = pct;
update(onUpdate, `${label} ${pct}%`);
}
}
}
}
} catch {
// stream closed
}
})();
const stderrText = await new Response(proc.stderr).text();
await proc.exited;
if (proc.exitCode !== 0) {
if (existsSync(outputFile)) unlinkSync(outputFile);
return { outputFile, success: false, error: stderrText.trim() || 'ffmpeg failed' };
}
return { outputFile, success: true };
}
export async function execute(
_toolCallId: string,
params: { mode: 'single' | 'batch'; path: string },
_signal: AbortSignal | undefined,
onUpdate?: OnUpdate,
) {
const { mode, path } = params;
// ── Single file ──────────────────────────────────────────────────────────
if (mode === 'single') {
if (!existsSync(path)) {
return {
content: [{ type: 'text', text: `File not found: ${path}` }],
details: { error: 'file_not_found' },
isError: true,
};
}
const ext = extname(path).slice(1).toLowerCase();
if (!AUDIO_EXTS.has(ext)) {
return {
content: [{ type: 'text', text: `Unsupported format: .${ext}\nSupported: ${[...AUDIO_EXTS].join(', ')}` }],
details: { error: 'unsupported_format' },
isError: true,
};
}
update(onUpdate, `Getting duration of ${basename(path)}...`);
const duration = await getDuration(path);
update(onUpdate, `Converting ${basename(path)}...`);
const result = await convertFile(path, duration, 'Progress:', onUpdate);
if (result.skipped) {
return {
content: [{ type: 'text', text: `Skipped: ${result.outputFile} already exists` }],
details: { skipped: true, outputFile: result.outputFile },
};
}
if (!result.success) {
return {
content: [{ type: 'text', text: `Failed to convert ${basename(path)}:\n${result.error}` }],
details: { error: result.error },
isError: true,
};
}
unlinkSync(path);
return {
content: [{ type: 'text', text: `Done.\nConverted: ${basename(result.outputFile)}\nDeleted source: ${basename(path)}` }],
details: { outputFile: result.outputFile },
};
}
// ── Batch ─────────────────────────────────────────────────────────────────
if (!existsSync(path)) {
return {
content: [{ type: 'text', text: `Directory not found: ${path}` }],
details: { error: 'dir_not_found' },
isError: true,
};
}
if (!statSync(path).isDirectory()) {
return {
content: [{ type: 'text', text: `Not a directory: ${path}\nUse mode="single" for individual files.` }],
details: { error: 'not_a_directory' },
isError: true,
};
}
update(onUpdate, `Scanning ${basename(path)} for audio files...`);
const audioFiles = scanAudioFiles(path);
if (audioFiles.length === 0) {
return {
content: [{ type: 'text', text: `No audio files found in: ${path}` }],
details: { found: 0 },
};
}
update(onUpdate, `Found ${audioFiles.length} audio files. Starting conversion...`);
type BatchResult = ConvertResult & { input: string };
const results: BatchResult[] = [];
for (let i = 0; i < audioFiles.length; i++) {
const inputFile = audioFiles[i]!;
const label = `[${i + 1}/${audioFiles.length}] ${basename(inputFile)}`;
update(onUpdate, `Converting ${label}...`);
const duration = await getDuration(inputFile);
const result = await convertFile(inputFile, duration, label, onUpdate);
results.push({ ...result, input: inputFile });
if (result.skipped) {
update(onUpdate, `→ Skipped ${label} (MP3 already exists)`);
} else if (result.success) {
update(onUpdate, `✓ Done ${label}`);
} else {
update(onUpdate, `✗ Failed ${label}: ${result.error}`);
}
}
const converted = results.filter((r) => r.success && !r.skipped);
const failed = results.filter((r) => !r.success);
const skipped = results.filter((r) => r.skipped);
if (failed.length === 0) {
// All succeeded — delete source files
update(onUpdate, `All conversions succeeded. Deleting ${converted.length} source files...`);
for (const r of converted) unlinkSync(r.input);
const lines = [
`Conversion complete.`,
`Converted: ${converted.length}`,
skipped.length > 0 ? `Skipped (already existed): ${skipped.length}` : null,
`Source files deleted: ${converted.length}`,
].filter(Boolean);
return {
content: [{ type: 'text', text: lines.join('\n') }],
details: { converted: converted.length, failed: 0, skipped: skipped.length },
};
}
// Some failed — delete created MP3s for a clean retry
update(onUpdate, `${failed.length} failure(s). Rolling back ${converted.length} created MP3(s) for clean retry...`);
for (const r of converted) {
if (existsSync(r.outputFile)) unlinkSync(r.outputFile);
}
const failLines = failed.map((r) => ` - ${basename(r.input)}: ${r.error}`).join('\n');
return {
content: [{
type: 'text',
text: [
`Conversion failed. ${failed.length}/${audioFiles.length} file(s) could not be converted.`,
`Successfully created MP3s have been removed — the directory is unchanged for a clean retry.`,
``,
`Failed files:`,
failLines,
].join('\n'),
}],
details: { converted: 0, failed: failed.length, rolledBack: converted.length },
isError: true,
};
}
+27
View File
@@ -0,0 +1,27 @@
---
name: web_search
label: Web Search
description: Search the web using a private SearXNG instance and return a list of results with titles, URLs, and snippets. Use when you need to find information or URLs without already knowing where to look. Pair with web_fetch to read the full content of any result.
language: typescript
inputs:
query:
type: string
description: The search query
max_results:
type: number
description: Maximum number of results to return (default 10, max 20)
optional: true
---
# Web Search
Searches the web via a self-hosted SearXNG instance. Returns titles, URLs, and content snippets.
## Usage pattern
1. Call `web_search` with a query to get a list of results
2. Call `web_fetch` on any result URL to read its full content
## Configuration
The SearXNG instance URL is read from the `PI_SEARXNG_URL` environment variable.
+124
View File
@@ -0,0 +1,124 @@
const TIMEOUT_MS = 10_000;
const DEFAULT_MAX_RESULTS = 10;
const HARD_MAX_RESULTS = 20;
type SearxngResult = {
title: string;
url: string;
content?: string;
engine?: string;
score?: number;
};
type SearxngResponse = {
query: string;
number_of_results: number;
results: SearxngResult[];
};
type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void;
function update(onUpdate: OnUpdate | undefined, text: string): void {
onUpdate?.({ content: [{ type: 'text', text }] });
}
function formatResults(results: SearxngResult[]): string {
if (results.length === 0) return 'No results found.';
return results
.map((r, i) => {
const lines = [`${i + 1}. **${r.title}**`, ` ${r.url}`];
if (r.content?.trim()) lines.push(` ${r.content.trim()}`);
return lines.join('\n');
})
.join('\n\n');
}
export async function execute(
_toolCallId: string,
params: { query: string; max_results?: number },
_signal: AbortSignal | undefined,
onUpdate?: OnUpdate,
) {
const searxngUrl = process.env.PI_SEARXNG_URL;
if (!searxngUrl) {
return {
content: [{ type: 'text', text: 'Web search is not configured. PI_SEARXNG_URL is not set.' }],
details: { error: 'not_configured' },
isError: true,
};
}
const { query } = params;
const maxResults = Math.min(params.max_results ?? DEFAULT_MAX_RESULTS, HARD_MAX_RESULTS);
if (!query?.trim()) {
return {
content: [{ type: 'text', text: 'Query cannot be empty.' }],
details: { error: 'empty_query' },
isError: true,
};
}
update(onUpdate, `Searching for: ${query}`);
const searchUrl = new URL('/search', searxngUrl);
searchUrl.searchParams.set('q', query);
searchUrl.searchParams.set('format', 'json');
searchUrl.searchParams.set('categories', 'general');
let response: Response;
try {
response = await fetch(searchUrl.toString(), {
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { Accept: 'application/json' },
});
} catch (err) {
return {
content: [{ type: 'text', text: `Failed to reach SearXNG at ${searxngUrl}: ${String(err)}` }],
details: { error: 'fetch_failed', url: searxngUrl },
isError: true,
};
}
if (!response.ok) {
return {
content: [{ type: 'text', text: `SearXNG returned HTTP ${response.status}` }],
details: { error: 'http_error', status: response.status },
isError: true,
};
}
let data: SearxngResponse;
try {
data = (await response.json()) as SearxngResponse;
} catch {
return {
content: [{ type: 'text', text: 'SearXNG returned an invalid response.' }],
details: { error: 'invalid_json' },
isError: true,
};
}
const results = (data.results ?? []).slice(0, maxResults);
update(onUpdate, `Found ${data.number_of_results ?? results.length} results, returning top ${results.length}`);
const output = [
`Search: "${query}"`,
`Results: ${results.length}`,
``,
formatResults(results),
].join('\n');
return {
content: [{ type: 'text', text: output }],
details: {
query,
total: data.number_of_results ?? results.length,
returned: results.length,
results: results.map((r) => ({ title: r.title, url: r.url })),
},
};
}