agentcraft 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +40 -11
- package/bin/agentcraft.js +151 -7
- package/commands/agentcraft.md +6 -5
- package/docs/plans/2026-02-22-soundpack-implementation.md +667 -0
- package/docs/plans/2026-02-22-soundpack-system-design.md +121 -0
- package/hooks/play-sound.sh +13 -2
- package/package.json +1 -1
- package/web/app/api/assignments/route.ts +2 -4
- package/web/app/api/audio/[...path]/route.ts +7 -16
- package/web/app/api/packs/route.ts +109 -0
- package/web/app/api/preview/route.ts +4 -8
- package/web/app/api/sounds/route.ts +37 -59
- package/web/app/api/ui-sounds/route.ts +17 -34
- package/web/components/agent-roster-panel.tsx +6 -3
- package/web/components/packs-panel.tsx +180 -0
- package/web/components/sound-browser-panel.tsx +10 -4
- package/web/lib/packs.ts +82 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Sound Pack System Design
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-02-22
|
|
4
|
+
**Status:** Approved
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
The current `~/.agentcraft/sounds/` directory is a flat, monolithic library cloned from one repo. There is no way for users to add additional sound packs, no way to ship UI themes as separate installable units, and no concept of publisher or pack identity in assignment paths. The system assumes exactly one sound library exists.
|
|
9
|
+
|
|
10
|
+
## Goal
|
|
11
|
+
|
|
12
|
+
A pack system that:
|
|
13
|
+
- Lets users install sound packs from any GitHub repo via `agentcraft pack install publisher/name`
|
|
14
|
+
- Works drop-in: manually cloning a repo into the right directory is equivalent to installing
|
|
15
|
+
- Keeps packs isolated — no file conflicts between publishers
|
|
16
|
+
- Migrates the existing `agentcraft-sounds` library cleanly
|
|
17
|
+
- Powers the `agentcraft` npm CLI (already registered at 0.0.1)
|
|
18
|
+
|
|
19
|
+
## Directory Layout
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
~/.agentcraft/
|
|
23
|
+
assignments.json — sound assignments (paths include pack prefix)
|
|
24
|
+
waveforms.json — waveform cache
|
|
25
|
+
packs/
|
|
26
|
+
rohenaz/
|
|
27
|
+
agentcraft-sounds/ — official pack (migrated from ~/.agentcraft/sounds/)
|
|
28
|
+
sc2/
|
|
29
|
+
wc3/
|
|
30
|
+
ff7/
|
|
31
|
+
ff9/
|
|
32
|
+
apps/
|
|
33
|
+
classic-os/
|
|
34
|
+
phones/
|
|
35
|
+
ui/ — UI theme sounds
|
|
36
|
+
sc2/
|
|
37
|
+
wc3/
|
|
38
|
+
ff7/
|
|
39
|
+
ff9/
|
|
40
|
+
sc-bigbox/
|
|
41
|
+
community-publisher/
|
|
42
|
+
halo-pack/ — example third-party pack
|
|
43
|
+
sounds/
|
|
44
|
+
ui/
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
No manifest file required. Any directory at depth `packs/<publisher>/<name>/` is a valid pack. A `pack.json` is optional metadata (name, description, version) for display purposes only.
|
|
48
|
+
|
|
49
|
+
## Assignment Path Format
|
|
50
|
+
|
|
51
|
+
Paths in `assignments.json` gain a pack prefix separated by `:`:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
rohenaz/agentcraft-sounds:sc2/terran/session-start/scv-ready.mp3
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The hook script resolves this to:
|
|
58
|
+
```
|
|
59
|
+
~/.agentcraft/packs/rohenaz/agentcraft-sounds/sc2/terran/session-start/scv-ready.mp3
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Paths without a `:` prefix are legacy paths (from `~/.agentcraft/sounds/`) and fall back to that directory during a migration grace period.
|
|
63
|
+
|
|
64
|
+
## Default Configuration
|
|
65
|
+
|
|
66
|
+
The user's current configuration is saved as `defaults/assignments.json` in the `agentcraft-sounds` repo. On first install, if no `~/.agentcraft/assignments.json` exists, the CLI copies this file as the starting configuration.
|
|
67
|
+
|
|
68
|
+
## CLI (`agentcraft` npm package)
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
agentcraft pack install rohenaz/agentcraft-sounds # git clone into packs/
|
|
72
|
+
agentcraft pack install rohenaz/agentcraft-sounds --branch dev # specific branch
|
|
73
|
+
agentcraft pack list # show all installed packs
|
|
74
|
+
agentcraft pack remove rohenaz/agentcraft-sounds # rm -rf
|
|
75
|
+
agentcraft pack update rohenaz/agentcraft-sounds # git pull
|
|
76
|
+
agentcraft pack update --all # pull all packs
|
|
77
|
+
agentcraft start # launch dashboard on port 4040
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Install resolves `publisher/name` to `https://github.com/publisher/name` and clones into `~/.agentcraft/packs/publisher/name/`.
|
|
81
|
+
|
|
82
|
+
## Web UI Changes
|
|
83
|
+
|
|
84
|
+
### Sound Browser
|
|
85
|
+
- Groups sounds by pack in the category filter
|
|
86
|
+
- Pack shown as top-level grouping: `rohenaz/agentcraft-sounds > sc2 > terran`
|
|
87
|
+
- Search spans all packs
|
|
88
|
+
|
|
89
|
+
### API Routes
|
|
90
|
+
- `/api/sounds` — scans `~/.agentcraft/packs/` recursively, prefixes all paths with `publisher/name:`
|
|
91
|
+
- `/api/audio/[...path]` — resolves `publisher/name:internal/path` to filesystem path
|
|
92
|
+
- `/api/ui-sounds` — scans `ui/` directory across all installed packs
|
|
93
|
+
|
|
94
|
+
### Migration
|
|
95
|
+
- On startup, if `~/.agentcraft/sounds/` exists and `~/.agentcraft/packs/` does not, the server symlinks `~/.agentcraft/packs/rohenaz/agentcraft-sounds` → `~/.agentcraft/sounds/` automatically
|
|
96
|
+
- Assignment paths without `:` prefix resolve against `rohenaz/agentcraft-sounds` as the default pack
|
|
97
|
+
|
|
98
|
+
## `/agentcraft` Slash Command Changes
|
|
99
|
+
|
|
100
|
+
The auto-clone on first run targets `~/.agentcraft/packs/rohenaz/agentcraft-sounds` instead of `~/.agentcraft/sounds/`. Uses `agentcraft pack install` if CLI is available, falls back to raw `git clone`.
|
|
101
|
+
|
|
102
|
+
## `pack.json` (Optional)
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"name": "agentcraft-sounds",
|
|
107
|
+
"publisher": "rohenaz",
|
|
108
|
+
"version": "2.0.0",
|
|
109
|
+
"description": "Official AgentCraft sound library — SC2, WC3, FF7, FF9, phones, classic OS",
|
|
110
|
+
"types": ["sounds", "ui"]
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`types` is informational — the browser detects what's present by scanning the directory.
|
|
115
|
+
|
|
116
|
+
## Out of Scope (Future)
|
|
117
|
+
|
|
118
|
+
- Central pack registry / discovery
|
|
119
|
+
- Pack ratings or featured packs
|
|
120
|
+
- Signed packs / integrity verification
|
|
121
|
+
- Pack versioning / lockfile
|
package/hooks/play-sound.sh
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# AgentCraft hook - plays assigned sound for this event/agent/skill
|
|
3
3
|
CONFIG="$HOME/.agentcraft/assignments.json"
|
|
4
|
-
|
|
4
|
+
PACKS="$HOME/.agentcraft/packs"
|
|
5
5
|
|
|
6
6
|
# Read stdin with 2s timeout. 'timeout' isn't available by default on macOS,
|
|
7
7
|
# so use perl's alarm() which is always present.
|
|
@@ -55,7 +55,18 @@ fi
|
|
|
55
55
|
[ -z "$SOUND" ] && SOUND=$(jq -r --arg e "$EVENT" '.global[$e] // empty' "$CONFIG")
|
|
56
56
|
[ -z "$SOUND" ] && exit 0
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
# Resolve pack-prefixed path: "publisher/name:internal/path"
|
|
59
|
+
if echo "$SOUND" | grep -q ':'; then
|
|
60
|
+
PACK_ID="${SOUND%%:*}"
|
|
61
|
+
INTERNAL="${SOUND#*:}"
|
|
62
|
+
PUBLISHER="${PACK_ID%%/*}"
|
|
63
|
+
PACKNAME="${PACK_ID##*/}"
|
|
64
|
+
FULL="$PACKS/$PUBLISHER/$PACKNAME/$INTERNAL"
|
|
65
|
+
else
|
|
66
|
+
# Legacy path — fall back to rohenaz/agentcraft-sounds
|
|
67
|
+
FULL="$PACKS/rohenaz/agentcraft-sounds/$SOUND"
|
|
68
|
+
fi
|
|
69
|
+
|
|
59
70
|
[ ! -f "$FULL" ] && exit 0
|
|
60
71
|
|
|
61
72
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
package/package.json
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
const ASSIGNMENTS_PATH = join(homedir(), '.agentcraft', 'assignments.json');
|
|
3
|
+
import { dirname } from 'path';
|
|
4
|
+
import { ASSIGNMENTS_PATH } from '@/lib/packs';
|
|
7
5
|
|
|
8
6
|
export async function GET() {
|
|
9
7
|
try {
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import { readFile } from 'fs/promises';
|
|
3
|
-
import {
|
|
4
|
-
import { homedir } from 'os';
|
|
5
|
-
|
|
6
|
-
const SOUND_LIBRARY = join(homedir(), '.agentcraft', 'sounds');
|
|
3
|
+
import { resolvePackPath } from '@/lib/packs';
|
|
7
4
|
|
|
8
5
|
export async function GET(
|
|
9
6
|
req: NextRequest,
|
|
@@ -11,23 +8,17 @@ export async function GET(
|
|
|
11
8
|
) {
|
|
12
9
|
try {
|
|
13
10
|
const { path: pathParts } = await params;
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
if (relativePath.includes('..')) {
|
|
11
|
+
const soundPath = decodeURIComponent(pathParts.join('/'));
|
|
12
|
+
if (soundPath.includes('..')) {
|
|
17
13
|
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
18
14
|
}
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
const fullPath = resolvePackPath(soundPath);
|
|
16
|
+
if (!fullPath) return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
21
17
|
const data = await readFile(fullPath);
|
|
22
|
-
|
|
23
|
-
const ext = relativePath.split('.').pop()?.toLowerCase();
|
|
18
|
+
const ext = soundPath.split('.').pop()?.toLowerCase();
|
|
24
19
|
const contentType = ext === 'mp3' ? 'audio/mpeg' : ext === 'wav' ? 'audio/wav' : 'audio/mpeg';
|
|
25
|
-
|
|
26
20
|
return new NextResponse(data, {
|
|
27
|
-
headers: {
|
|
28
|
-
'Content-Type': contentType,
|
|
29
|
-
'Cache-Control': 'public, max-age=86400',
|
|
30
|
-
},
|
|
21
|
+
headers: { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=86400' },
|
|
31
22
|
});
|
|
32
23
|
} catch {
|
|
33
24
|
return NextResponse.json({ error: 'Audio file not found' }, { status: 404 });
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { readdir, stat, readFile } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { spawnSync } from 'child_process';
|
|
6
|
+
|
|
7
|
+
const PACKS_DIR = join(homedir(), '.agentcraft', 'packs');
|
|
8
|
+
|
|
9
|
+
interface PackInfo {
|
|
10
|
+
id: string; // "publisher/name"
|
|
11
|
+
publisher: string;
|
|
12
|
+
name: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
version?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function getInstalledPacks(): Promise<PackInfo[]> {
|
|
18
|
+
const packs: PackInfo[] = [];
|
|
19
|
+
let publishers: string[];
|
|
20
|
+
try {
|
|
21
|
+
publishers = await readdir(PACKS_DIR);
|
|
22
|
+
} catch {
|
|
23
|
+
return packs;
|
|
24
|
+
}
|
|
25
|
+
for (const publisher of publishers) {
|
|
26
|
+
const pubPath = join(PACKS_DIR, publisher);
|
|
27
|
+
const ps = await stat(pubPath).catch(() => null);
|
|
28
|
+
if (!ps?.isDirectory()) continue;
|
|
29
|
+
const names = await readdir(pubPath).catch(() => [] as string[]);
|
|
30
|
+
for (const name of names) {
|
|
31
|
+
const packPath = join(pubPath, name);
|
|
32
|
+
const ns = await stat(packPath).catch(() => null);
|
|
33
|
+
if (!ns?.isDirectory()) continue;
|
|
34
|
+
// Try to read pack.json for metadata
|
|
35
|
+
let description: string | undefined;
|
|
36
|
+
let version: string | undefined;
|
|
37
|
+
try {
|
|
38
|
+
const manifest = JSON.parse(await readFile(join(packPath, 'pack.json'), 'utf-8'));
|
|
39
|
+
description = manifest.description;
|
|
40
|
+
version = manifest.version;
|
|
41
|
+
} catch { /* no manifest, that's fine */ }
|
|
42
|
+
packs.push({ id: `${publisher}/${name}`, publisher, name, description, version });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return packs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// GET — list installed packs
|
|
49
|
+
export async function GET() {
|
|
50
|
+
const packs = await getInstalledPacks();
|
|
51
|
+
return NextResponse.json(packs);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// POST { repo: "publisher/name" } — install a pack
|
|
55
|
+
export async function POST(req: NextRequest) {
|
|
56
|
+
try {
|
|
57
|
+
const { repo } = await req.json();
|
|
58
|
+
if (!repo || !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo)) {
|
|
59
|
+
return NextResponse.json({ error: 'Invalid repo format' }, { status: 400 });
|
|
60
|
+
}
|
|
61
|
+
const [publisher, name] = repo.split('/');
|
|
62
|
+
const dest = join(PACKS_DIR, publisher, name);
|
|
63
|
+
const url = `https://github.com/${repo}`;
|
|
64
|
+
// mkdir publisher dir
|
|
65
|
+
spawnSync('mkdir', ['-p', join(PACKS_DIR, publisher)]);
|
|
66
|
+
const result = spawnSync('git', ['clone', url, dest], { timeout: 60000 });
|
|
67
|
+
if (result.status !== 0) {
|
|
68
|
+
return NextResponse.json({ error: 'Clone failed' }, { status: 500 });
|
|
69
|
+
}
|
|
70
|
+
return NextResponse.json({ ok: true });
|
|
71
|
+
} catch {
|
|
72
|
+
return NextResponse.json({ error: 'Install failed' }, { status: 500 });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// DELETE { repo: "publisher/name" } — remove a pack
|
|
77
|
+
export async function DELETE(req: NextRequest) {
|
|
78
|
+
try {
|
|
79
|
+
const { repo } = await req.json();
|
|
80
|
+
if (!repo || !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo)) {
|
|
81
|
+
return NextResponse.json({ error: 'Invalid repo format' }, { status: 400 });
|
|
82
|
+
}
|
|
83
|
+
const [publisher, name] = repo.split('/');
|
|
84
|
+
const dest = join(PACKS_DIR, publisher, name);
|
|
85
|
+
spawnSync('rm', ['-rf', dest]);
|
|
86
|
+
return NextResponse.json({ ok: true });
|
|
87
|
+
} catch {
|
|
88
|
+
return NextResponse.json({ error: 'Remove failed' }, { status: 500 });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// PATCH { repo: "publisher/name" } — update a pack (git pull)
|
|
93
|
+
export async function PATCH(req: NextRequest) {
|
|
94
|
+
try {
|
|
95
|
+
const { repo } = await req.json();
|
|
96
|
+
if (!repo || !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo)) {
|
|
97
|
+
return NextResponse.json({ error: 'Invalid repo format' }, { status: 400 });
|
|
98
|
+
}
|
|
99
|
+
const [publisher, name] = repo.split('/');
|
|
100
|
+
const dest = join(PACKS_DIR, publisher, name);
|
|
101
|
+
const result = spawnSync('git', ['-C', dest, 'pull'], { timeout: 30000 });
|
|
102
|
+
if (result.status !== 0) {
|
|
103
|
+
return NextResponse.json({ error: 'Update failed' }, { status: 500 });
|
|
104
|
+
}
|
|
105
|
+
return NextResponse.json({ ok: true });
|
|
106
|
+
} catch {
|
|
107
|
+
return NextResponse.json({ error: 'Update failed' }, { status: 500 });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import { spawn } from 'child_process';
|
|
3
|
-
import {
|
|
4
|
-
import { homedir } from 'os';
|
|
5
|
-
|
|
6
|
-
const SOUND_LIBRARY = join(homedir(), '.agentcraft', 'sounds');
|
|
3
|
+
import { resolvePackPath } from '@/lib/packs';
|
|
7
4
|
|
|
8
5
|
export async function POST(req: NextRequest) {
|
|
9
6
|
try {
|
|
10
7
|
const { path } = await req.json();
|
|
11
|
-
if (!path || typeof path !== 'string'
|
|
8
|
+
if (!path || typeof path !== 'string') {
|
|
12
9
|
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
13
10
|
}
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
const fullPath = resolvePackPath(path);
|
|
12
|
+
if (!fullPath) return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
16
13
|
spawn('afplay', [fullPath], { detached: true, stdio: 'ignore' }).unref();
|
|
17
|
-
|
|
18
14
|
return NextResponse.json({ ok: true });
|
|
19
15
|
} catch {
|
|
20
16
|
return NextResponse.json({ error: 'Playback failed' }, { status: 500 });
|
|
@@ -1,32 +1,22 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import {
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
3
3
|
import { join, dirname } from 'path';
|
|
4
|
-
import { homedir } from 'os';
|
|
5
4
|
import { spawnSync } from 'child_process';
|
|
5
|
+
import { listPacks, walkPackDir, WAVEFORM_CACHE } from '@/lib/packs';
|
|
6
6
|
import type { SoundAsset } from '@/lib/types';
|
|
7
7
|
|
|
8
|
-
const SOUND_LIBRARY = join(homedir(), '.agentcraft', 'sounds');
|
|
9
|
-
const WAVEFORM_CACHE = join(homedir(), '.agentcraft', 'waveforms.json');
|
|
10
8
|
const BARS = 16;
|
|
11
9
|
const FALLBACK = [5, 7, 4, 9, 6, 8, 3, 7, 5, 8, 4, 6, 7, 5, 8, 4];
|
|
12
10
|
|
|
13
11
|
function computeWaveform(filePath: string): number[] {
|
|
14
|
-
// Decode to mono f32le PCM at 1000 Hz (massively downsampled — fast, still accurate)
|
|
15
12
|
const result = spawnSync('ffmpeg', [
|
|
16
|
-
'-i', filePath,
|
|
17
|
-
'-ac', '1', // mono
|
|
18
|
-
'-ar', '1000', // 1000 Hz sample rate
|
|
19
|
-
'-f', 'f32le', // raw 32-bit float output
|
|
20
|
-
'-', // pipe to stdout
|
|
13
|
+
'-i', filePath, '-ac', '1', '-ar', '1000', '-f', 'f32le', '-',
|
|
21
14
|
], { maxBuffer: 1024 * 1024 * 4 });
|
|
22
|
-
|
|
23
15
|
if (result.status !== 0 || !result.stdout?.length) return FALLBACK;
|
|
24
|
-
|
|
25
16
|
const buf = result.stdout as Buffer;
|
|
26
|
-
const samples = buf.length / 4;
|
|
17
|
+
const samples = buf.length / 4;
|
|
27
18
|
const blockSize = Math.floor(samples / BARS);
|
|
28
19
|
const values: number[] = [];
|
|
29
|
-
|
|
30
20
|
for (let i = 0; i < BARS; i++) {
|
|
31
21
|
let sum = 0;
|
|
32
22
|
for (let j = 0; j < blockSize; j++) {
|
|
@@ -34,17 +24,13 @@ function computeWaveform(filePath: string): number[] {
|
|
|
34
24
|
}
|
|
35
25
|
values.push(sum / blockSize);
|
|
36
26
|
}
|
|
37
|
-
|
|
38
27
|
const max = Math.max(...values, 0.0001);
|
|
39
28
|
return values.map((v) => Math.max(1, Math.round((v / max) * 10)));
|
|
40
29
|
}
|
|
41
30
|
|
|
42
31
|
async function loadCache(): Promise<Record<string, number[]>> {
|
|
43
|
-
try {
|
|
44
|
-
|
|
45
|
-
} catch {
|
|
46
|
-
return {};
|
|
47
|
-
}
|
|
32
|
+
try { return JSON.parse(await readFile(WAVEFORM_CACHE, 'utf-8')); }
|
|
33
|
+
catch { return {}; }
|
|
48
34
|
}
|
|
49
35
|
|
|
50
36
|
async function saveCache(cache: Record<string, number[]>) {
|
|
@@ -52,53 +38,45 @@ async function saveCache(cache: Record<string, number[]>) {
|
|
|
52
38
|
await writeFile(WAVEFORM_CACHE, JSON.stringify(cache), 'utf-8');
|
|
53
39
|
}
|
|
54
40
|
|
|
55
|
-
async function
|
|
56
|
-
|
|
57
|
-
|
|
41
|
+
export async function GET() {
|
|
42
|
+
try {
|
|
43
|
+
const packs = await listPacks();
|
|
44
|
+
const allFiles: Array<{ relPath: string; absPath: string }> = [];
|
|
45
|
+
for (const pack of packs) {
|
|
46
|
+
const files = await walkPackDir(pack.path, pack.path, pack.id);
|
|
47
|
+
allFiles.push(...files);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const cache = await loadCache();
|
|
51
|
+
let dirty = false;
|
|
58
52
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// For flat dirs (depth 1): icq/uh-oh.mp3 → category=icq, subcategory=''
|
|
67
|
-
// For nested dirs (depth 2+): sc2/terran/session-start/scv.mp3 → category=sc2/terran, subcategory=session-start
|
|
68
|
-
const category = parts.length > 2 ? parts.slice(0, -2).join('/') : parts[0];
|
|
53
|
+
const results: SoundAsset[] = allFiles.map(({ relPath, absPath }) => {
|
|
54
|
+
const internal = relPath.slice(relPath.indexOf(':') + 1);
|
|
55
|
+
const parts = internal.split('/');
|
|
56
|
+
const packId = relPath.slice(0, relPath.indexOf(':'));
|
|
57
|
+
const category = parts.length > 2
|
|
58
|
+
? `${packId}:${parts.slice(0, -2).join('/')}`
|
|
59
|
+
: `${packId}:${parts[0]}`;
|
|
69
60
|
const subcategory = parts.length > 2 ? parts[parts.length - 2] : '';
|
|
70
|
-
|
|
71
|
-
|
|
61
|
+
|
|
62
|
+
const waveform = cache[relPath] ?? (() => {
|
|
63
|
+
const wf = computeWaveform(absPath);
|
|
64
|
+
cache[relPath] = wf;
|
|
65
|
+
dirty = true;
|
|
66
|
+
return wf;
|
|
67
|
+
})();
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
id: relPath.replace(/\.[^/.]+$/, ''),
|
|
72
71
|
filename: parts[parts.length - 1],
|
|
73
72
|
category,
|
|
74
73
|
subcategory,
|
|
75
|
-
path:
|
|
76
|
-
waveform
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return assets;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export async function GET() {
|
|
85
|
-
try {
|
|
86
|
-
const assets = await walkDir(SOUND_LIBRARY, SOUND_LIBRARY);
|
|
87
|
-
const cache = await loadCache();
|
|
88
|
-
|
|
89
|
-
let dirty = false;
|
|
90
|
-
const results = assets.map((asset) => {
|
|
91
|
-
if (cache[asset.path]) {
|
|
92
|
-
return { ...asset, waveform: cache[asset.path] };
|
|
93
|
-
}
|
|
94
|
-
const waveform = computeWaveform(join(SOUND_LIBRARY, asset.path));
|
|
95
|
-
cache[asset.path] = waveform;
|
|
96
|
-
dirty = true;
|
|
97
|
-
return { ...asset, waveform };
|
|
74
|
+
path: relPath,
|
|
75
|
+
waveform,
|
|
76
|
+
};
|
|
98
77
|
});
|
|
99
78
|
|
|
100
79
|
if (dirty) await saveCache(cache);
|
|
101
|
-
|
|
102
80
|
return NextResponse.json(results);
|
|
103
81
|
} catch {
|
|
104
82
|
return NextResponse.json({ error: 'Failed to read sound library' }, { status: 500 });
|
|
@@ -1,44 +1,27 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import {
|
|
3
|
-
import { join } from 'path';
|
|
4
|
-
import { homedir } from 'os';
|
|
5
|
-
|
|
6
|
-
const UI_DIR = join(homedir(), '.agentcraft', 'sounds', 'ui');
|
|
7
|
-
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.m4a']);
|
|
2
|
+
import { listPacks, walkPackDir } from '@/lib/packs';
|
|
8
3
|
|
|
9
4
|
interface UISound {
|
|
10
|
-
path: string;
|
|
11
|
-
filename: string;
|
|
12
|
-
group: string;
|
|
5
|
+
path: string;
|
|
6
|
+
filename: string;
|
|
7
|
+
group: string;
|
|
13
8
|
}
|
|
14
9
|
|
|
15
|
-
async function
|
|
10
|
+
export async function GET() {
|
|
11
|
+
const packs = await listPacks();
|
|
16
12
|
const results: UISound[] = [];
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (s.isDirectory()) {
|
|
28
|
-
results.push(...await scanDir(full, `${base}/${entry}`));
|
|
29
|
-
} else {
|
|
30
|
-
const ext = entry.toLowerCase().slice(entry.lastIndexOf('.'));
|
|
31
|
-
if (AUDIO_EXTS.has(ext)) {
|
|
32
|
-
const rel = `${base}/${entry}`;
|
|
33
|
-
const group = base.replace('ui/', '');
|
|
34
|
-
results.push({ path: rel, filename: entry, group });
|
|
35
|
-
}
|
|
13
|
+
|
|
14
|
+
for (const pack of packs) {
|
|
15
|
+
const uiDir = `${pack.path}/ui`;
|
|
16
|
+
const files = await walkPackDir(uiDir, pack.path, pack.id).catch(() => []);
|
|
17
|
+
for (const { relPath } of files) {
|
|
18
|
+
const internal = relPath.slice(relPath.indexOf(':') + 1);
|
|
19
|
+
const parts = internal.split('/');
|
|
20
|
+
if (parts[0] !== 'ui') continue;
|
|
21
|
+
const group = parts[1] ?? '';
|
|
22
|
+
results.push({ path: relPath, filename: parts[parts.length - 1], group });
|
|
36
23
|
}
|
|
37
24
|
}
|
|
38
|
-
return results;
|
|
39
|
-
}
|
|
40
25
|
|
|
41
|
-
|
|
42
|
-
const sounds = await scanDir(UI_DIR, 'ui');
|
|
43
|
-
return NextResponse.json(sounds);
|
|
26
|
+
return NextResponse.json(results);
|
|
44
27
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState, useMemo } from 'react';
|
|
4
4
|
import { HookSlot } from './hook-slot';
|
|
5
5
|
import { AgentForm } from './agent-form';
|
|
6
|
+
import { PacksPanel } from './packs-panel';
|
|
6
7
|
import { playUISound } from '@/lib/ui-audio';
|
|
7
8
|
import { getEventLabel } from '@/lib/utils';
|
|
8
9
|
import type { HookEvent, SkillHookEvent, SoundAssignments, AgentInfo, SkillInfo, AgentFormData, SelectMode } from '@/lib/types';
|
|
@@ -220,7 +221,7 @@ interface AgentRosterPanelProps {
|
|
|
220
221
|
}
|
|
221
222
|
|
|
222
223
|
export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChange, onPreview, onAgentsChange, selectMode, onSlotSelect }: AgentRosterPanelProps) {
|
|
223
|
-
const [activeView, setActiveView] = useState<'agents' | 'skills'>('agents');
|
|
224
|
+
const [activeView, setActiveView] = useState<'agents' | 'skills' | 'packs'>('agents');
|
|
224
225
|
const [showForm, setShowForm] = useState(false);
|
|
225
226
|
const [editingAgent, setEditingAgent] = useState<AgentInfo | undefined>();
|
|
226
227
|
const [skillSearch, setSkillSearch] = useState('');
|
|
@@ -341,7 +342,7 @@ export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChan
|
|
|
341
342
|
{/* Tab bar header */}
|
|
342
343
|
<div className="shrink-0 border-b" style={{ borderColor: 'var(--sf-border)', backgroundColor: 'var(--sf-panel)' }}>
|
|
343
344
|
<div className="flex items-stretch">
|
|
344
|
-
{(['agents', 'skills'] as const).map((view) => (
|
|
345
|
+
{(['agents', 'skills', 'packs'] as const).map((view) => (
|
|
345
346
|
<button
|
|
346
347
|
key={view}
|
|
347
348
|
data-sf-hover
|
|
@@ -359,7 +360,7 @@ export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChan
|
|
|
359
360
|
backgroundColor: activeView === view ? 'rgba(0,229,255,0.04)' : 'transparent',
|
|
360
361
|
}}
|
|
361
362
|
>
|
|
362
|
-
{view
|
|
363
|
+
{view.toUpperCase()}
|
|
363
364
|
</button>
|
|
364
365
|
))}
|
|
365
366
|
{activeView === 'agents' && (
|
|
@@ -496,6 +497,8 @@ export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChan
|
|
|
496
497
|
})}
|
|
497
498
|
</>
|
|
498
499
|
)}
|
|
500
|
+
|
|
501
|
+
{activeView === 'packs' && <PacksPanel />}
|
|
499
502
|
</div>
|
|
500
503
|
</div>
|
|
501
504
|
);
|