/\n 10→ SKILL.md\n 11→```\n 12→\n 13→## SKILL.md Format\n 14→\n 15→A skill file has two parts: **frontmatter** (YAML metadata) and **body** (Markdown documentation).\n 16→\n 17→### Frontmatter\n 18→\n 19→```yaml\n 20→---\n 21→name: skill-name\n 22→description: What this skill does and when to use it.\n 23→---\n 24→```\n 25→\n 26→#### Fields\n 27→\n 28→| Field | Type | Required | Description |\n 29→|-------|------|----------|-------------|\n 30→| `name` | string | yes | Identifier for the skill. |\n 31→| `description` | string | yes | What the skill does and when to use it. Should include trigger phrases (e.g., \"Use when the user wants to...\"). |\n 32→\n 33→### Body\n 34→\n 35→The body contains reference documentation for the tool or service. The structure varies depending on the type of skill, but typically includes:\n 36→\n 37→- **Title** — `# Skill Name`\n 38→- **Overview** — What the tool is and how it works.\n 39→- **Usage** — How to invoke the tool (endpoints, CLI synopsis, etc.).\n 40→- **Parameters/Options** — Detailed reference tables.\n 41→- **Examples** — Common usage patterns and recipes.\n 42→- **Source** — Links to official documentation and repositories.\n 43→\n 44→### Existing Skills\n 45→\n 46→| Skill | Type | Description |\n 47→|-------|------|-------------|\n 48→| `whisper.cpp` | HTTP API | Speech-to-text transcription via a local whisper.cpp server. |\n 49→| `ffmpeg` | CLI | Audio/video processing, conversion, and analysis. |\n 50→","isError":false},{"role":"tool","toolName":"Glob","toolInput":{"pattern":"/home/pastilhas/projects/officer.dev/monorepo/seed/skills/**/SKILL.md"},"toolUseId":"toolu_013TuF8iaytm7Q9rqdajsdGF","output":"/home/pastilhas/projects/officer.dev/monorepo/seed/skills/whisper.cpp/SKILL.md\n/home/pastilhas/projects/officer.dev/monorepo/seed/skills/ffmpeg/SKILL.md\n/home/pastilhas/projects/officer.dev/monorepo/seed/skills/fizzy-cli/SKILL.md\n/home/pastilhas/projects/officer.dev/monorepo/seed/skills/mlxaudio/SKILL.md\n/home/pastilhas/projects/officer.dev/monorepo/seed/skills/sharp/SKILL.md\n/home/pastilhas/projects/officer.dev/monorepo/seed/skills/mutagen/SKILL.md\n/home/pastilhas/projects/officer.dev/monorepo/seed/skills/convert-audio-to-mp3/SKILL.md","isError":false},{"role":"assistant","text":"Let me look at a couple other skill files to compare quality and patterns."},{"role":"tool","toolName":"Read","toolInput":{"file_path":"/home/pastilhas/projects/officer.dev/monorepo/seed/skills/ffmpeg/SKILL.md"},"toolUseId":"toolu_01SHHCMRCYSZb2Er8qAeaHTB","output":" 1→---\n 2→name: ffmpeg\n 3→description: Process audio and video files using ffmpeg/ffprobe. Use when the user wants to convert, transcode, trim, merge, extract, resize, compress, or analyze multimedia files.\n 4→---\n 5→\n 6→# FFmpeg\n 7→\n 8→CLI reference for FFmpeg v8.x — a complete, cross-platform solution for recording, converting, and streaming audio and video.\n 9→\n 10→Official docs: https://www.ffmpeg.org/documentation.html\n 11→\n 12→## Tools\n 13→\n 14→| Tool | Purpose |\n 15→|------|---------|\n 16→| `ffmpeg` | Transcode, convert, filter, mux/demux multimedia |\n 17→| `ffprobe` | Analyze and inspect multimedia streams |\n 18→| `ffplay` | Play multimedia files (interactive) |\n 19→\n 20→---\n 21→\n 22→## ffmpeg\n 23→\n 24→### Synopsis\n 25→\n 26→```\n 27→ffmpeg [global_options] {[input_options] -i input_url} ... {[output_options] output_url} ...\n 28→```\n 29→\n 30→Options before `-i` apply to the input; options before the output URL apply to the output.\n 31→\n 32→### Global Options\n 33→\n 34→| Flag | Description |\n 35→|------|-------------|\n 36→| `-y` | Overwrite output files without asking |\n 37→| `-n` | Do not overwrite; exit if output exists |\n 38→| `-hide_banner` | Suppress copyright/build info banner |\n 39→| `-loglevel level` | Set log level: `quiet`, `error`, `warning`, `info` (default), `verbose`, `debug` |\n 40→| `-stats` | Print encoding progress/statistics |\n 41→| `-progress url` | Send machine-readable progress to url |\n 42→| `-report` | Dump full command line and log to a file |\n 43→| `-filter_threads n` | Number of threads for filter processing |\n 44→\n 45→### Input/Output Options\n 46→\n 47→| Flag | Description |\n 48→|------|-------------|\n 49→| `-i url` | Input file URL |\n 50→| `-f fmt` | Force input or output format |\n 51→| `-c[:stream] codec` | Select encoder/decoder; use `copy` for stream copying |\n 52→| `-t duration` | Limit duration (as input: read limit; as output: write limit) |\n 53→| `-to position` | Stop at position (timestamp) |\n 54→| `-ss position` | Seek to position (before `-i`: fast input seek; after: output seek) |\n 55→| `-sseof position` | Seek relative to end of file |\n 56→| `-itsoffset offset` | Set input time offset |\n 57→| `-itsscale scale` | Rescale input timestamps |\n 58→| `-metadata key=value` | Set metadata key/value pair |\n 59→| `-disposition value` | Set stream disposition flags |\n 60→| `-target type` | Specify target type: `vcd`, `svcd`, `dvd`, `dv`, `dv50` |\n 61→| `-stream_loop n` | Loop input stream n times (-1 = infinite) |\n 62→| `-frames[:stream] n` | Stop after n frames |\n 63→| `-fs limit` | Set file size limit in bytes |\n 64→| `-timestamp date` | Set recording timestamp |\n 65→\n 66→### Video Options\n 67→\n 68→| Flag | Description |\n 69→|------|-------------|\n 70→| `-vn` | Disable video |\n 71→| `-vcodec codec` | Set video codec (alias for `-c:v`) |\n 72→| `-r fps` | Set frame rate |\n 73→| `-fpsmax fps` | Set maximum frame rate |\n 74→| `-s WxH` | Set frame size |\n 75→| `-aspect ratio` | Set display aspect ratio (e.g. `16:9`) |\n 76→| `-pix_fmt format` | Set pixel format |\n 77→| `-vf filtergraph` | Apply video filter graph (alias for `-filter:v`) |\n 78→| `-pass n` | Two-pass encoding pass (1 or 2) |\n 79→| `-passlogfile prefix` | Two-pass log file prefix |\n 80→| `-vframes n` | Set number of video frames to output |\n 81→| `-autorotate` | Auto-rotate based on metadata (default on) |\n 82→| `-display_rotation angle` | Set video rotation metadata |\n 83→| `-display_hflip` | Horizontal flip metadata |\n 84→| `-display_vflip` | Vertical flip metadata |\n 85→| `-force_key_frames expr` | Force keyframes at specified times/expression |\n 86→| `-copyinkf` | Copy non-key frames at the beginning during stream copy |\n 87→\n 88→### Audio Options\n 89→\n 90→| Flag | Description |\n 91→|------|-------------|\n 92→| `-an` | Disable audio |\n 93→| `-acodec codec` | Set audio codec (alias for `-c:a`) |\n 94→| `-ar freq` | Set audio sample rate (Hz) |\n 95→| `-ac channels` | Set number of audio channels |\n 96→| `-af filtergraph` | Apply audio filter graph (alias for `-filter:a`) |\n 97→| `-sample_fmt fmt` | Set audio sample format |\n 98→| `-channel_layout layout` | Set audio channel layout |\n 99→| `-aq q` | Set audio quality (codec-specific VBR) |\n 100→| `-aframes n` | Set number of audio frames to output |\n 101→\n 102→### Subtitle Options\n 103→\n 104→| Flag | Description |\n 105→|------|-------------|\n 106→| `-sn` | Disable subtitles |\n 107→| `-scodec codec` | Set subtitle codec (alias for `-c:s`) |\n 108→| `-fix_sub_duration` | Fix subtitle durations to avoid overlap |\n 109→\n 110→### Stream Selection\n 111→\n 112→| Flag | Description |\n 113→|------|-------------|\n 114→| `-map input:stream` | Manually select streams for output |\n 115→| `-dn` | Disable data streams |\n 116→\n 117→Stream specifiers: `v` (video), `V` (video, no images), `a` (audio), `s` (subtitle), `d` (data). Index with `:N` (e.g. `a:0` = first audio).\n 118→\n 119→### Hardware Acceleration\n 120→\n 121→| Flag | Description |\n 122→|------|-------------|\n 123→| `-hwaccel method` | HW accel method: `cuda`, `vaapi`, `qsv`, `vulkan`, `auto` |\n 124→| `-hwaccel_device device` | Select HW device |\n 125→| `-init_hw_device type=name` | Initialize HW device |\n 126→\n 127→---\n 128→\n 129→## ffprobe\n 130→\n 131→### Synopsis\n 132→\n 133→```\n 134→ffprobe [options] input_url\n 135→```\n 136→\n 137→### Main Options\n 138→\n 139→| Flag | Description |\n 140→|------|-------------|\n 141→| `-show_format` | Show container format info |\n 142→| `-show_streams` | Show per-stream info |\n 143→| `-show_packets` | Show per-packet info |\n 144→| `-show_frames` | Show per-frame info |\n 145→| `-show_chapters` | Show chapter info |\n 146→| `-show_programs` | Show program info |\n 147→| `-show_entries section=key1,key2` | Show only specific fields |\n 148→| `-show_error` | Show probe errors |\n 149→| `-select_streams specifier` | Filter to specific streams (e.g. `v:0`, `a`) |\n 150→| `-count_frames` | Count frames per stream |\n 151→| `-count_packets` | Count packets per stream |\n 152→| `-read_intervals intervals` | Analyze specific time ranges |\n 153→\n 154→### Output Formats\n 155→\n 156→Set with `-output_format` (or `-of`, `-print_format`):\n 157→\n 158→| Format | Description |\n 159→|--------|-------------|\n 160→| `default` | `[SECTION] key=value [/SECTION]` |\n 161→| `json` | JSON output (most useful for parsing) |\n 162→| `xml` | XML output |\n 163→| `csv` | Comma-separated values |\n 164→| `flat` | Flat `key=value` per line |\n 165→| `ini` | INI-style sections |\n 166→\n 167→### Display Options\n 168→\n 169→| Flag | Description |\n 170→|------|-------------|\n 171→| `-pretty` | Human-readable units and time formatting |\n 172→| `-unit` | Show value units |\n 173→| `-sexagesimal` | Format times as HH:MM:SS.us |\n 174→| `-hide_banner` | Suppress copyright/build info |\n 175→| `-o output_url` | Write output to file instead of stdout |\n 176→\n 177→---\n 178→\n 179→## Common Codecs\n 180→\n 181→### Video Encoders\n 182→\n 183→#### libx264 (H.264)\n 184→\n 185→| Option | Description |\n 186→|--------|-------------|\n 187→| `-preset` | Speed/quality: `ultrafast`, `superfast`, `veryfast`, `faster`, `fast`, `medium` (default), `slow`, `slower`, `veryslow` |\n 188→| `-crf` | Constant quality: 0 (lossless) to 51 (worst). 18-23 is typical |\n 189→| `-profile:v` | `baseline`, `main`, `high` |\n 190→| `-tune` | `film`, `animation`, `grain`, `stillimage`, `fastdecode`, `zerolatency` |\n 191→| `-b:v` | Target bitrate (e.g. `2M`) |\n 192→\n 193→#### libx265 (H.265/HEVC)\n 194→\n 195→| Option | Description |\n 196→|--------|-------------|\n 197→| `-preset` | Same presets as x264 |\n 198→| `-crf` | 0-51, default 28. Similar quality to x264 at lower bitrate |\n 199→| `-profile:v` | `main`, `main10`, `main12` |\n 200→| `-b:v` | Target bitrate |\n 201→\n 202→#### libvpx-vp9 (VP9)\n 203→\n 204→| Option | Description |\n 205→|--------|-------------|\n 206→| `-crf` | 0-63. 31 is a good default |\n 207→| `-b:v` | Target bitrate (set to `0` for pure CRF mode) |\n 208→| `-cpu-used` | Speed: 0 (slowest/best) to 8 (fastest) |\n 209→| `-deadline` | `best`, `good` (default), `realtime` |\n 210→| `-row-mt 1` | Enable row-based multithreading |\n 211→\n 212→#### libsvtav1 (SVT-AV1)\n 213→\n 214→| Option | Description |\n 215→|--------|-------------|\n 216→| `-crf` | 0-63. 30 is a good default |\n 217→| `-preset` | 0 (slowest/best) to 13 (fastest). 8 is a good default |\n 218→| `-b:v` | Target bitrate |\n 219→\n 220→#### libaom-av1 (AOM AV1)\n 221→\n 222→| Option | Description |\n 223→|--------|-------------|\n 224→| `-crf` | 0-63 |\n 225→| `-cpu-used` | 0 (best) to 8 (fastest) |\n 226→| `-b:v` | Target bitrate (set to `0` for pure CRF mode) |\n 227→| `-tiles` | Tile columns x rows for parallelism |\n 228→\n 229→### Audio Encoders\n 230→\n 231→#### aac (Native AAC)\n 232→\n 233→| Option | Description |\n 234→|--------|-------------|\n 235→| `-b:a` | Bitrate: `128k`, `192k`, `256k` |\n 236→| `-profile:a` | `aac_low` (default), `aac_he`, `aac_he_v2` |\n 237→\n 238→#### libmp3lame (MP3)\n 239→\n 240→| Option | Description |\n 241→|--------|-------------|\n 242→| `-b:a` | CBR bitrate: `128k`, `192k`, `320k` |\n 243→| `-q:a` | VBR quality: 0 (best) to 9 (worst). 2 is a good default |\n 244→\n 245→#### libopus (Opus)\n 246→\n 247→| Option | Description |\n 248→|--------|-------------|\n 249→| `-b:a` | Bitrate: `64k` to `256k`. 128k is a good default |\n 250→| `-vbr` | `on` (default), `off`, `constrained` |\n 251→| `-application` | `audio` (default), `voip`, `lowdelay` |\n 252→\n 253→#### libvorbis (Vorbis)\n 254→\n 255→| Option | Description |\n 256→|--------|-------------|\n 257→| `-q:a` | VBR quality: -1 to 10. 5 is a good default |\n 258→| `-b:a` | ABR bitrate |\n 259→\n 260→#### flac (FLAC)\n 261→\n 262→| Option | Description |\n 263→|--------|-------------|\n 264→| `-compression_level` | 0 (fast) to 12 (best). 5 is default |\n 265→\n 266→---\n 267→\n 268→## Common Container Formats\n 269→\n 270→| Format | Extensions | Notes |\n 271→|--------|-----------|-------|\n 272→| `mp4` | .mp4, .m4a, .m4v | Use `-movflags +faststart` for web streaming |\n 273→| `matroska` | .mkv | Supports virtually all codecs |\n 274→| `webm` | .webm | VP8/VP9/AV1 + Vorbis/Opus for web |\n 275→| `avi` | .avi | Legacy; limited codec support |\n 276→| `mpegts` | .ts | Broadcast transport stream |\n 277→| `ogg` | .ogg, .ogv | Vorbis/Opus/Theora container |\n 278→| `wav` | .wav | Uncompressed PCM audio |\n 279→| `flac` | .flac | Lossless audio |\n 280→| `mp3` | .mp3 | MPEG audio layer 3 |\n 281→| `hls` | .m3u8 | HTTP Live Streaming |\n 282→| `dash` | .mpd | DASH adaptive streaming |\n 283→| `gif` | .gif | Animated GIF |\n 284→| `image2` | various | Image sequence input/output |\n 285→| `concat` | text file | Concatenation demuxer (file list) |\n 286→| `null` | — | Discard output (benchmarking) |\n 287→\n 288→---\n 289→\n 290→## Common Video Filters (`-vf`)\n 291→\n 292→| Filter | Description | Example |\n 293→|--------|-------------|---------|\n 294→| `scale=W:H` | Resize video. Use `-1` or `-2` to auto-calculate | `scale=1280:720`, `scale=-2:480` |\n 295→| `crop=W:H:X:Y` | Crop to WxH starting at X,Y | `crop=640:480:100:50` |\n 296→| `pad=W:H:X:Y:color` | Pad video with borders | `pad=1920:1080:(ow-iw)/2:(oh-ih)/2:black` |\n 297→| `overlay=X:Y` | Composite second input over first | `overlay=10:10` |\n 298→| `transpose=N` | Rotate: 0=90ccw+vflip, 1=90cw, 2=90ccw, 3=90cw+vflip | `transpose=1` |\n 299→| `hflip` / `vflip` | Horizontal / vertical flip | `hflip` |\n 300→| `rotate=angle` | Rotate by arbitrary angle (radians) | `rotate=PI/4` |\n 301→| `fps=N` | Change frame rate | `fps=30` |\n 302→| `setpts=expr` | Modify presentation timestamps | `setpts=0.5*PTS` (2x speed) |\n 303→| `trim=start:end` | Extract time range | `trim=start=10:end=20` |\n 304→| `drawtext=opts` | Overlay text | `drawtext=text='Hello':fontsize=24:x=10:y=10` |\n 305→| `fade=t=type:st=S:d=D` | Fade in/out | `fade=t=in:st=0:d=2` |\n 306→| `eq=opts` | Adjust brightness/contrast/saturation | `eq=brightness=0.1:contrast=1.2` |\n 307→| `format=pix_fmt` | Convert pixel format | `format=yuv420p` |\n 308→| `concat=n:v:a` | Concatenate segments | `concat=n=2:v=1:a=1` |\n 309→| `split` / `select` | Duplicate / select frames | `select='eq(pict_type,I)'` |\n 310→| `deinterlace` / `yadif` | Remove interlacing | `yadif=1` |\n 311→| `boxblur=R` | Apply box blur | `boxblur=5:1` |\n 312→| `subtitles=file` | Burn in subtitles from file | `subtitles=subs.srt` |\n 313→| `palettegen` / `paletteuse` | Generate/apply palette for GIF | Used in two-pass GIF creation |\n 314→| `colorchannelmixer` | Mix color channels | `colorchannelmixer=.3:.4:.3:0:.3:.4:.3:0:.3:.4:.3` (grayscale) |\n 315→\n 316→## Common Audio Filters (`-af`)\n 317→\n 318→| Filter | Description | Example |\n 319→|--------|-------------|---------|\n 320→| `volume=V` | Adjust volume | `volume=1.5`, `volume=-3dB` |\n 321→| `loudnorm` | EBU R128 loudness normalization | `loudnorm=I=-16:TP=-1.5:LRA=11` |\n 322→| `atempo=T` | Change tempo (0.5-100.0) | `atempo=2.0` (2x speed) |\n 323→| `aresample=rate` | Resample audio | `aresample=44100` |\n 324→| `amerge` | Merge audio channels | `amerge=inputs=2` |\n 325→| `afade=t=type:st=S:d=D` | Audio fade in/out | `afade=t=in:st=0:d=3` |\n 326→| `highpass=f=freq` | High-pass filter | `highpass=f=200` |\n 327→| `lowpass=f=freq` | Low-pass filter | `lowpass=f=3000` |\n 328→| `equalizer=f:t:w:g` | Parametric EQ | `equalizer=f=1000:t=q:w=1:g=5` |\n 329→| `acompressor` | Dynamic range compression | `acompressor=threshold=-20dB:ratio=4` |\n 330→| `silenceremove` | Remove silence | `silenceremove=1:0:-50dB` |\n 331→| `silencedetect` | Detect silence | `silencedetect=n=-30dB:d=2` |\n 332→| `adelay=delays` | Delay audio channels | `adelay=1000\\|1000` (ms) |\n 333→| `aecho=id:ig:delays:decays` | Add echo effect | `aecho=0.8:0.88:60:0.4` |\n 334→| `pan=layout:gains` | Remix channels | `pan=mono\\|c0=0.5*c0+0.5*c1` |\n 335→\n 336→---\n 337→\n 338→## Common Recipes\n 339→\n 340→### Convert format\n 341→\n 342→```bash\n 343→ffmpeg -i input.mkv output.mp4\n 344→```\n 345→\n 346→### Transcode with CRF quality\n 347→\n 348→```bash\n 349→ffmpeg -i input.mp4 -c:v libx264 -crf 20 -c:a aac -b:a 192k output.mp4\n 350→```\n 351→\n 352→### Extract audio\n 353→\n 354→```bash\n 355→ffmpeg -i video.mp4 -vn -c:a copy audio.m4a\n 356→```\n 357→\n 358→### Extract video (no audio)\n 359→\n 360→```bash\n 361→ffmpeg -i input.mp4 -an -c:v copy output.mp4\n 362→```\n 363→\n 364→### Trim / cut\n 365→\n 366→```bash\n 367→ffmpeg -ss 00:01:30 -to 00:03:00 -i input.mp4 -c copy output.mp4\n 368→```\n 369→\n 370→### Resize video\n 371→\n 372→```bash\n 373→ffmpeg -i input.mp4 -vf \"scale=1280:720\" -c:a copy output.mp4\n 374→```\n 375→\n 376→### Compress video (lower quality)\n 377→\n 378→```bash\n 379→ffmpeg -i input.mp4 -c:v libx264 -crf 28 -preset fast -c:a aac -b:a 128k output.mp4\n 380→```\n 381→\n 382→### Two-pass encoding\n 383→\n 384→```bash\n 385→ffmpeg -i input.mp4 -c:v libx264 -b:v 2M -pass 1 -f null /dev/null\n 386→ffmpeg -i input.mp4 -c:v libx264 -b:v 2M -pass 2 output.mp4\n 387→```\n 388→\n 389→### Concatenate files (concat demuxer)\n 390→\n 391→```bash\n 392→# files.txt contains:\n 393→# file 'part1.mp4'\n 394→# file 'part2.mp4'\n 395→ffmpeg -f concat -safe 0 -i files.txt -c copy output.mp4\n 396→```\n 397→\n 398→### Add subtitles (burn-in)\n 399→\n 400→```bash\n 401→ffmpeg -i input.mp4 -vf \"subtitles=subs.srt\" output.mp4\n 402→```\n 403→\n 404→### Create GIF\n 405→\n 406→```bash\n 407→ffmpeg -i input.mp4 -vf \"fps=10,scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse\" output.gif\n 408→```\n 409→\n 410→### Add watermark / overlay\n 411→\n 412→```bash\n 413→ffmpeg -i video.mp4 -i logo.png -filter_complex \"overlay=10:10\" output.mp4\n 414→```\n 415→\n 416→### Change speed (video + audio)\n 417→\n 418→```bash\n 419→ffmpeg -i input.mp4 -vf \"setpts=0.5*PTS\" -af \"atempo=2.0\" output.mp4\n 420→```\n 421→\n 422→### Extract frames as images\n 423→\n 424→```bash\n 425→ffmpeg -i input.mp4 -vf \"fps=1\" frame_%04d.png\n 426→```\n 427→\n 428→### Merge audio and video\n 429→\n 430→```bash\n 431→ffmpeg -i video.mp4 -i audio.m4a -c:v copy -c:a copy -shortest output.mp4\n 432→```\n 433→\n 434→### Normalize audio loudness\n 435→\n 436→```bash\n 437→ffmpeg -i input.mp4 -af \"loudnorm=I=-16:TP=-1.5:LRA=11\" -c:v copy output.mp4\n 438→```\n 439→\n 440→### Convert to web-optimized MP4\n 441→\n 442→```bash\n 443→ffmpeg -i input.mp4 -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k -movflags +faststart output.mp4\n 444→```\n 445→\n 446→### Probe file info (JSON)\n 447→\n 448→```bash\n 449→ffprobe -v quiet -print_format json -show_format -show_streams input.mp4\n 450→```\n 451→\n 452→### Get duration only\n 453→\n 454→```bash\n 455→ffprobe -v quiet -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 input.mp4\n 456→```\n 457→\n 458→### Get resolution only\n 459→\n 460→```bash\n 461→ffprobe -v quiet -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 input.mp4\n 462→```\n 463→\n 464→---\n 465→\n 466→## Source\n 467→\n 468→- Website: https://ffmpeg.org/\n 469→- Documentation: https://www.ffmpeg.org/documentation.html\n 470→- CLI reference: https://www.ffmpeg.org/ffmpeg.html\n 471→- Filters reference: https://www.ffmpeg.org/ffmpeg-filters.html\n 472→- Codecs reference: https://www.ffmpeg.org/ffmpeg-codecs.html\n 473→- Formats reference: https://www.ffmpeg.org/ffmpeg-formats.html\n 474→- Wiki: https://trac.ffmpeg.org/wiki\n 475→","isError":false},{"role":"tool","toolName":"Read","toolInput":{"file_path":"/home/pastilhas/projects/officer.dev/monorepo/seed/skills/sharp/SKILL.md"},"toolUseId":"toolu_017oEb4meiH27zdBGaMvyVMX","output":" 1→---\n 2→name: sharp\n 3→description: Process images using the sharp Node.js library. Use when the user wants to resize, convert, crop, composite, transform, or optimize images programmatically.\n 4→---\n 5→\n 6→# Sharp\n 7→\n 8→API reference for sharp — a high-performance Node.js image processing library built on libvips.\n 9→\n 10→Typically 4-5x faster than ImageMagick/GraphicsMagick. Supports JPEG, PNG, WebP, GIF, AVIF, TIFF, SVG, HEIF, JP2, and JXL.\n 11→\n 12→Official docs: https://sharp.pixelplumbing.com\n 13→Repository: https://github.com/lovell/sharp\n 14→\n 15→## Installation\n 16→\n 17→```bash\n 18→npm install sharp\n 19→```\n 20→\n 21→Requires Node.js ^18.17.0 or >= 20.3.0 (or Deno/Bun with Node-API v9).\n 22→\n 23→## Usage\n 24→\n 25→Sharp uses a fluent, chainable API. Every call returns a Sharp instance.\n 26→\n 27→```js\n 28→import sharp from 'sharp';\n 29→\n 30→await sharp('input.jpg')\n 31→ .resize(800, 600)\n 32→ .jpeg({ quality: 80 })\n 33→ .toFile('output.jpg');\n 34→```\n 35→\n 36→Sharp implements `stream.Duplex` — it can be piped to/from.\n 37→\n 38→---\n 39→\n 40→## Constructor\n 41→\n 42→```js\n 43→sharp([input], [options])\n 44→```\n 45→\n 46→- `input` (Buffer | string | Array): Image buffer, file path, array of inputs, or omit for stream input.\n 47→\n 48→### Options\n 49→\n 50→| Option | Type | Default | Description |\n 51→|--------|------|---------|-------------|\n 52→| `failOn` | string | `'warning'` | `'none'`, `'truncated'`, `'error'`, `'warning'` |\n 53→| `limitInputPixels` | number \\| boolean | `268402689` | Max pixels; `false` to disable |\n 54→| `unlimited` | boolean | `false` | Remove memory safety for JPEG/PNG/SVG/HEIF |\n 55→| `autoOrient` | boolean | `false` | Auto-rotate per EXIF Orientation |\n 56→| `sequentialRead` | boolean | `true` | Sequential vs random access |\n 57→| `density` | number | `72` | DPI for vector images (1-100000) |\n 58→| `ignoreIcc` | boolean | `false` | Ignore embedded ICC profile |\n 59→| `pages` | number | `1` | Pages to extract; `-1` for all |\n 60→| `page` | number | `0` | Starting page (zero-based) |\n 61→| `animated` | boolean | `false` | Read all frames (equiv. `pages: -1`) |\n 62→\n 63→### Raw Input\n 64→\n 65→```js\n 66→sharp(buffer, { raw: { width: 100, height: 100, channels: 4 } })\n 67→```\n 68→\n 69→| Property | Type | Description |\n 70→|----------|------|-------------|\n 71→| `width` | number | Pixel width |\n 72→| `height` | number | Pixel height |\n 73→| `channels` | number | 1-4 |\n 74→| `premultiplied` | boolean | Skip premultiplication (default `false`) |\n 75→\n 76→### Create New Image\n 77→\n 78→```js\n 79→sharp({ create: { width: 300, height: 200, channels: 4, background: '#ff0000' } })\n 80→```\n 81→\n 82→| Property | Type | Description |\n 83→|----------|------|-------------|\n 84→| `width` | number | Pixel width |\n 85→| `height` | number | Pixel height |\n 86→| `channels` | number | 3 (RGB) or 4 (RGBA) |\n 87→| `background` | string \\| Object | Color (parsed by color module) |\n 88→| `noise` | Object | `{ type: 'gaussian', mean: 128, sigma: 30 }` |\n 89→\n 90→### Render Text\n 91→\n 92→```js\n 93→sharp({ text: { text: 'Hello', font: 'Arial', dpi: 150 } })\n 94→```\n 95→\n 96→| Property | Type | Default | Description |\n 97→|----------|------|---------|-------------|\n 98→| `text` | string | -- | UTF-8; supports Pango markup |\n 99→| `font` | string | -- | Font name |\n 100→| `fontfile` | string | -- | Absolute path to font file |\n 101→| `width` | number | `0` | Word-wrap boundary; 0 = no wrap |\n 102→| `height` | number | `0` | Max height |\n 103→| `align` | string | `'left'` | `'left'`, `'centre'`, `'center'`, `'right'` |\n 104→| `justify` | boolean | `false` | Text justification |\n 105→| `dpi` | number | `72` | Render resolution |\n 106→| `rgba` | boolean | `false` | RGBA for color emoji/Pango markup |\n 107→| `spacing` | number | `0` | Line height in points |\n 108→| `wrap` | string | `'word'` | `'word'`, `'char'`, `'word-char'`, `'none'` |\n 109→\n 110→### Join Array\n 111→\n 112→```js\n 113→sharp([img1, img2, img3], { join: { across: 3, shim: 10 } })\n 114→```\n 115→\n 116→| Property | Type | Default | Description |\n 117→|----------|------|---------|-------------|\n 118→| `across` | number | `1` | Images per row |\n 119→| `animated` | boolean | `false` | Join as animated image |\n 120→| `shim` | number | `0` | Pixel gap between images |\n 121→| `background` | string \\| Object | -- | Gap fill color |\n 122→| `halign` | string | `'left'` | `'left'`, `'centre'`, `'right'` |\n 123→| `valign` | string | `'top'` | `'top'`, `'centre'`, `'bottom'` |\n 124→\n 125→### Clone\n 126→\n 127→```js\n 128→const pipeline = sharp('input.jpg');\n 129→const clone1 = pipeline.clone().resize(200).toFile('thumb.jpg');\n 130→const clone2 = pipeline.clone().resize(800).toFile('large.jpg');\n 131→```\n 132→\n 133→---\n 134→\n 135→## Resize\n 136→\n 137→```js\n 138→.resize([width], [height], [options])\n 139→```\n 140→\n 141→| Option | Type | Default | Description |\n 142→|--------|------|---------|-------------|\n 143→| `width` | number | -- | Target width (null to auto-scale) |\n 144→| `height` | number | -- | Target height (null to auto-scale) |\n 145→| `fit` | string | `'cover'` | `'cover'`, `'contain'`, `'fill'`, `'inside'`, `'outside'` |\n 146→| `position` | string | `'centre'` | Gravity/position for cover/contain |\n 147→| `background` | string \\| Object | `{r:0,g:0,b:0,alpha:1}` | Fill color for `contain` |\n 148→| `kernel` | string | `'lanczos3'` | `'nearest'`, `'linear'`, `'cubic'`, `'mitchell'`, `'lanczos2'`, `'lanczos3'` |\n 149→| `withoutEnlargement` | boolean | `false` | Don't upscale |\n 150→| `withoutReduction` | boolean | `false` | Don't downscale |\n 151→| `fastShrinkOnLoad` | boolean | `true` | JPEG/WebP shrink-on-load |\n 152→\n 153→**Fit modes:**\n 154→- `cover` — crop to fill both dimensions\n 155→- `contain` — letterbox within dimensions\n 156→- `fill` — stretch to exact dimensions (ignores aspect ratio)\n 157→- `inside` — fit within, no exceeding\n 158→- `outside` — minimum size meeting both dimensions\n 159→\n 160→**Position values:** `top`, `right top`, `right`, `right bottom`, `bottom`, `left bottom`, `left`, `left top`, `north`, `northeast`, `east`, `southeast`, `south`, `southwest`, `west`, `northwest`, `centre`/`center`\n 161→\n 162→**Strategy (cover only):** `entropy`, `attention`\n 163→\n 164→Only one resize per pipeline.\n 165→\n 166→---\n 167→\n 168→## Operations\n 169→\n 170→### Rotation & Orientation\n 171→\n 172→| Method | Description |\n 173→|--------|-------------|\n 174→| `.rotate([angle], [options])` | Rotate by degrees; omit angle for EXIF auto-rotate. `options.background` for fill color |\n 175→| `.autoOrient()` | Auto-orient from EXIF, then remove Orientation tag |\n 176→| `.flip([flip])` | Vertical mirror (default `true`) |\n 177→| `.flop([flop])` | Horizontal mirror (default `true`) |\n 178→\n 179→### Transform\n 180→\n 181→| Method | Description |\n 182→|--------|-------------|\n 183→| `.affine(matrix, [options])` | 2x2 affine transform. Options: `background`, `idx`, `idy`, `odx`, `ody`, `interpolator` |\n 184→| `.extend(extend)` | Add padding. Number for uniform, or `{ top, right, bottom, left, extendWith, background }`. `extendWith`: `'background'`, `'copy'`, `'repeat'`, `'mirror'` |\n 185→| `.extract({ left, top, width, height })` | Crop region. Can be called before and/or after resize |\n 186→| `.trim([options])` | Auto-crop to content. Options: `background` (default top-left pixel), `threshold` (default `10`), `lineArt` |\n 187→\n 188→### Enhancement\n 189→\n 190→| Method | Description |\n 191→|--------|-------------|\n 192→| `.sharpen([options])` | Sharpen. `options.sigma` (0.000001-10), `.m1` (flat), `.m2` (jagged), `.x1`, `.y2`, `.y3` |\n 193→| `.blur([options])` | No args: 3x3 box blur. `options.sigma` (0.3-1000) for Gaussian. Options: `precision`, `minAmplitude` |\n 194→| `.median([size])` | Median filter, default 3x3 |\n 195→| `.gamma([gamma], [gammaOut])` | Gamma correction (1.0-3.0, default 2.2) |\n 196→| `.normalise([options])` | Stretch luminance. `options.lower` (default `1`), `.upper` (default `99`) percentiles |\n 197→| `.clahe({ width, height, [maxSlope] })` | Contrast Limited Adaptive Histogram Equalization |\n 198→\n 199→### Morphology\n 200→\n 201→| Method | Description |\n 202→|--------|-------------|\n 203→| `.dilate([width])` | Dilation, default 1px |\n 204→| `.erode([width])` | Erosion, default 1px |\n 205→\n 206→### Pixel Operations\n 207→\n 208→| Method | Description |\n 209→|--------|-------------|\n 210→| `.negate([options])` | Invert colors. `options.alpha` (default `true`) |\n 211→| `.threshold([value], [options])` | Binarize at threshold (0-255, default 128). `options.greyscale` (default `true`) |\n 212→| `.boolean(operand, operator)` | Bitwise op with another image: `'and'`, `'or'`, `'eor'` |\n 213→| `.linear([a], [b])` | Per-channel linear transform: `a * pixel + b` |\n 214→| `.recomb(matrix)` | 3x3 or 4x4 color recombination matrix |\n 215→| `.modulate([options])` | Adjust `brightness` (multiply), `saturation` (multiply), `hue` (degrees), `lightness` (add) |\n 216→| `.convolve(kernel)` | Custom convolution: `{ width, height, kernel, scale, offset }` |\n 217→| `.flatten([options])` | Merge alpha with `options.background`, remove alpha |\n 218→| `.unflatten()` | Add alpha; white becomes transparent (experimental) |\n 219→\n 220→---\n 221→\n 222→## Colour\n 223→\n 224→| Method | Description |\n 225→|--------|-------------|\n 226→| `.tint(color)` | Apply tint, preserving alpha |\n 227→| `.greyscale([bool])` | Convert to 8-bit greyscale (alias: `.grayscale()`) |\n 228→| `.pipelineColourspace(space)` | Set pipeline colorspace (e.g. `'rgb16'`, `'lab'`, `'grey16'`) |\n 229→| `.toColourspace(space)` | Set output colorspace (e.g. `'srgb'`, `'cmyk'`, `'b-w'`) |\n 230→\n 231→---\n 232→\n 233→## Channel\n 234→\n 235→| Method | Description |\n 236→|--------|-------------|\n 237→| `.removeAlpha()` | Remove alpha channel |\n 238→| `.ensureAlpha([alpha])` | Add alpha if missing. `alpha`: 0 (transparent) to 1 (opaque, default) |\n 239→| `.extractChannel(channel)` | Extract single channel: `0`-`3` or `'red'`, `'green'`, `'blue'`, `'alpha'` |\n 240→| `.joinChannel(images, [options])` | Add channel(s) from other image(s) |\n 241→| `.bandbool(op)` | Bitwise across all bands: `'and'`, `'or'`, `'eor'` |\n 242→\n 243→---\n 244→\n 245→## Composite\n 246→\n 247→```js\n 248→.composite(images)\n 249→```\n 250→\n 251→Overlay images onto the pipeline image. `images` is an array of objects:\n 252→\n 253→| Property | Type | Default | Description |\n 254→|----------|------|---------|-------------|\n 255→| `input` | Buffer \\| string | -- | Image data, file path, or `create`/`text` object |\n 256→| `blend` | string | `'over'` | Blend mode |\n 257→| `gravity` | string | `'centre'` | Placement gravity |\n 258→| `top` | number | -- | Pixel offset from top (overrides gravity) |\n 259→| `left` | number | -- | Pixel offset from left (overrides gravity) |\n 260→| `tile` | boolean | `false` | Repeat overlay across image |\n 261→| `premultiplied` | boolean | `false` | Skip premultiplication |\n 262→| `density` | number | `72` | DPI for vector overlays |\n 263→\n 264→**Blend modes:** `over`, `multiply`, `screen`, `overlay`, `darken`, `lighten`, `hard-light`, `soft-light`, `difference`, `exclusion`, `colour-dodge`, `colour-burn`, `add`, `saturate`, `clear`, `source`, `in`, `out`, `atop`, `dest`, `dest-over`, `dest-in`, `dest-out`, `dest-atop`, `xor`\n 265→\n 266→```js\n 267→await sharp('base.png')\n 268→ .composite([{ input: 'overlay.png', gravity: 'southeast' }])\n 269→ .toFile('output.png');\n 270→```\n 271→\n 272→---\n 273→\n 274→## Output\n 275→\n 276→### Write to File\n 277→\n 278→```js\n 279→await sharp('input.jpg').resize(800).toFile('output.jpg');\n 280→```\n 281→\n 282→Format inferred from extension. Returns `{ format, size, width, height, channels, premultiplied }`.\n 283→\n 284→### Write to Buffer\n 285→\n 286→```js\n 287→const buffer = await sharp('input.jpg').resize(800).toBuffer();\n 288→// or with info:\n 289→const { data, info } = await sharp('input.jpg').resize(800).toBuffer({ resolveWithObject: true });\n 290→```\n 291→\n 292→### Format Methods\n 293→\n 294→#### JPEG\n 295→\n 296→```js\n 297→.jpeg([options])\n 298→```\n 299→\n 300→| Option | Type | Default | Description |\n 301→|--------|------|---------|-------------|\n 302→| `quality` | number | `80` | 1-100 |\n 303→| `progressive` | boolean | `false` | Progressive JPEG |\n 304→| `chromaSubsampling` | string | `'4:2:0'` | `'4:2:0'` or `'4:4:4'` |\n 305→| `mozjpeg` | boolean | `false` | MozJPEG optimizations |\n 306→| `force` | boolean | `true` | Force JPEG output |\n 307→\n 308→#### PNG\n 309→\n 310→```js\n 311→.png([options])\n 312→```\n 313→\n 314→| Option | Type | Default | Description |\n 315→|--------|------|---------|-------------|\n 316→| `progressive` | boolean | `false` | Progressive (interlace) |\n 317→| `compressionLevel` | number | `6` | 0-9 |\n 318→| `adaptiveFiltering` | boolean | `false` | Adaptive row filtering |\n 319→| `palette` | boolean | `false` | Quantise to palette |\n 320→| `quality` | number | `100` | Palette quality (1-100) |\n 321→| `effort` | number | `7` | CPU effort (1-10, palette mode) |\n 322→| `colours`/`colors` | number | `256` | Max palette colors (2-256) |\n 323→| `dither` | number | `1.0` | Floyd-Steinberg dithering level |\n 324→| `force` | boolean | `true` | Force PNG output |\n 325→\n 326→#### WebP\n 327→\n 328→```js\n 329→.webp([options])\n 330→```\n 331→\n 332→| Option | Type | Default | Description |\n 333→|--------|------|---------|-------------|\n 334→| `quality` | number | `80` | 1-100 |\n 335→| `alphaQuality` | number | `100` | 0-100 |\n 336→| `lossless` | boolean | `false` | Lossless compression |\n 337→| `nearLossless` | boolean | `false` | Near-lossless mode |\n 338→| `smartSubsample` | boolean | `false` | Smart chroma subsampling |\n 339→| `preset` | string | `'default'` | `'default'`, `'photo'`, `'picture'`, `'drawing'`, `'icon'`, `'text'` |\n 340→| `effort` | number | `4` | 0-6 |\n 341→| `loop` | number | `0` | Animation loops (0 = infinite) |\n 342→| `delay` | number \\| Array | -- | Frame delay(s) in ms |\n 343→| `force` | boolean | `true` | Force WebP output |\n 344→\n 345→#### AVIF\n 346→\n 347→```js\n 348→.avif([options])\n 349→```\n 350→\n 351→| Option | Type | Default | Description |\n 352→|--------|------|---------|-------------|\n 353→| `quality` | number | `50` | 1-100 |\n 354→| `lossless` | boolean | `false` | Lossless mode |\n 355→| `effort` | number | `4` | 0-9 |\n 356→| `chromaSubsampling` | string | `'4:4:4'` | Chroma subsampling |\n 357→| `bitdepth` | number | `8` | 8, 10, or 12 |\n 358→\n 359→#### GIF\n 360→\n 361→```js\n 362→.gif([options])\n 363→```\n 364→\n 365→| Option | Type | Default | Description |\n 366→|--------|------|---------|-------------|\n 367→| `reuse` | boolean | `true` | Reuse palette |\n 368→| `progressive` | boolean | `false` | Progressive (interlace) |\n 369→| `colours`/`colors` | number | `256` | 2-256 |\n 370→| `effort` | number | `7` | 1-10 |\n 371→| `dither` | number | `1.0` | 0-1 |\n 372→| `loop` | number | `0` | 0 = infinite |\n 373→| `delay` | number \\| Array | -- | Frame delay(s) in ms |\n 374→| `force` | boolean | `true` | Force GIF output |\n 375→\n 376→#### TIFF\n 377→\n 378→```js\n 379→.tiff([options])\n 380→```\n 381→\n 382→| Option | Type | Default | Description |\n 383→|--------|------|---------|-------------|\n 384→| `quality` | number | `80` | 1-100 |\n 385→| `compression` | string | `'jpeg'` | `'none'`, `'jpeg'`, `'deflate'`, `'packbits'`, `'lzw'`, `'webp'`, `'zstd'`, `'jp2k'`, `'ccittfax4'` |\n 386→| `predictor` | string | `'horizontal'` | `'none'`, `'horizontal'`, `'float'` |\n 387→| `pyramid` | boolean | `false` | Write image pyramid |\n 388→| `tile` | boolean | `false` | Tiled TIFF |\n 389→| `tileWidth` | number | `256` | Tile width |\n 390→| `tileHeight` | number | `256` | Tile height |\n 391→| `bitdepth` | number | `8` | 1, 2, 4, or 8 |\n 392→| `force` | boolean | `true` | Force TIFF output |\n 393→\n 394→#### HEIF\n 395→\n 396→```js\n 397→.heif({ compression: 'hevc' })\n 398→```\n 399→\n 400→| Option | Type | Default | Description |\n 401→|--------|------|---------|-------------|\n 402→| `compression` | string | required | `'av1'` or `'hevc'` |\n 403→| `quality` | number | `50` | 1-100 |\n 404→| `lossless` | boolean | `false` | Lossless mode |\n 405→| `effort` | number | `4` | 0-9 |\n 406→| `bitdepth` | number | `8` | 8, 10, or 12 |\n 407→\n 408→#### Raw\n 409→\n 410→```js\n 411→.raw([options])\n 412→```\n 413→\n 414→- `options.depth` (string, default `'uchar'`): `'char'`, `'uchar'`, `'short'`, `'ushort'`, `'int'`, `'uint'`, `'float'`, `'double'`\n 415→\n 416→#### Tile (DZI / Zoomify / IIIF)\n 417→\n 418→```js\n 419→.tile([options])\n 420→```\n 421→\n 422→| Option | Type | Default | Description |\n 423→|--------|------|---------|-------------|\n 424→| `size` | number | `256` | Tile size (1-8192) |\n 425→| `overlap` | number | `0` | Tile overlap (0-8192) |\n 426→| `layout` | string | `'dz'` | `'dz'`, `'iiif'`, `'iiif3'`, `'zoomify'`, `'google'` |\n 427→| `container` | string | `'fs'` | `'fs'` or `'zip'` |\n 428→| `angle` | number | `0` | Rotation (multiple of 90) |\n 429→| `background` | string \\| Object | white | Fill color |\n 430→\n 431→---\n 432→\n 433→## Metadata & Stats\n 434→\n 435→### metadata()\n 436→\n 437→```js\n 438→const meta = await sharp('input.jpg').metadata();\n 439→```\n 440→\n 441→Returns without decoding pixels:\n 442→\n 443→| Property | Type | Description |\n 444→|----------|------|-------------|\n 445→| `format` | string | `'jpeg'`, `'png'`, `'webp'`, `'gif'`, `'svg'`, etc. |\n 446→| `width` | number | Pixel width |\n 447→| `height` | number | Pixel height |\n 448→| `space` | string | Color space (`'srgb'`, `'rgb'`, `'cmyk'`, `'b-w'`, etc.) |\n 449→| `channels` | number | Band count |\n 450→| `depth` | string | Pixel depth (`'uchar'`, `'ushort'`, `'float'`, etc.) |\n 451→| `density` | number | DPI |\n 452→| `chromaSubsampling` | string | e.g. `'4:2:0'` |\n 453→| `isProgressive` | boolean | Progressive/interlaced |\n 454→| `hasAlpha` | boolean | Has alpha channel |\n 455→| `hasProfile` | boolean | Has ICC profile |\n 456→| `orientation` | number | EXIF orientation (1-8) |\n 457→| `pages` | number | Page count |\n 458→| `size` | number | Total bytes (Buffer/Stream input) |\n 459→| `exif` | Buffer | Raw EXIF |\n 460→| `icc` | Buffer | ICC profile |\n 461→| `xmp` | Buffer | XMP data |\n 462→\n 463→### stats()\n 464→\n 465→```js\n 466→const stats = await sharp('input.jpg').stats();\n 467→```\n 468→\n 469→Returns pixel-derived statistics:\n 470→\n 471→| Property | Type | Description |\n 472→|----------|------|-------------|\n 473→| `channels` | Array | Per-channel: `min`, `max`, `sum`, `mean`, `stdev`, `minX`, `minY`, `maxX`, `maxY` |\n 474→| `isOpaque` | boolean | Fully opaque |\n 475→| `entropy` | number | Greyscale entropy |\n 476→| `sharpness` | number | Laplacian sharpness |\n 477→| `dominant` | Object | Dominant sRGB color |\n 478→\n 479→---\n 480→\n 481→## Metadata Preservation\n 482→\n 483→By default, sharp strips all metadata and converts to sRGB.\n 484→\n 485→| Method | Description |\n 486→|--------|-------------|\n 487→| `.keepMetadata()` | Preserve all metadata (EXIF, ICC, XMP, IPTC) |\n 488→| `.keepExif()` | Preserve EXIF only |\n 489→| `.withExif(exif)` | Set EXIF (replaces input). Object keyed by IFD |\n 490→| `.withExifMerge(exif)` | Merge with existing EXIF |\n 491→| `.keepIccProfile()` | Preserve ICC profile |\n 492→| `.withIccProfile(icc, [options])` | Set ICC: path or `'srgb'`, `'p3'`, `'cmyk'` |\n 493→| `.keepXmp()` | Preserve XMP |\n 494→| `.withXmp(xmp)` | Set XMP (XML string) |\n 495→| `.withMetadata([options])` | Preserve most metadata. Options: `orientation`, `density` |\n 496→\n 497→---\n 498→\n 499→## Timeout\n 500→\n 501→```js\n 502→.timeout({ seconds: 30 })\n 503→```\n 504→\n 505→Abort processing after N seconds. `0` = no timeout (default).\n 506→\n 507→---\n 508→\n 509→## Utility (Static)\n 510→\n 511→| Property/Method | Description |\n 512→|-----------------|-------------|\n 513→| `sharp.format` | Object with available input/output format booleans |\n 514→| `sharp.versions` | Version info for sharp, libvips, dependencies |\n 515→| `sharp.interpolators` | Enum: `nearest`, `bilinear`, `bicubic`, `lbb`, `nohalo`, `vsqbs` |\n 516→| `sharp.cache([options])` | Get/set cache: `{ memory: 50, files: 20, items: 100 }` |\n 517→| `sharp.concurrency([n])` | Get/set thread count (default: CPU cores) |\n 518→| `sharp.counters()` | Returns `{ queue, process }` |\n 519→| `sharp.simd([bool])` | Enable/disable SIMD (default `true`) |\n 520→| `sharp.block({ operation })` | Block specific operations |\n 521→| `sharp.unblock({ operation })` | Unblock operations |\n 522→\n 523→---\n 524→\n 525→## Common Recipes\n 526→\n 527→### Resize and convert format\n 528→\n 529→```js\n 530→await sharp('input.png')\n 531→ .resize(800, 600)\n 532→ .webp({ quality: 80 })\n 533→ .toFile('output.webp');\n 534→```\n 535→\n 536→### Resize to fit within bounds (no upscale)\n 537→\n 538→```js\n 539→await sharp('input.jpg')\n 540→ .resize(1200, 800, { fit: 'inside', withoutEnlargement: true })\n 541→ .toFile('output.jpg');\n 542→```\n 543→\n 544→### Create thumbnail (cover crop)\n 545→\n 546→```js\n 547→await sharp('input.jpg')\n 548→ .resize(250, 250, { fit: 'cover', position: 'attention' })\n 549→ .toFile('thumb.jpg');\n 550→```\n 551→\n 552→### Crop region\n 553→\n 554→```js\n 555→await sharp('input.jpg')\n 556→ .extract({ left: 100, top: 50, width: 400, height: 300 })\n 557→ .toFile('cropped.jpg');\n 558→```\n 559→\n 560→### Add watermark overlay\n 561→\n 562→```js\n 563→await sharp('photo.jpg')\n 564→ .composite([{ input: 'watermark.png', gravity: 'southeast' }])\n 565→ .toFile('watermarked.jpg');\n 566→```\n 567→\n 568→### Composite text overlay\n 569→\n 570→```js\n 571→await sharp('photo.jpg')\n 572→ .composite([{\n 573→ input: { text: { text: 'Hello World', font: 'sans', dpi: 200, rgba: true } },\n 574→ gravity: 'south'\n 575→ }])\n 576→ .toFile('annotated.jpg');\n 577→```\n 578→\n 579→### Convert to greyscale\n 580→\n 581→```js\n 582→await sharp('input.jpg')\n 583→ .greyscale()\n 584→ .toFile('grey.jpg');\n 585→```\n 586→\n 587→### Blur\n 588→\n 589→```js\n 590→await sharp('input.jpg')\n 591→ .blur({ sigma: 5 })\n 592→ .toFile('blurred.jpg');\n 593→```\n 594→\n 595→### Rotate\n 596→\n 597→```js\n 598→await sharp('input.jpg')\n 599→ .rotate(90)\n 600→ .toFile('rotated.jpg');\n 601→```\n 602→\n 603→### Auto-orient from EXIF\n 604→\n 605→```js\n 606→await sharp('input.jpg')\n 607→ .autoOrient()\n 608→ .toFile('oriented.jpg');\n 609→```\n 610→\n 611→### Extend with padding\n 612→\n 613→```js\n 614→await sharp('input.png')\n 615→ .extend({ top: 20, bottom: 20, left: 20, right: 20, background: '#ffffff' })\n 616→ .toFile('padded.png');\n 617→```\n 618→\n 619→### Auto-trim whitespace\n 620→\n 621→```js\n 622→await sharp('input.png')\n 623→ .trim({ threshold: 10 })\n 624→ .toFile('trimmed.png');\n 625→```\n 626→\n 627→### Optimize JPEG for web\n 628→\n 629→```js\n 630→await sharp('input.jpg')\n 631→ .resize(1920, null, { withoutEnlargement: true })\n 632→ .jpeg({ quality: 75, mozjpeg: true, progressive: true })\n 633→ .toFile('optimized.jpg');\n 634→```\n 635→\n 636→### Generate AVIF from JPEG\n 637→\n 638→```js\n 639→await sharp('input.jpg')\n 640→ .avif({ quality: 50, effort: 4 })\n 641→ .toFile('output.avif');\n 642→```\n 643→\n 644→### Extract channel\n 645→\n 646→```js\n 647→await sharp('input.png')\n 648→ .extractChannel('red')\n 649→ .toFile('red-channel.png');\n 650→```\n 651→\n 652→### Get image metadata\n 653→\n 654→```js\n 655→const { width, height, format, space } = await sharp('input.jpg').metadata();\n 656→```\n 657→\n 658→### Buffer round-trip\n 659→\n 660→```js\n 661→const buffer = await sharp('input.jpg')\n 662→ .resize(300)\n 663→ .png()\n 664→ .toBuffer();\n 665→```\n 666→\n 667→### Create solid color image\n 668→\n 669→```js\n 670→await sharp({ create: { width: 100, height: 100, channels: 4, background: '#ff6600' } })\n 671→ .png()\n 672→ .toFile('orange.png');\n 673→```\n 674→\n 675→### Join images into grid\n 676→\n 677→```js\n 678→await sharp(['a.png', 'b.png', 'c.png', 'd.png'], { join: { across: 2 } })\n 679→ .toFile('grid.png');\n 680→```\n 681→\n 682→### Preserve metadata\n 683→\n 684→```js\n 685→await sharp('input.jpg')\n 686→ .resize(800)\n 687→ .keepMetadata()\n 688→ .toFile('output.jpg');\n 689→```\n 690→\n 691→### Animated GIF resize\n 692→\n 693→```js\n 694→await sharp('input.gif', { animated: true })\n 695→ .resize(200)\n 696→ .gif()\n 697→ .toFile('small.gif');\n 698→```\n 699→\n 700→### Multiple outputs from one input\n 701→\n 702→```js\n 703→const pipeline = sharp('input.jpg');\n 704→await Promise.all([\n 705→ pipeline.clone().resize(200).toFile('thumb.jpg'),\n 706→ pipeline.clone().resize(800).toFile('medium.jpg'),\n 707→ pipeline.clone().resize(1600).toFile('large.jpg'),\n 708→]);\n 709→```\n 710→\n 711→---\n 712→\n 713→## Source\n 714→\n 715→- Repository: https://github.com/lovell/sharp\n 716→- Documentation: https://sharp.pixelplumbing.com\n 717→- API — Constructor: https://sharp.pixelplumbing.com/api-constructor\n 718→- API — Input: https://sharp.pixelplumbing.com/api-input\n 719→- API — Resize: https://sharp.pixelplumbing.com/api-resize\n 720→- API — Operations: https://sharp.pixelplumbing.com/api-operation\n 721→- API — Colour: https://sharp.pixelplumbing.com/api-colour\n 722→- API — Channel: https://sharp.pixelplumbing.com/api-channel\n 723→- API — Composite: https://sharp.pixelplumbing.com/api-composite\n 724→- API — Output: https://sharp.pixelplumbing.com/api-output\n 725→- API — Utility: https://sharp.pixelplumbing.com/api-utility\n 726→","isError":false},{"role":"user","text":"is there a health checkpoint?"},{"role":"system","text":"\n\nYou have access to the following skills. When a user's request matches a skill, read its SKILL.md file for detailed instructions before proceeding.\n\nAvailable skills:\n- whisper.cpp: Transcribe audio files to text using whisper.cpp. Use when the user wants to transcribe audio, convert speech to text, or extract text from an audio/video file. (read /home/pastilhas/projects/officer.dev/monorepo/seed/skills/whisper.cpp/SKILL.md for full instructions)\n- ffmpeg: Process audio and video files using ffmpeg/ffprobe. Use when the user wants to convert, transcode, trim, merge, extract, resize, compress, or analyze multimedia files. (read /home/pastilhas/projects/officer.dev/monorepo/seed/skills/ffmpeg/SKILL.md for full instructions)\n- fizzy-cli: Manage Fizzy boards, cards, columns, and comments from the command line. Use when the user wants to create, list, update, or organize cards and boards on Fizzy. (read /home/pastilhas/projects/officer.dev/monorepo/seed/skills/fizzy-cli/SKILL.md for full instructions)\n- mlx.audio: Generate speech from text and transcribe audio using mlx-audio. Use when the user wants text-to-speech synthesis, speech-to-text transcription, voice cloning, audio separation, or speech-to-speech processing on Apple Silicon. (read /home/pastilhas/projects/officer.dev/monorepo/seed/skills/mlxaudio/SKILL.md for full instructions)\n- sharp: Process images using the sharp Node.js library. Use when the user wants to resize, convert, crop, composite, transform, or optimize images programmatically. (read /home/pastilhas/projects/officer.dev/monorepo/seed/skills/sharp/SKILL.md for full instructions)\n- mutagen: Read and write audio metadata using the mutagen Python library. Use when the user wants to read, edit, embed, or remove tags (title, artist, album, cover art, lyrics, etc.) in MP3, FLAC, MP4/M4A, OGG, and other audio files. (read /home/pastilhas/projects/officer.dev/monorepo/seed/skills/mutagen/SKILL.md for full instructions)\n- Convert Audio To MP3: Convert audio files to MP3 320kbps using a shell script. Use when the user wants to convert an artist's audio files (FLAC, WAV, OGG, etc.) to MP3 format. (read /home/pastilhas/projects/officer.dev/monorepo/seed/skills/convert-audio-to-mp3/SKILL.md for full instructions)\n- Isoflow: Generate isometric infrastructure and network diagrams as JSON. Use when the user wants to create architecture diagrams, network topologies, cloud infrastructure visuals, or any isometric diagram using Isoflow. (read /home/pastilhas/projects/officer.dev/data/skills/isoflow/SKILL.md for full instructions)"},{"role":"assistant","text":"\n\nLet me check if the whisper.cpp server has a health endpoint."},{"role":"tool","toolName":"Bash","toolInput":{"command":"curl -s -o /dev/null -w \"%{http_code}\" http://macmini:8178/","description":"Check whisper.cpp server root endpoint","timeout":5000},"toolUseId":"toolu_0117qh7wZ5p86iVCoZwYxYMX","output":"200","isError":false},{"role":"tool","toolName":"Bash","toolInput":{"command":"curl -s http://macmini:8178/ | head -c 2000","description":"Get response body from whisper.cpp root","timeout":5000},"toolUseId":"toolu_011A43BxqWNUpnuKRaEv5JA5","output":" \n \n Whisper.cpp Server\n \n \n \n \n \n Whisper.cpp Server
\n\n /inference
\n \n curl 127.0.0.1:8178/inference \\\n -H \"Content-Type: multipart/form-data\" \\\n -F file=\"@<file-path>\" \\\n -F temperature=\"0.0\" \\\n -F temperature_inc=\"0.2\" \\\n -F response_format=\"json\"\n
\n\n /load
\n \n curl 127.0.0.1:8178/load \\\n -H \"Content-Type: multipart/form-data\" \\\n -F model=\"<path-to-model-file>\"\n
\n\n \n
Try it out
\n
-
+
{registrationOpen ? : }
);
}
-
diff --git a/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx b/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx
index 03d13b86..6ff9aab2 100644
--- a/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx
+++ b/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx
@@ -7,17 +7,20 @@ import { useState } from 'react';
import { toast } from 'sonner';
import { useForm } from 'hooks/useForm';
import { useAuth } from 'hooks/useAuth';
+import { useGlobal } from 'hooks/useGlobal';
export function Login() {
const [isSubmitting, setIsSubmitting] = useState(false);
const { state, formRef, update, isValid } = useForm({}, validateForm);
const { signin } = useAuth();
+ const [, setDuckHidden] = useGlobal('DUCK_HIDDEN', false);
const handleSubmit = async (ev: React.FormEvent) => {
ev.preventDefault();
if (!isValid || isSubmitting) return;
setIsSubmitting(true);
+ setDuckHidden(true);
try {
await signin({ email: state.email!, password: state.password! });
window.location.href = '/';
@@ -31,7 +34,7 @@ export function Login() {
};
return (
-
+
Welcome Back
Sign in to your account
diff --git a/src/apps/officer-web/Screens/Authentication/Layout/AuthenticationLayout.tsx b/src/apps/officer-web/Screens/Authentication/Layout/AuthenticationLayout.tsx
index cf503f6b..6c4bac2d 100644
--- a/src/apps/officer-web/Screens/Authentication/Layout/AuthenticationLayout.tsx
+++ b/src/apps/officer-web/Screens/Authentication/Layout/AuthenticationLayout.tsx
@@ -7,7 +7,7 @@ export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
-
+
{children}
diff --git a/src/apps/officer-web/Screens/Authentication/Layout/Background.tsx b/src/apps/officer-web/Screens/Authentication/Layout/Background.tsx
index b9511e7e..30688686 100644
--- a/src/apps/officer-web/Screens/Authentication/Layout/Background.tsx
+++ b/src/apps/officer-web/Screens/Authentication/Layout/Background.tsx
@@ -1,8 +1,11 @@
import { DuckAvatar } from "./DuckAvatar";
import { PixelGrid } from "@/components/PixelGrid";
+import { useGlobal } from 'hooks/useGlobal';
import landscapebg from './landscape1.jpg';
export function Background() {
+ const [duckHidden] = useGlobal('DUCK_HIDDEN', false);
+
return (
);
};
diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/Widget.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/Widget.tsx
index a96ee8cb..41d1943a 100644
--- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/Widget.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/Widget.tsx
@@ -1,77 +1,58 @@
import { Link } from 'react-router';
-import { MessageSquare, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
-import { Card } from '@/components/Card';
+import { MessageSquare, Trash2 } from 'lucide-react';
+import { Widget } from '@/components/Widget';
import { useChatSessions } from '@/state/useChatSessions';
-import { useUserState } from '@/state/useUserState';
export const ChatHistory = () => {
- const [collapsed, setCollapsed] = useUserState('widget:chatHistory:collapsed', true);
const { sessions, deleteSession } = useChatSessions();
return (
-
-
-
-
- Chat History
-
-
-
- {!collapsed && (
- <>
-
- {sessions.length === 0 ? (
-
No sessions yet
- ) : (
-
- {sessions.map((session) => (
- -
-
+
+ {sessions.length === 0 ? (
+
No sessions yet
+ ) : (
+
+ {sessions.map((session) => (
+ -
+
+
+
+
{session.title}
+
+ {new Date(session.createdAt).toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
-
-
- {session.title}
-
- {new Date(session.createdAt).toLocaleDateString(undefined, {
- month: 'short',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- })}
-
- {session.provider === 'claude' ? 'Claude' : 'OpenCode'}
-
-
-
-
-
-
- ))}
-
- )}
-
- >
+ {session.provider === 'claude' ? 'Claude' : 'OpenCode'}
+
+
+
+
+
+
+ ))}
+
)}
-
-
+
+
);
};
diff --git a/src/apps/officer-web/Screens/Dashboard/Files/Widget.tsx b/src/apps/officer-web/Screens/Dashboard/Files/Widget.tsx
index f45d220a..f7c552aa 100644
--- a/src/apps/officer-web/Screens/Dashboard/Files/Widget.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Files/Widget.tsx
@@ -1,12 +1,11 @@
import { useState, useEffect, useRef, useMemo } from 'react';
-import { useNavigate, Link } from 'react-router';
-import { Folder, Pin, PinOff, Search, Clock, FolderOpen, Loader2, X, ChevronDown, ChevronUp } from 'lucide-react';
+import { useNavigate } from 'react-router';
+import { Folder, Pin, PinOff, Search, Clock, FolderOpen, Loader2, X } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { useFiles, type DirEntry, Breadcrumb } from 'widgets/FileBrowser';
import { useRecentFiles } from './state/useRecentFiles';
import { usePinnedFiles } from './state/usePinnedFiles';
-import { useUserState } from '@/state/useUserState';
-import { Card } from '@/components/Card';
+import { Widget } from '@/components/Widget';
type Tab = 'browse' | 'recent' | 'pinned';
@@ -22,7 +21,6 @@ export const FileBrowser = () => {
const { recents, addRecent } = useRecentFiles();
const { pinned, togglePin, isPinned } = usePinnedFiles();
- const [collapsed, setCollapsed] = useUserState('widget:fileBrowser:collapsed', true);
const [tab, setTab] = useState
('browse');
const [browsePath, setBrowsePath] = useState('/');
const [entries, setEntries] = useState([]);
@@ -72,171 +70,154 @@ export const FileBrowser = () => {
});
return (
-
-
-
-
- File Browser
-
-
+
+ {/* Tab bar + search */}
+
+
+ {TABS.map(({ key, label, icon: Icon }) => (
+
+ ))}
- {!collapsed && (
- <>
- {/* Tab bar + search */}
-
-
- {TABS.map(({ key, label, icon: Icon }) => (
-
- ))}
-
-
-
- setSearchQuery(ev.target.value)}
- placeholder="Search..."
- className="w-full rounded-lg border border-duck-dark/20 bg-white pl-8 pr-8 py-1 text-xs text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
- />
- {searchQuery && (
-
- )}
-
-
+
+
+ setSearchQuery(ev.target.value)}
+ placeholder="Search..."
+ className="w-full rounded-lg border border-duck-dark/20 bg-white pl-8 pr-8 py-1 text-xs text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
+ />
+ {searchQuery && (
+
+ )}
+
+
- {/* Content */}
-
- {isSearching ? (
- searching ? (
+ {/* Content */}
+
+ {isSearching ? (
+ searching ? (
+
+
+
+ ) : searchResults.length === 0 ? (
+
No results
+ ) : (
+
+ {searchResults.map((entry) => (
+ togglePin(entry.path!, entry.name) : undefined}
+ onClick={() => {
+ if (entry.type === 'file') openFile(entry.path!, entry.name);
+ }}
+ />
+ ))}
+
+ )
+ ) : (
+ <>
+ {tab === 'browse' && (
+
+
+
+
+ {loading ? (
- ) : searchResults.length === 0 ? (
-
No results
+ ) : sorted.length === 0 ? (
+
Empty directory
) : (
- {searchResults.map((entry) => (
+ {sorted.map((entry) => {
+ const fullPath = browsePath === '/' ? `/${entry.name}` : `${browsePath}/${entry.name}`;
+ return (
+ togglePin(fullPath, entry.name) : undefined}
+ onClick={() => {
+ if (entry.type === 'directory') setBrowsePath(fullPath);
+ else openFile(fullPath, entry.name);
+ }}
+ />
+ );
+ })}
+
+ )}
+
+ )}
+
+ {tab === 'recent' && (
+
+ {recents.length === 0 ? (
+
No recent files
+ ) : (
+
+ {recents.map((f) => (
togglePin(entry.path!, entry.name) : undefined}
- onClick={() => {
- if (entry.type === 'file') openFile(entry.path!, entry.name);
- }}
+ key={f.path}
+ name={f.name}
+ subtitle={f.path}
+ type="file"
+ pinned={isPinned(f.path)}
+ onPin={() => togglePin(f.path, f.name)}
+ onClick={() => openFile(f.path, f.name)}
/>
))}
- )
- ) : (
- <>
- {tab === 'browse' && (
-
-
-
-
- {loading ? (
-
-
-
- ) : sorted.length === 0 ? (
-
Empty directory
- ) : (
-
- {sorted.map((entry) => {
- const fullPath = browsePath === '/' ? `/${entry.name}` : `${browsePath}/${entry.name}`;
- return (
- togglePin(fullPath, entry.name) : undefined}
- onClick={() => {
- if (entry.type === 'directory') setBrowsePath(fullPath);
- else openFile(fullPath, entry.name);
- }}
- />
- );
- })}
-
- )}
-
- )}
+ )}
+
+ )}
- {tab === 'recent' && (
-
- {recents.length === 0 ? (
-
No recent files
- ) : (
-
- {recents.map((f) => (
- togglePin(f.path, f.name)}
- onClick={() => openFile(f.path, f.name)}
- />
- ))}
-
- )}
-
- )}
-
- {tab === 'pinned' && (
-
- {pinned.length === 0 ? (
-
No pinned files
- ) : (
-
- {pinned.map((f) => (
- togglePin(f.path, f.name)}
- onClick={() => openFile(f.path, f.name)}
- />
- ))}
-
- )}
-
- )}
- >
- )}
-
+ {tab === 'pinned' && (
+
+ {pinned.length === 0 ? (
+
No pinned files
+ ) : (
+
+ {pinned.map((f) => (
+ togglePin(f.path, f.name)}
+ onClick={() => openFile(f.path, f.name)}
+ />
+ ))}
+
+ )}
+
+ )}
>
)}
-
-
+
+
);
};
diff --git a/src/apps/officer-web/Screens/Dashboard/Home/ChatLauncher.tsx b/src/apps/officer-web/Screens/Dashboard/Home/ChatLauncher.tsx
index c37d25b5..9f149d4c 100644
--- a/src/apps/officer-web/Screens/Dashboard/Home/ChatLauncher.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Home/ChatLauncher.tsx
@@ -1,9 +1,8 @@
import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
-import { useNavigate, Link } from 'react-router';
+import { useNavigate } from 'react-router';
import {
Send,
ChevronDown,
- ChevronUp,
Check,
Paperclip,
Link as LinkIcon,
@@ -14,9 +13,8 @@ import {
} from 'lucide-react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
-import { useUserState } from '@/state/useUserState';
import { Button } from '@/components/ui/button';
-import { Card } from '@/components/Card';
+import { Widget } from '@/components/Widget';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import {
DropdownMenu,
@@ -39,7 +37,6 @@ export const ChatLauncher = () => {
const [model, setModel] = useState(settings.chat.defaultModel);
const [input, setInput] = useState('');
const [attachments, setAttachments] = useState([]);
- const [collapsed, setCollapsed] = useUserState('widget:chatLauncher:collapsed', true);
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
const [urlInput, setUrlInput] = useState('');
const textareaRef = useRef(null);
@@ -153,172 +150,154 @@ export const ChatLauncher = () => {
}, [input]);
return (
-
-
-
-
- Start Chat
-
-
-
- {!collapsed && (
- <>
-
- {attachments.length > 0 && (
-
- {attachments.map((a, i) => (
-
- {a.loading ? (
-
- ) : a.type === 'image' && a.dataUrl ? (
-
- ) : a.type === 'image' ? (
-
- ) : (
-
- )}
-
- {a.type === 'image' ? a.filename : a.loading ? a.url : a.title || a.url}
-
-
-
- ))}
-
- )}
-
-
-
-
-
-
- imageInputRef.current?.click()}>
-
- Image
-
-
-
- Text File
-
-
-
- PDF
-
- setUrlDialogOpen(true)}>
-
- Webpage URL
-
-
-
-
{
- const file = ev.target.files?.[0];
- if (file) handleAttachImage(file);
- ev.target.value = '';
- }}
- />
-
+
+
+
+ {(['claude', 'opencode'] as const).map((value) => (
+
+ ))}
+
+
+ {models.length > 0 && (
+
+
+
+
+
+ {models.map((m) => (
+ setModel(m.id)} className="cursor-pointer">
+
+ {m.name}
+ {m.provider && ({m.provider})}
+
+ ))}
+
+
+ )}
+
+
-
+ >
);
};
diff --git a/src/apps/officer-web/Screens/Dashboard/Home/index.tsx b/src/apps/officer-web/Screens/Dashboard/Home/index.tsx
index 917c51b0..f13c64db 100644
--- a/src/apps/officer-web/Screens/Dashboard/Home/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Home/index.tsx
@@ -1,19 +1,26 @@
+import { Workspace, createWorkspaceDefaults } from '@/components/Workspace';
+import { useUserState } from '@/state/useUserState';
import { ChatLauncher } from './ChatLauncher';
import { FileBrowserWidget as FileBrowser } from '@/Screens/Dashboard/Files';
import { ChatHistoryWidget as ChatHistory } from '@/Screens/Dashboard/ChatHistory';
import { Catalog } from 'sounds';
+const WIDGET_IDS = ['chat-launcher', 'file-browser', 'chat-history', 'sound-library'] as const;
+const DEFAULTS = createWorkspaceDefaults([...WIDGET_IDS]);
+
export const HomeScreen = () => {
+ const [state, setState] = useUserState('home-layout', DEFAULTS);
+
return (
-
+ ,
+ 'file-browser': ,
+ 'chat-history': ,
+ 'sound-library': ,
+ }}
+ state={state}
+ onChange={setState}
+ />
);
};
diff --git a/src/apps/officer-web/Screens/Dashboard/ResourcePage.tsx b/src/apps/officer-web/Screens/Dashboard/ResourcePage.tsx
index 1b1088ca..e1f79145 100644
--- a/src/apps/officer-web/Screens/Dashboard/ResourcePage.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/ResourcePage.tsx
@@ -4,7 +4,7 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { toast } from 'sonner';
-import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search } from 'lucide-react';
+import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient';
@@ -22,6 +22,7 @@ type ResourceSummary = {
type ResourceDetail = ResourceSummary & {
body: string;
+ rawFrontmatter: string;
filePath: string;
chatSessionId: string | null;
};
@@ -109,6 +110,25 @@ const ResourceChat = ({
);
};
+const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+ {open && (
+
{yaml}
+ )}
+
+ );
+};
+
export const ResourcePage = ({ kind, endpoint, queryKey }: ResourcePageProps) => {
const client = useClient();
const qc = useQueryClient();
@@ -315,6 +335,7 @@ export const ResourcePage = ({ kind, endpoint, queryKey }: ResourcePageProps) =>
)}
+ {detail?.rawFrontmatter &&
}
{detail?.body ? (
diff --git a/src/servers/api/processes/processes.ts b/src/servers/api/processes/processes.ts
index ff4dad90..a3dec7d0 100644
--- a/src/servers/api/processes/processes.ts
+++ b/src/servers/api/processes/processes.ts
@@ -8,9 +8,9 @@ type Frontmatter = {
description: string;
};
-export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string } {
+export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string; rawYaml: string } {
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
- if (!match) return { frontmatter: { name: '', description: '' }, body: raw };
+ if (!match) return { frontmatter: { name: '', description: '' }, body: raw, rawYaml: '' };
const yaml = match[1]!;
const body = match[2]!;
@@ -18,7 +18,7 @@ export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body:
const name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? '';
const description = yaml.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? '';
- return { frontmatter: { name, description }, body };
+ return { frontmatter: { name, description }, body, rawYaml: yaml };
}
export async function readProcessDirs(dir: string): Promise