tools and skills, etc in the containers
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 })),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user