diff --git a/seed/tasks/build-discography/TASK.md b/seed/tasks/build-discography/TASK.md deleted file mode 100644 index 54f6d1b2..00000000 --- a/seed/tasks/build-discography/TASK.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: Build Discography -description: Identify all albums in an artist folder, rename directories, fetch detailed info and cover art for each. -version: 1 -mode: pipeline -triggers: - - type: directory -inputs: - artist_name: - type: string - description: Artist / band name - autofill: entry_name -steps: - - task: convert-audio - inputs: - file_path: . - target_format: mp3 - delete_source: "true" - - task: clean-playlist-files - inputs: - file_path: . - - task: prepare-discography - inputs: - artist_name: ${artist_name} - - task: fetch-album-info - foreach: subdirectory - concurrency: 5 - skip_if: album-info.md - inputs: - artist_name: ${artist_name} - album_name: ${folder_name} - - task: tag-album - foreach: subdirectory - concurrency: 5 - inputs: - artist_name: ${artist_name} - album_name: ${folder_name} ---- diff --git a/seed/tasks/clean-playlist-files/TASK.md b/seed/tasks/clean-playlist-files/TASK.md deleted file mode 100644 index d888d0bc..00000000 --- a/seed/tasks/clean-playlist-files/TASK.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: Clean Playlist Files -description: Recursively delete .cue, .m3u, .m3u8, .pls, .wpl, .xspf and other playlist files. -version: 1 -mode: script -language: bash -triggers: - - type: directory -inputs: - file_path: - type: string - description: Path to a directory to clean. -args: [file_path] ---- - -# Clean Playlist Files - -Remove playlist and cue sheet files from a directory tree. diff --git a/seed/tasks/clean-playlist-files/run.sh b/seed/tasks/clean-playlist-files/run.sh deleted file mode 100644 index b17aad5d..00000000 --- a/seed/tasks/clean-playlist-files/run.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -EXTENSIONS="cue|m3u|m3u8|pls|wpl|xspf|nfo|txt|log|accurip|sfv|md5" - -TARGET="${1:-${INPUT_FILE_PATH:-}}" - -if [[ -z "$TARGET" ]]; then - echo "Error: No directory path provided" >&2 - exit 1 -fi - -if [[ ! -d "$TARGET" ]]; then - echo "Error: Not a directory: $TARGET" >&2 - exit 1 -fi - -deleted=0 - -while IFS= read -r -d '' file; do - echo "Deleting: $file" - rm "$file" - deleted=$((deleted + 1)) -done < <(find "$TARGET" -type f -regextype posix-extended -iregex ".*\.($EXTENSIONS)" -print0 | sort -z) - -if [[ $deleted -eq 0 ]]; then - echo "No playlist/cue files found in: $TARGET" -else - echo "" - echo "Summary: $deleted files deleted" -fi diff --git a/seed/tasks/convert-audio/TASK.md b/seed/tasks/convert-audio/TASK.md deleted file mode 100644 index 55e356e2..00000000 --- a/seed/tasks/convert-audio/TASK.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: Convert Audio -description: Convert audio files between formats, preserving metadata. -version: 1 -mode: script -language: bash -triggers: - - type: file - extensions: - - mp3 - - flac - - wav - - ogg - - wma - - aac - - m4a - - opus - - aiff - - aif - - ape - - wv - - alac - - dsf - - dff - - type: directory -inputs: - file_path: - type: string - description: Path to an audio file or directory to convert. - target_format: - type: string - description: Target format - default: mp3 - options: - - mp3 - - flac - - wav - - ogg - - aac - - opus - delete_source: - type: boolean - description: Delete source files after successful conversion. - default: false -args: [file_path] ---- - -# Convert Audio - -Convert audio files between formats using ffmpeg, preserving metadata. -Supports single file conversion and batch directory conversion. diff --git a/seed/tasks/convert-audio/run.sh b/seed/tasks/convert-audio/run.sh deleted file mode 100755 index e2dce3d0..00000000 --- a/seed/tasks/convert-audio/run.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ── Task: Convert Audio ── - -EXTENSIONS="mp3|flac|wav|ogg|wma|aac|m4a|opus|aiff|aif|ape|wv|alac|dsf|dff" -TARGET_FORMAT="${INPUT_TARGET_FORMAT:-mp3}" -DELETE_SOURCE="${INPUT_DELETE_SOURCE:-false}" - -ffmpeg_args_for_format() { - case "$1" in - mp3) echo "-codec:a libmp3lame -b:a 320k -id3v2_version 3" ;; - flac) echo "-codec:a flac" ;; - wav) echo "-codec:a pcm_s16le" ;; - ogg) echo "-codec:a libvorbis -q:a 6" ;; - aac) echo "-codec:a aac -b:a 256k" ;; - opus) echo "-codec:a libopus -b:a 192k" ;; - *) echo "Unsupported format: $1" >&2; return 1 ;; - esac -} - -FFMPEG_ARGS=$(ffmpeg_args_for_format "$TARGET_FORMAT") || exit 1 - -process_file() { - local input="$1" - local ext="${input##*.}" - local dir - dir="$(dirname "$input")" - local base - base="$(basename "$input" ".$ext")" - local output="$dir/$base.$TARGET_FORMAT" - - if [[ "${ext,,}" == "$TARGET_FORMAT" ]]; then - echo "Skipping (already $TARGET_FORMAT): $input" - return 0 - fi - - if [[ -f "$output" ]]; then - echo "Skipping (output exists): $output" - return 0 - fi - - echo "Converting: $input → $output" - # shellcheck disable=SC2086 - if ffmpeg -nostdin -i "$input" $FFMPEG_ARGS -map_metadata 0 -y "$output" 2>/dev/null; then - echo " Done" - if [[ "$DELETE_SOURCE" == "true" ]]; then - rm "$input" - echo " Deleted source" - fi - else - echo " Failed" >&2 - return 1 - fi -} - -# ── Template: File Processor ── - -TARGET="${1:-${INPUT_FILE_PATH:-}}" - -if [[ -z "$TARGET" ]]; then - echo "Error: No file or directory path provided" >&2 - exit 1 -fi - -if [[ ! -e "$TARGET" ]]; then - echo "Error: Path does not exist: $TARGET" >&2 - exit 1 -fi - -converted=0 -failed=0 - -if [[ -f "$TARGET" ]]; then - if process_file "$TARGET"; then - converted=$((converted + 1)) - else - failed=$((failed + 1)) - fi -elif [[ -d "$TARGET" ]]; then - while IFS= read -r -d '' file; do - if process_file "$file"; then - converted=$((converted + 1)) - else - failed=$((failed + 1)) - fi - done < <(find "$TARGET" -type f -regextype posix-extended -iregex ".*\.($EXTENSIONS)" -print0 | sort -z) - - if [[ $converted -eq 0 && $failed -eq 0 ]]; then - echo "No matching files found in: $TARGET" - exit 0 - fi -else - echo "Error: Not a file or directory: $TARGET" >&2 - exit 1 -fi - -echo "" -echo "Summary: $converted processed, $failed failed" diff --git a/seed/tasks/fetch-album-info/TASK.md b/seed/tasks/fetch-album-info/TASK.md deleted file mode 100644 index ff1a47d1..00000000 --- a/seed/tasks/fetch-album-info/TASK.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -name: Fetch Album Info -description: Search the web for album information and save a detailed info file. -version: 1 -mode: agentic -triggers: - - type: directory -inputs: - artist_name: - type: string - description: Artist name - autofill: entry_name - album_name: - type: string - description: Album name - autofill: entry_name ---- - -# Fetch Album Info - -You are given an artist name and album name. Your job is to find comprehensive information about this album from the web. - -## Process - -1. **Search** for the album using `web_search` with queries like `" wikipedia"`, `" musicbrainz"`, `" allmusic"`. -2. **Fetch** the most relevant pages using `web_fetch` to extract detailed information. -3. **Cross-reference** multiple sources to get the most accurate and complete data. -4. **Download** the front cover image and save it as `cover.{ext}` (matching the image format, e.g. `cover.jpg`) in the target directory. Overwrite if it already exists. -5. **Save** the result as an `album-info.md` file in the target directory (provided in Context). - -## Information to collect - -- **Artist** name (canonical/correct spelling) -- **Album** title (canonical/correct spelling) -- **Release year** (original release) -- **Genre(s)** and subgenres -- **Label** / record company -- **Tracklist** — for each track: number, title, duration. For multi-disc releases, organize by disc. -- **Total duration** -- **Front cover image URL** — find the highest quality cover art URL available (Wikipedia, MusicBrainz cover art archive) -- **Credits** — producer, engineer, notable musicians (if available) -- **Additional notes** — compilation info, remaster details, notable facts - -## Output format - -Write a markdown file named `album-info.md` in the **target directory** (from Context) with this structure: - -```markdown -# Artist — Album Title - -![Cover](url-to-cover-image) - -| Field | Value | -|-------|-------| -| Artist | ... | -| Album | ... | -| Year | ... | -| Genre | ... | -| Label | ... | -| Duration | ... | - -## Tracklist - -### Disc 1 -| # | Title | Duration | -|---|-------|----------| -| 1 | ... | 0:00 | - -### Disc 2 -... - -## Credits -- Producer: ... -- ... - -## Notes -... -``` - -If there is only one disc, omit the "Disc 1" heading and just use a flat tracklist. - -## Cover art download - -Once you have a cover image URL, download it to the target directory using curl: -``` -curl -sL -o "/cover." "" -``` -Use the correct extension based on the image format (jpg, png, etc.). Overwrite any existing file. -In the `album-info.md`, keep the original remote URL in the image tag: `![Cover]()`. - -## Important - -- Prefer Wikipedia for context, genre classification, credits, and tracklist data. -- Use MusicBrainz / Cover Art Archive for high-quality cover image URLs and precise edition identification. -- Use AllMusic as an additional source for credits and reviews. -- If information conflicts between sources, prefer Wikipedia for metadata and MusicBrainz for tracklist data. -- Do NOT make up information. If something is not found, omit it. diff --git a/seed/tasks/prepare-discography/TASK.md b/seed/tasks/prepare-discography/TASK.md deleted file mode 100644 index f71511c5..00000000 --- a/seed/tasks/prepare-discography/TASK.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: Prepare Discography -description: Scan an artist's album folders, identify exact release editions, and produce a discography summary. -version: 1 -mode: agentic -triggers: - - type: directory -inputs: - artist_name: - type: string - description: Artist / band name - autofill: entry_name ---- - -# Prepare Discography - -You are given an artist/band directory containing one subfolder per album. Your job is to identify the **exact release edition** of each album and produce a `discography.md` summary file. - -## Why this matters - -Album folders often contain special editions, anniversary reissues, deluxe multi-disc sets, etc. A naive search for "The Doors 1967" returns the original 11-track release, but the folder might contain a 50th Anniversary 3CD edition with 30+ tracks. You must identify the **specific edition** the user has. - -## Process - -For each album subfolder in the artist directory: - -### 1. Analyze local signals - -Gather as much information as possible from the folder itself **before** searching the web: - -- **Folder name**: often contains year and album title, sometimes edition hints like `(3CD)`, `(Deluxe)`, `(Remaster)` -- **Disc structure**: count `CD1/`, `CD2/`, etc. subdirectories — this tells you how many discs the release has -- **Track count per disc**: list audio files in each disc folder (or root if single-disc) -- **Track names**: the filenames often contain track titles (e.g. `01 - Break on Through.flac`) -- **Audio tags**: use the `mutagen` tool with `action: read` on 1-2 representative tracks per disc to get tagged metadata (artist, album, year, genre, label, etc.) -- **Existing files**: check for `album-info.md`, `Front.jpg`, `cover.jpg`, or `Scans/` — these provide additional context - -### 2. Build a search profile - -From the local signals, construct a profile: -- Artist name (from tags or folder name) -- Album title (from tags or folder name) -- Release year (from tags or folder name) -- Number of discs -- Track count per disc -- Any edition keywords (deluxe, remaster, anniversary, etc.) - -### 3. Search for the exact release - -Use `web_search` to find the specific edition: -- Search Wikipedia first: `" wikipedia"` — Wikipedia often has edition/reissue details -- Search MusicBrainz: `" musicbrainz"` — MusicBrainz catalogs every pressing and edition -- If the disc/track count doesn't match the first result, refine: `" deluxe 3CD"` or similar -- Use `web_fetch` on the most relevant pages to confirm the tracklist matches your local files - -### 4. Confirm the match - -Compare the web result against local signals: -- Does the disc count match? -- Does the track count per disc match (approximately)? -- Do track names align? -- If there's a mismatch, search for alternative editions until you find the best match - -### 5. Rename the album folder - -Once you have confidently identified the release, rename the album folder to the standardized format: - -``` -[YYYY] Album Title (Edition Marker) -``` - -- **YYYY** = original release year (NOT the reissue/special edition year) -- **Album Title** = canonical album name -- **Edition Marker** = only if it's not the standard original release. Examples: `50th Anniversary 3CD Deluxe Edition`, `2017 Remaster`, `Deluxe Edition` -- If it IS the plain original release, omit the parenthetical: `[1967] The Doors` - -Use `mv` via Bash to rename. Do this **before** writing discography.md so the file references the new folder names. - -## Output - -Write a `discography.md` file in the **artist directory** (the target directory from Context) with this structure: - -```markdown -# Artist Name — Discography - -## Albums - -### [YYYY] Album Title (Edition Marker) -- **Folder**: `[YYYY] Album Title (Edition Marker)/` -- **Format**: FLAC / MP3 / Mixed -- **Discs**: N -- **Tracks**: N (or N per disc: D1: X, D2: Y, ...) -- **Year**: YYYY (original release) / YYYY (this edition) -- **Label**: ... -- **Genre**: ... -- **Edition**: 50th Anniversary Deluxe Edition / Original / Remaster / etc. -- **Wikipedia**: https://en.wikipedia.org/wiki/... (if found) -- **MusicBrainz**: https://musicbrainz.org/release/... (if found) -- **Notes**: ... - -### [YYYY] Another Album -... -``` - -Sort albums chronologically by original release year. - -Include **direct URLs** to the sources used (Discogs release page, Wikipedia article, MusicBrainz release) — these will be used by downstream tasks to avoid redundant searching. - -## Important - -- **Accuracy over speed**: it's better to correctly identify 3 out of 4 albums than to guess all 4 wrong -- **Wikipedia and MusicBrainz are your primary sources** for edition identification -- **Use mutagen sparingly**: read 1-2 tracks per disc, not every file -- **Track count is the strongest signal** for distinguishing editions — a 3CD set with 10+12+8 tracks is very different from a single-disc 11-track original -- **Folder name hints**: `(3CD)`, `(2LP)`, `(Deluxe)`, `(Remaster)`, `(Anniversary)` in the folder name are strong clues -- **Do NOT make up information**. If you cannot confidently identify an edition, note what you found and flag the uncertainty -- The artist name in the folder may be formatted as `Last, First` or `Name, The` — normalize it when searching (e.g. `Doors, the` → `The Doors`) diff --git a/seed/tasks/tag-album/TASK.md b/seed/tasks/tag-album/TASK.md deleted file mode 100644 index 44310aa6..00000000 --- a/seed/tasks/tag-album/TASK.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -name: Tag Album -description: Rename track files and set ID3 tags based on album-info.md metadata. -version: 1 -mode: agentic -triggers: - - type: directory -inputs: - artist_name: - type: string - description: Artist name - autofill: entry_name - album_name: - type: string - description: Album name - autofill: entry_name ---- - -# Tag Album - -You are given an artist name and album name. Your job is to rename audio files and set proper ID3 tags using the metadata from `album-info.md` in the target directory. - -## Prerequisites - -The target directory (from Context) must contain: -- Audio files (`.mp3`) -- An `album-info.md` file (produced by the fetch-album-info task) -- Optionally a cover image (`cover.jpg`, `cover.png`, `front.jpg`, or similar) - -If `album-info.md` does not exist, stop and report that the album info must be fetched first. - -## Process - -### 1. Read album-info.md - -Parse the `album-info.md` file to extract: -- **Artist** (canonical name from the metadata table) -- **Album** title (canonical name from the metadata table) -- **Year** (from the metadata table) -- **Genre** (from the metadata table) -- **Tracklist** — track numbers, titles, and disc numbers (if multi-disc) - -### 2. Inventory existing files - -List all `.mp3` files in the target directory (and `Disc N/` subdirectories for multi-disc albums). - -Match each existing file to a track in the tracklist. Use track number, partial title match, or positional order to establish the mapping. If the album has multiple discs, match within each `Disc N/` subdirectory. - -### 3. Rename files - -Rename each audio file to the standardized format: - -``` -NNN - Track Title.mp3 -``` - -Where: -- `NNN` is the track number zero-padded to 3 digits (e.g. `001`, `012`) -- `Track Title` is the canonical title from `album-info.md` - -**Filename safety rules** — these characters are illegal on Windows and must be replaced: -- `:` → ` -` (space dash) -- `?` → removed -- `"` → removed -- `*`, `<`, `>`, `|`, `\`, `/` → removed - -Use `mv` to rename. Work within each disc subdirectory if the album is multi-disc. - -### 4. Set ID3 tags - -Use the `mutagen` tool to write tags on each track. Required tags: - -| Tag | Value | -|-----|-------| -| TIT2 | Track title (from tracklist, NO track number prefix) | -| TPE1 | Artist name (use the `artist_name` input exactly as provided) | -| TPE2 | Same as TPE1 | -| TALB | Album title | -| TRCK | `track/total` (e.g. `3/12`) | -| TDRC | Release year (4-digit) | -| TCON | Genre(s) from album-info | - -For multi-disc albums, also set: -| TPOS | `disc/total` (e.g. `1/2`) | - -Use the `mutagen` tool's `write` action. Example: - -``` -mutagen write --path "001 - Track Name.mp3" --tags '{"TIT2": "Track Name", "TPE1": "Artist", "TPE2": "Artist", "TALB": "Album", "TRCK": "1/12", "TDRC": "1967", "TCON": "Rock"}' -``` - -### 5. Embed cover art - -If a cover image exists in the target directory (check for `cover.jpg`, `cover.png`, `front.jpg`, `Front.jpg`, `Cover/Cover.jpg`, or any image file that looks like album art), embed it into every track using the `mutagen` tool's `embed_cover` action: - -``` -mutagen embed_cover --path "001 - Track Name.mp3" --image "cover.jpg" -``` - -### 6. Verify - -After all files are renamed and tagged, read back the tags of the first and last track using `mutagen read` to confirm the tags were written correctly. Report a summary of what was done: -- Number of tracks processed -- Any files that could not be matched or tagged -- Whether cover art was embedded - -## Important - -- Always read `album-info.md` as the source of truth for metadata — do NOT use web search. -- Do NOT modify the audio content, only metadata and filenames. -- Preserve disc subdirectory structure for multi-disc albums. -- If a track file cannot be matched to a tracklist entry, skip it and report it. -- Track titles in TIT2 must NOT include track numbers (e.g. "Song Name", not "01 - Song Name"). -- Use the `artist_name` input parameter exactly as provided for TPE1 and TPE2 — do NOT reformat it or use the artist name from `album-info.md`. The user organizes their library alphabetically by this value. -- Use the canonical album title from `album-info.md` for TALB. diff --git a/src/databases/officer_db/seed-tasks.ts b/src/databases/officer_db/seed-tasks.ts deleted file mode 100644 index f59d8a1c..00000000 --- a/src/databases/officer_db/seed-tasks.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Seed native tasks into the database. - * Run: bun src/databases/officer_db/seed-tasks.ts - */ -import { eq, and } from 'drizzle-orm'; -import { db } from './src/db'; -import { tasks } from './src/schema/agent-items'; -import { readdirSync, readFileSync, existsSync } from 'node:fs'; -import { join, resolve } from 'node:path'; - -const SEED_TASKS_DIR = resolve(import.meta.dir, '../../../seed/tasks'); - -type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' }; - -function parseFrontmatter(content: string) { - const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); - if (!match) return { meta: {} as Record, body: content }; - - const yaml = match[1]!; - const body = match[2]!; - const meta: Record = {}; - - meta.name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? ''; - meta.description = yaml.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? ''; - meta.mode = yaml.match(/^mode:\s*(.+)$/m)?.[1]?.trim() ?? 'agentic'; - meta.language = yaml.match(/^language:\s*(.+)$/m)?.[1]?.trim() ?? undefined; - meta.version = parseInt(yaml.match(/^version:\s*(.+)$/m)?.[1]?.trim() ?? '1', 10); - - // Parse args - const argsMatch = yaml.match(/^args:\s*\[([^\]]*)\]/m); - meta.args = argsMatch ? argsMatch[1]!.split(',').map((a) => a.trim()).filter(Boolean) : undefined; - - // Parse triggers - const triggers: TriggerConfig[] = []; - const triggerMatch = yaml.match(/^triggers?:\s*\n((?:[ \t]+.+\n?)*)/m); - if (triggerMatch) { - const items = triggerMatch[1]!.split(/(?=^\s+-\s*type:)/m); - for (const item of items) { - const type = item.match(/type:\s*(.+)/)?.[1]?.trim(); - if (type === 'directory') { - triggers.push({ type: 'directory' }); - } else if (type === 'file') { - const extBlock = item.match(/extensions:\s*\n((?:\s+-\s*.+\n?)*)/); - const extensions = extBlock ? [...extBlock[1]!.matchAll(/^\s+-\s*(.+)$/gm)].map((m) => m[1]!.trim()) : []; - if (extensions.length > 0) triggers.push({ type: 'file', extensions }); - } - } - } - meta.trigger = triggers.length > 0 ? triggers : undefined; - - // Parse inputs - const inputsMatch = yaml.match(/^inputs:\s*\n((?:[ \t]+.+\n?)*)/m); - if (inputsMatch) { - const inputBlock = inputsMatch[1]!; - const inputEntries: Record> = {}; - let currentInput: string | null = null; - let currentListKey: string | null = null; - let currentList: string[] = []; - - const flushList = () => { - if (currentInput && currentListKey && currentList.length > 0) { - inputEntries[currentInput]![currentListKey] = currentList; - } - currentListKey = null; - currentList = []; - }; - - for (const line of inputBlock.split('\n')) { - const topMatch = line.match(/^\s{2}(\w[\w_-]*):\s*$/); - if (topMatch) { - flushList(); - currentInput = topMatch[1]!; - inputEntries[currentInput] = {}; - continue; - } - // List item (6 spaces + dash) - const listItemMatch = line.match(/^\s{6}-\s*(.+)$/); - if (listItemMatch && currentInput && currentListKey) { - currentList.push(listItemMatch[1]!.trim()); - continue; - } - // Property with value or start of list - const propMatch = line.match(/^\s{4}(\w[\w_-]*):\s*(.*)$/); - if (propMatch && currentInput) { - flushList(); - const value = propMatch[2]!.trim(); - if (value === '') { - // Start of a list (e.g. "options:") - currentListKey = propMatch[1]!; - currentList = []; - } else { - inputEntries[currentInput]![propMatch[1]!] = value; - } - } - } - flushList(); - if (Object.keys(inputEntries).length > 0) { - meta.inputs = inputEntries; - } - } - - // Parse pipeline steps - const stepsMatch = yaml.match(/^steps:\s*\n((?:[ \t]+.+\n?)*)/m); - if (stepsMatch) { - const stepsBlock = stepsMatch[1]!; - const steps: Record[] = []; - let currentStep: Record | null = null; - let inInputs = false; - let stepInputs: Record = {}; - - for (const line of stepsBlock.split('\n')) { - // New step entry (2 spaces + dash) - const stepStart = line.match(/^\s{2}-\s+task:\s*(.+)$/); - if (stepStart) { - if (currentStep) { - if (Object.keys(stepInputs).length > 0) currentStep.inputs = stepInputs; - steps.push(currentStep); - } - currentStep = { task: stepStart[1]!.trim() }; - stepInputs = {}; - inInputs = false; - continue; - } - if (!currentStep) continue; - - // Step-level properties (4 spaces) - const propMatch = line.match(/^\s{4}(\w[\w_-]*):\s*(.*)$/); - if (propMatch) { - const key = propMatch[1]!; - const value = propMatch[2]!.trim(); - if (key === 'inputs' && value === '') { - inInputs = true; - } else { - inInputs = false; - currentStep[key] = value; - } - continue; - } - - // Step input entries (6 spaces) - const inputMatch = line.match(/^\s{6}(\w[\w_-]*):\s*(.+)$/); - if (inputMatch && inInputs) { - const raw = inputMatch[2]!.trim(); - stepInputs[inputMatch[1]!] = raw.replace(/^["'](.*)["']$/, '$1'); - } - } - if (currentStep) { - if (Object.keys(stepInputs).length > 0) currentStep.inputs = stepInputs; - steps.push(currentStep); - } - if (steps.length > 0) { - meta.config = { steps }; - } - } - - return { meta, body }; -} - -async function seedTasks() { - if (!existsSync(SEED_TASKS_DIR)) { - console.log('No seed tasks directory found'); - return; - } - - const entries = readdirSync(SEED_TASKS_DIR, { withFileTypes: true }); - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - - const taskDir = join(SEED_TASKS_DIR, entry.name); - const taskMdPath = join(taskDir, 'TASK.md'); - if (!existsSync(taskMdPath)) continue; - - const content = readFileSync(taskMdPath, 'utf-8'); - const { meta, body } = parseFrontmatter(content); - - // Find implementation file - let implementation: string | null = null; - const lang = meta.language as string | undefined; - const implCandidates = [ - { file: 'run.sh', lang: 'bash' }, - { file: 'index.ts', lang: 'typescript' }, - { file: 'run.py', lang: 'python' }, - { file: 'index.js', lang: 'javascript' }, - ]; - for (const c of implCandidates) { - const path = join(taskDir, c.file); - if (existsSync(path)) { - implementation = readFileSync(path, 'utf-8'); - break; - } - } - - const values = { - scope: 'native' as const, - userId: null, - dirName: entry.name, - name: (meta.name as string) || entry.name, - description: (meta.description as string) || null, - body: body || null, - version: (meta.version as number) || 1, - mode: (meta.mode as string) || 'agentic', - language: (meta.language as string) || null, - implementation, - args: (meta.args as string[]) || null, - trigger: (meta.trigger as TriggerConfig[]) || null, - inputs: (meta.inputs as Record) || null, - config: (meta.config as Record) || null, - }; - - // Upsert: check by dirName + scope since unique constraint doesn't work with NULL userId - const existing = await db - .select({ id: tasks.id }) - .from(tasks) - .where(and(eq(tasks.dirName, values.dirName), eq(tasks.scope, 'native'))) - .limit(1); - - if (existing.length > 0) { - await db - .update(tasks) - .set({ - name: values.name, - description: values.description, - body: values.body, - version: values.version, - mode: values.mode, - language: values.language, - implementation: values.implementation, - args: values.args, - trigger: values.trigger, - inputs: values.inputs, - config: values.config, - updatedAt: new Date(), - }) - .where(eq(tasks.id, existing[0]!.id)); - } else { - await db.insert(tasks).values(values); - } - - console.log(`Seeded: ${entry.name} (${meta.mode}/${meta.language})`); - } - - console.log('Done seeding tasks'); - process.exit(0); -} - -seedTasks().catch((err) => { - console.error('Seed failed:', err); - process.exit(1); -}); diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 2aa1b14d..2c075de8 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -79,6 +79,7 @@ export { createTask, updateTask, deleteTask, + upsertNativeTask, } from './queries/tasks'; export { diff --git a/src/databases/officer_db/src/queries/tasks.ts b/src/databases/officer_db/src/queries/tasks.ts index 402e85ae..9d954700 100644 --- a/src/databases/officer_db/src/queries/tasks.ts +++ b/src/databases/officer_db/src/queries/tasks.ts @@ -72,3 +72,20 @@ export async function updateTask(id: number, data: Partial) { export async function deleteTask(id: number) { await db.delete(tasks).where(eq(tasks.id, id)); } + +export async function upsertNativeTask(data: Omit) { + const existing = await db + .select({ id: tasks.id }) + .from(tasks) + .where(and(eq(tasks.dirName, data.dirName), eq(tasks.scope, 'native'))) + .limit(1); + + if (existing.length > 0) { + await db + .update(tasks) + .set({ ...data, updatedAt: new Date() }) + .where(eq(tasks.id, existing[0]!.id)); + } else { + await db.insert(tasks).values({ ...data, scope: 'native', userId: null }); + } +} diff --git a/src/servers/bootstrap.ts b/src/servers/bootstrap.ts index a7195a4d..e55d063f 100644 --- a/src/servers/bootstrap.ts +++ b/src/servers/bootstrap.ts @@ -2,7 +2,7 @@ import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { DATA_PATH } from './data-path'; -import { syncMarketplaceTools } from './sync-marketplace'; +import { syncMarketplaceTools, syncMarketplaceTasks } from './sync-marketplace'; import { ensureToolLoader } from './ensure-tool-loader'; // Queue is now owned by the sidecar process import { startDiscordBotIfConfigured } from './channels/discord/bot'; @@ -68,6 +68,7 @@ async function installPi(): Promise { } await syncMarketplaceTools(); + await syncMarketplaceTasks(); ensureToolLoader(); // Queue is initialized by the sidecar process diff --git a/src/servers/sync-marketplace.ts b/src/servers/sync-marketplace.ts index 1c04fdd7..3b64d628 100644 --- a/src/servers/sync-marketplace.ts +++ b/src/servers/sync-marketplace.ts @@ -2,10 +2,13 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { DATA_PATH } from './data-path'; import { parseSeedVersion } from './sync-version'; +import { upsertNativeTask } from 'officerdb'; const MARKETPLACE_URL = process.env.MARKETPLACE_URL ?? 'https://marketplace.officer.dev'; const GLOBAL_TOOLS_DIR = join(DATA_PATH, 'tools'); +// ── Tool sync types ── + type ToolInput = { type: string; description: string; @@ -26,11 +29,35 @@ type MarketplaceTool = { implementation: string; }; -type MarketplaceResponse = { +type ToolSyncResponse = { categories: Array<{ name: string; tools: MarketplaceTool[] }>; uncategorized: MarketplaceTool[]; }; +// ── Task sync types ── + +type MarketplaceTask = { + dirName: string; + name: string; + description: string; + body: string; + version: number; + mode: string; + language: string | null; + implementation: string | null; + args: string[] | null; + inputs: Record | null; + trigger: unknown[] | null; + config: Record | null; +}; + +type TaskSyncResponse = { + categories: Array<{ name: string; tasks: MarketplaceTask[] }>; + uncategorized: MarketplaceTask[]; +}; + +// ── Tool sync (writes files to disk) ── + function buildToolMd(tool: MarketplaceTool): string { const lines = ['---']; lines.push(`name: ${tool.name}`); @@ -72,7 +99,7 @@ export async function syncMarketplaceTools(): Promise { return; } - const data = (await res.json()) as MarketplaceResponse; + const data = (await res.json()) as ToolSyncResponse; mkdirSync(GLOBAL_TOOLS_DIR, { recursive: true }); @@ -103,3 +130,46 @@ export async function syncMarketplaceTools(): Promise { console.error('[marketplace] Failed to sync tools:', err instanceof Error ? err.message : err); } } + +// ── Task sync (upserts into officer_db) ── + +export async function syncMarketplaceTasks(): Promise { + try { + const res = await fetch(`${MARKETPLACE_URL}/api/tasks/native`); + if (!res.ok) { + console.error(`[marketplace] Failed to fetch tasks: ${res.status} ${res.statusText}`); + return; + } + + const data = (await res.json()) as TaskSyncResponse; + + const allTasks: MarketplaceTask[] = []; + for (const category of data.categories) { + allTasks.push(...category.tasks); + } + if (data.uncategorized) { + allTasks.push(...data.uncategorized); + } + + for (const task of allTasks) { + await upsertNativeTask({ + dirName: task.dirName, + name: task.name, + description: task.description, + body: task.body, + version: task.version, + mode: task.mode, + language: task.language, + implementation: task.implementation, + args: task.args, + inputs: task.inputs, + trigger: task.trigger, + config: task.config, + }); + + console.log(`[marketplace] Synced task: ${task.dirName} (v${task.version})`); + } + } catch (err) { + console.error('[marketplace] Failed to sync tasks:', err instanceof Error ? err.message : err); + } +}