@clawhub-badajoz95-66fdb5eb40
H-ear.world transforms sound into an actionable, meaningful translation layer of the world around you. Describe, share and act upon environmental audio as a...
---
name: h-ear
description: "H-ear.world transforms sound into an actionable, meaningful translation layer of the world around you. Describe, share and act upon environmental audio as a machine sensor that empowers you, your business and your AI flow."
version: 0.1.0
author: H-ear World
homepage: https://h-ear.world
metadata: {"openclaw": {"requires": {"env": ["HEAR_API_KEY", "HEAR_ENV"], "bins": []}, "primaryEnv": "HEAR_API_KEY"}}
---
# H-ear — Sound Intelligence for AI Agents
H-ear.world transforms sound into an actionable, meaningful translation layer of the world around you. Describe, share and act upon environmental audio as a machine sensor that empowers you, your business and your AI flow.
## Commands
| Command | Description |
|---------|-------------|
| `classify <url>` | Classify audio from a URL. Returns detected sound classes with confidence scores. |
| `classify batch <url1> <url2>...` | Batch classify multiple audio URLs. Results delivered asynchronously via the gateway's webhook endpoint. |
| `sounds [search]` | List supported sound classes (521+ across 3 taxonomies). |
| `usage` | Show API usage statistics (minutes, calls, quota). |
| `jobs [last N]` | List recent classification jobs with status. |
| `job <id>` | Show detailed job results with classifications. |
| `alerts on <sound>` | Enable real-time alerts for a sound class. Notifications delivered to your connected channel via the gateway. |
| `alerts off <sound>` | Disable alerts for a sound class. |
| `health` | Check API status. |
## Setup
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `HEAR_API_KEY` | Yes* | | H-ear Enterprise API key (`ncm_sk_...`). Required unless `HEAR_BEARER_TOKEN` is set. Get one at [h-ear.world](https://h-ear.world). |
| `HEAR_BEARER_TOKEN` | Yes* | | OAuth bearer token. Alternative to `HEAR_API_KEY` — one of the two must be set. |
| `HEAR_ENV` | Yes | | Target environment: `dev`, `staging`, or `prod`. |
| `HEAR_BASE_URL` | No | Per-environment default | Override API base URL (advanced). |
*One of `HEAR_API_KEY` or `HEAR_BEARER_TOKEN` is required.
## Webhook Delivery
Batch classification (`classify batch`) and sound alerts (`alerts on`) use webhook callbacks for asynchronous result delivery. The OpenClaw gateway manages webhook endpoints automatically -- the skill registers callbacks against the gateway's own webhook receiver, which routes results back to your connected messaging channel. No external endpoint configuration is required by the user.
Webhook events: `job.completed`, `job.failed`, `batch.completed`, `quota.warning`.
FILE:README.md
# @h-ear/openclaw
OpenClaw skill for [H-ear World](https://h-ear.world) audio classification. Sound intelligence in WhatsApp, Telegram, Slack, Discord, and Teams.
## Install
```bash
npm install @h-ear/openclaw
```
Or via ClawHub — search for `h-ear` or paste `https://github.com/Badajoz95/h-ear-openclaw`.
## Setup
Set `HEAR_API_KEY` to your H-ear Enterprise API key:
```bash
export HEAR_API_KEY=ncm_sk_your_key
```
## Commands
| Command | Description |
|---------|-------------|
| `classify <url>` | Classify audio from a URL |
| `classify batch <url1> <url2>...` | Batch classify multiple audio URLs |
| `sounds [search]` | List supported sound classes (521+) |
| `usage` | Show API usage statistics |
| `jobs [last N]` | List recent classification jobs |
| `job <id>` | Show detailed job results |
| `alerts on <sound>` | Register a simple sound alert via webhook |
| `alerts off <sound>` | Remove a sound alert |
| `webhook list` | List enterprise webhook registrations |
| `webhook detail <id>` | Webhook details with filter config |
| `webhook create <url>` | Create enterprise webhook (returns signing secret once) |
| `webhook ping <id>` | Test webhook connectivity |
| `webhook deliveries <id>` | Delivery audit trail |
| `health` | Check API status |
## Example
In any connected messaging channel:
```
> classify https://example.com/city-noise.mp3
**Audio Classification Complete**
Duration: 45.2s | 15 noise events detected
| Sound | Confidence | Category |
|------------|-----------|----------|
| Car horn | 94% | Vehicle |
| Speech | 87% | Human |
| Dog bark | 72% | Animal |
```
## Programmatic Use
```typescript
import { createSkill, classifyCommand } from '@h-ear/openclaw';
const { client } = createSkill();
const result = await classifyCommand(client, 'https://example.com/audio.mp3');
console.log(result);
```
## Supported Formats
MP3, WAV, FLAC, OGG, M4A
## Get an API Key
Visit [h-ear.world](https://h-ear.world) to create an account and generate an API key.
## License
MIT
FILE:eslint.config.js
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.strict,
{
files: ['src/**/*.ts'],
languageOptions: {
parserOptions: {
project: './tsconfig.json',
},
},
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
ignores: ['dist/**', 'node_modules/**', '**/*.js', '**/*.d.ts'],
},
);
FILE:package.json
{
"name": "@h-ear/openclaw",
"version": "1.0.0",
"description": "OpenClaw skill for H-ear World audio classification — sound intelligence in WhatsApp, Telegram, Slack, Discord, and Teams",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"h-ear": "./dist/cli.js"
},
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsc",
"build:watch": "tsc --watch",
"clean": "rm -rf dist",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@h-ear/core": "^1.0.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^20.11.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.2"
},
"engines": {
"node": ">=18"
},
"files": [
"dist",
"SKILL.md",
"README.md"
],
"keywords": [
"openclaw",
"openclaw-skill",
"h-ear",
"audio",
"classification",
"noise",
"sound",
"ai-agent",
"chatbot"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/noise-control-monitor/ncm-monorepo",
"directory": "packages/openclaw"
}
}
FILE:src/cli.ts
#!/usr/bin/env node
/**
* h-ear CLI — OpenClaw-friendly command-line interface for H-ear audio classification.
*
* Usage:
* h-ear health Check API status
* h-ear sounds [search] List sound classes
* h-ear usage Show API usage
* h-ear classify <file-or-url> Classify audio (sync, polls for results)
* h-ear capture [--duration 15] Capture audio from RTSP camera and classify
* h-ear jobs [--limit 5] List recent jobs
*
* Environment:
* HEAR_API_KEY API key (required)
* HEAR_ENV Environment: dev, staging, prod (default: prod)
* LISTEN_RTSP_URL RTSP source URL (for capture command)
*/
import { readFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
import { spawnSync } from 'child_process';
import { join } from 'path';
import { tmpdir } from 'os';
import { createSkill } from './index.js';
import { healthCommand } from './commands/health.js';
import { soundsCommand } from './commands/sounds.js';
import { usageCommand } from './commands/usage.js';
import { classifySyncCommand } from './commands/classify.js';
import { jobsCommand } from './commands/jobs.js';
import { formatClassifyResult } from './formatter.js';
// --- Arg parsing -------------------------------------------------------------
const args = process.argv.slice(2);
const command = args[0] ?? 'help';
function getFlag(flag: string): string | null {
const idx = args.indexOf(flag);
return idx >= 0 && args[idx + 1] ? args[idx + 1] : null;
}
function hasFlag(flag: string): boolean {
return args.includes(flag);
}
// --- Capture -----------------------------------------------------------------
function captureAudio(sourceUrl: string, durationSec: number): { buffer: Buffer; fileName: string } {
const tmpDir = join(tmpdir(), 'h-ear-listen');
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
const outFile = join(tmpDir, `capture_timestamp_durationSecs.wav`);
const ffmpegArgs: string[] = ['-y'];
if (sourceUrl.startsWith('rtsp://')) ffmpegArgs.push('-rtsp_transport', 'tcp');
ffmpegArgs.push(
'-i', sourceUrl,
'-vn', '-ac', '1', '-ar', '16000', '-acodec', 'pcm_s16le',
'-t', String(durationSec), '-f', 'wav', outFile,
);
const result = spawnSync('ffmpeg', ffmpegArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
timeout: (durationSec + 30) * 1000,
});
if (result.status !== 0 || !existsSync(outFile)) {
const stderr = (result.stderr || '').trim().split('\n').slice(-3).join('\n');
throw new Error(`ffmpeg capture failed (exit result.status): stderr`);
}
const buffer = readFileSync(outFile);
try { unlinkSync(outFile); } catch (err) { console.error(` Cleanup warning: failed to remove temp file outFile`, err); }
return { buffer: Buffer.from(buffer), fileName: `capture_timestamp_durationSecs.wav` };
}
// --- Main --------------------------------------------------------------------
async function main(): Promise<void> {
if (command === 'help' || command === '--help' || command === '-h') {
console.log(`h-ear — Sound Intelligence CLI
Usage:
h-ear health Check API status
h-ear sounds [search] List sound classes (521+)
h-ear usage Show API usage stats
h-ear classify <file-or-url> Classify audio (waits for results)
h-ear capture [--duration 15] Capture from RTSP camera and classify
h-ear jobs [--limit 5] List recent jobs
Environment:
HEAR_API_KEY H-ear API key (required)
HEAR_ENV dev | staging | prod (default: prod)
LISTEN_RTSP_URL RTSP camera URL (for capture command)`);
return;
}
const { client } = createSkill();
switch (command) {
case 'health': {
console.log(await healthCommand(client));
break;
}
case 'sounds': {
const search = args[1] && !args[1].startsWith('-') ? args[1] : undefined;
const limit = parseInt(getFlag('--limit') ?? '20', 10);
console.log(await soundsCommand(client, search, { limit }));
break;
}
case 'usage': {
console.log(await usageCommand(client));
break;
}
case 'classify': {
const target = args[1];
if (!target) { console.error('Error: h-ear classify <file-or-url>'); process.exit(1); }
if (target.startsWith('http://') || target.startsWith('https://')) {
// URL mode — use existing sync command
console.log(await classifySyncCommand(client, target, undefined, (msg) => console.error(` msg`)));
} else {
// File mode — read and upload
if (!existsSync(target)) { console.error(`Error: file not found: target`); process.exit(1); }
const buffer = readFileSync(target);
const fileName = target.split(/[/\\]/).pop() ?? 'audio.wav';
console.error(` Uploading fileName ((buffer.length / 1024).toFixed(0) KB)...`);
const accepted = await client.submitClassifyFile(buffer, fileName, { threshold: 0.3 });
console.error(` Job accepted.requestId queued, polling...`);
const result = await client.pollJob(accepted.requestId, (msg) => console.error(` msg`));
console.log(formatClassifyResult(result));
}
break;
}
case 'listen':
case 'capture': {
const rtspUrl = getFlag('--rtsp') ?? process.env.LISTEN_RTSP_URL;
if (!rtspUrl) {
console.error('Error: RTSP URL required. Pass --rtsp <url> or set LISTEN_RTSP_URL');
process.exit(1);
}
const duration = parseInt(getFlag('--duration') ?? '15', 10);
const classifyAfter = !hasFlag('--no-classify');
console.error(` Capturing durations audio from rtspUrl...`);
const { buffer, fileName } = captureAudio(rtspUrl, duration);
console.error(` Captured (buffer.length / 1024).toFixed(0) KB`);
if (classifyAfter) {
console.error(` Uploading fileName...`);
const accepted = await client.submitClassifyFile(buffer, fileName, { threshold: 0.3 });
console.error(` Job accepted.requestId queued, polling...`);
const result = await client.pollJob(accepted.requestId, (msg) => console.error(` msg`));
console.log(formatClassifyResult(result));
} else {
console.log(`Captured: fileName ((buffer.length / 1024).toFixed(0) KB)`);
}
break;
}
case 'jobs': {
const limit = parseInt(getFlag('--limit') ?? '5', 10);
console.log(await jobsCommand(client, { limit }));
break;
}
default: {
console.error(`Unknown command: command. Run 'h-ear help' for usage.`);
process.exit(1);
}
}
}
main().catch((err: Error) => {
console.error(`Error: err.message`);
process.exit(1);
});
FILE:src/commands/alerts.ts
import type { HearApiClient } from '@h-ear/core';
import { formatAlertRegistered, formatAlertDeregistered } from '../formatter.js';
export async function alertOnCommand(
client: HearApiClient,
soundClass: string,
options?: { callbackUrl?: string },
): Promise<string> {
if (!options?.callbackUrl) throw new Error('callbackUrl is required for alert registration');
await client.registerWebhook({
url: options.callbackUrl,
events: ['job.completed'],
soundClass,
});
return formatAlertRegistered(soundClass);
}
export async function alertOffCommand(
client: HearApiClient,
webhookId: string,
soundClass: string,
): Promise<string> {
await client.deregisterWebhook(webhookId);
return formatAlertDeregistered(soundClass);
}
FILE:src/commands/classify-batch.ts
import type { HearApiClient } from '@h-ear/core';
export async function classifyBatchCommand(
client: HearApiClient,
urls: string[],
options?: { threshold?: number; callbackUrl?: string },
): Promise<string> {
const files = urls.map((url, i) => ({ url, id: `file-i + 1` }));
const result = await client.classifyBatch({
files,
callbackUrl: options?.callbackUrl ?? '',
threshold: options?.threshold ?? 0.3,
});
return [
`**Batch Submitted**`,
`Batch ID: result.batchId`,
`Files: result.fileCount`,
`Estimated: ~result.estimatedCompletionMinutes min`,
`Status: result.status`,
].join('\n');
}
FILE:src/commands/classify.ts
import type { HearApiClient } from '@h-ear/core';
import { formatClassifySubmitted, formatClassifyResult } from '../formatter.js';
/** Async submit — returns immediately with job ID. callbackUrl is optional; client defaults to noop. */
export async function classifyCommand(
client: HearApiClient,
url: string,
options?: { threshold?: number; callbackUrl?: string },
): Promise<string> {
const accepted = await client.submitClassify({
url,
threshold: options?.threshold ?? 0.3,
...(options?.callbackUrl ? { callbackUrl: options.callbackUrl } : {}),
});
return formatClassifySubmitted(accepted);
}
/** Sync classify — submits, polls until complete, returns formatted result. */
export async function classifySyncCommand(
client: HearApiClient,
url: string,
options?: { threshold?: number },
onProgress?: (msg: string) => void,
): Promise<string> {
const result = await client.classify(
{ url, threshold: options?.threshold ?? 0.3 },
onProgress,
);
return formatClassifyResult(result);
}
FILE:src/commands/health.ts
import type { HearApiClient } from '@h-ear/core';
import { formatHealth } from '../formatter.js';
export async function healthCommand(client: HearApiClient): Promise<string> {
const result = await client.health();
return formatHealth(result);
}
FILE:src/commands/job-audio.ts
import type { HearApiClient } from '@h-ear/core';
import { formatJobAudio } from '../formatter.js';
export async function jobAudioCommand(
client: HearApiClient,
jobId: string,
): Promise<string> {
const result = await client.getJobAudio(jobId);
return formatJobAudio(result);
}
FILE:src/commands/job-events.ts
import type { HearApiClient } from '@h-ear/core';
import { formatJobEvents } from '../formatter.js';
export async function jobEventsCommand(
client: HearApiClient,
jobId: string,
options?: { minConfidence?: number; category?: string; offset?: number },
): Promise<string> {
const result = await client.getJobEvents(jobId, {
minConfidence: options?.minConfidence,
category: options?.category,
offset: options?.offset ?? 0,
});
return formatJobEvents(result);
}
FILE:src/commands/job-waveform.ts
import type { HearApiClient } from '@h-ear/core';
import { formatJobWaveform } from '../formatter.js';
export async function jobWaveformCommand(
client: HearApiClient,
jobId: string,
options?: { zoom?: 256 | 1024 | 4096 },
): Promise<string> {
const result = await client.getJobWaveform(jobId, { zoom: options?.zoom });
return formatJobWaveform(result);
}
FILE:src/commands/jobs.ts
import type { HearApiClient } from '@h-ear/core';
import { formatJobsList, formatJobDetail } from '../formatter.js';
export async function jobsCommand(
client: HearApiClient,
options?: { limit?: number; offset?: number; status?: string },
): Promise<string> {
const result = await client.listJobs({
limit: options?.limit ?? 10,
offset: options?.offset ?? 0,
status: options?.status,
});
return formatJobsList(result);
}
export async function jobDetailCommand(
client: HearApiClient,
jobId: string,
): Promise<string> {
const result = await client.getJob(jobId);
return formatJobDetail(result);
}
FILE:src/commands/sounds.ts
import type { HearApiClient } from '@h-ear/core';
import { formatClassesList } from '../formatter.js';
export async function soundsCommand(
client: HearApiClient,
search?: string,
options?: { limit?: number; offset?: number },
): Promise<string> {
const result = await client.listClasses({
category: search,
limit: options?.limit ?? 20,
offset: options?.offset ?? 0,
});
return formatClassesList(result, search);
}
FILE:src/commands/usage.ts
import type { HearApiClient } from '@h-ear/core';
import { formatUsage } from '../formatter.js';
export async function usageCommand(client: HearApiClient): Promise<string> {
const result = await client.usage();
return formatUsage(result);
}
FILE:src/commands/webhooks.ts
import type { HearApiClient } from '@h-ear/core';
import {
formatWebhookList, formatWebhookDetail, formatWebhookCreated,
formatWebhookPing, formatWebhookDeliveries,
} from '../formatter.js';
export async function webhookListCommand(client: HearApiClient): Promise<string> {
const result = await client.listEnterpriseWebhooks();
return formatWebhookList(result);
}
export async function webhookDetailCommand(client: HearApiClient, webhookId: string): Promise<string> {
const result = await client.getEnterpriseWebhook(webhookId);
return formatWebhookDetail(result);
}
export async function webhookCreateCommand(
client: HearApiClient,
url: string,
options?: {
events?: string[];
description?: string;
taxonomyFilter?: string[];
notificationTierDepth?: number;
notificationTierValues?: string[];
},
): Promise<string> {
const result = await client.createEnterpriseWebhook({
url,
events: options?.events,
description: options?.description,
taxonomyFilter: options?.taxonomyFilter,
notificationTierDepth: options?.notificationTierDepth,
notificationTierValues: options?.notificationTierValues,
});
return formatWebhookCreated(result);
}
export async function webhookPingCommand(client: HearApiClient, webhookId: string): Promise<string> {
const result = await client.pingEnterpriseWebhook(webhookId);
return formatWebhookPing(result);
}
export async function webhookDeliveriesCommand(
client: HearApiClient,
webhookId: string,
options?: { limit?: number },
): Promise<string> {
const result = await client.getEnterpriseWebhookDeliveries(webhookId, options);
return formatWebhookDeliveries(result);
}
FILE:src/config.ts
/**
* Config resolution for H-ear OpenClaw skill.
* Reads from environment variables (OpenClaw passes these from SKILL.md requires).
*/
import { ENVIRONMENTS, type ServerConfig, type HearEnvironment } from '@h-ear/core';
export function resolveConfig(): ServerConfig {
const apiKey = process.env.HEAR_API_KEY;
const bearerToken = process.env.HEAR_BEARER_TOKEN || undefined;
if (!apiKey && !bearerToken) throw new Error('HEAR_API_KEY or HEAR_BEARER_TOKEN environment variable is required');
const envStr = process.env.HEAR_ENV || 'prod';
const environment = (Object.keys(ENVIRONMENTS).includes(envStr) ? envStr : 'prod') as HearEnvironment;
const envConfig = ENVIRONMENTS[environment];
const baseUrl = process.env.HEAR_BASE_URL || envConfig.baseUrl;
const apiPath = envConfig.apiPath;
return { apiKey: apiKey || '', bearerToken, environment, baseUrl, apiPath };
}
FILE:src/formatter.ts
/**
* Format H-ear API responses as chat-friendly markdown.
* Designed for messaging channels: Telegram, Slack, Discord, WhatsApp, Teams.
*/
import type {
ClassifyResult, ClassesResult, HealthResult,
UsageResult, JobsResult, JobResult, AsyncAccepted,
JobEventsResult, JobAudioResult, JobWaveformResult,
EnterpriseWebhookListResult, EnterpriseWebhook, EnterpriseWebhookCreateResult,
PingResult, WebhookDeliveriesResult,
} from '@h-ear/core';
export function formatClassifyResult(result: ClassifyResult): string {
const lines: string[] = [
`**Audio Classification Complete**`,
`Duration: result.duration?.toFixed(1) ?? '?'s | result.eventCount noise events detected`,
];
if (result.eventCount === 0) {
lines.push('', 'No sound events detected above threshold.');
} else {
lines.push('', `Call getJobEvents("result.requestId") for the per-event records, or formatJobWithEvents(result, events) to render a combined view.`);
}
if (result.reportUrl) {
lines.push('', `Full report: result.reportUrl`);
}
return lines.join('\n');
}
export function formatClassesList(result: ClassesResult, search?: string): string {
const lines: string[] = [
`**Sound Classes** (result.taxonomy)`,
`result.totalFiltered of result.totalAvailable classes`,
'',
];
if (result.classes.length > 0) {
lines.push('| # | Class | Category |');
lines.push('|---|-------|----------|');
for (const cls of result.classes.slice(0, 20)) {
lines.push(`| cls.index | cls.name | cls.category |`);
}
if (result.pagination?.hasMore) {
lines.push('', `_Showing result.classes.length of result.totalFiltered — use pagination for more._`);
}
} else {
const hint = search ? ` matching "search"` : '';
lines.push(`No classes foundhint.`);
}
return lines.join('\n');
}
export function formatHealth(result: HealthResult): string {
return [
`**H-ear API Status**`,
`Status: result.status`,
`Version: result.version`,
`Deployed: result.deployedTimestamp`,
].join('\n');
}
export function formatUsage(result: UsageResult): string {
const minutesPct = result.period.minutesLimit > 0
? Math.round((result.period.minutesUsed / result.period.minutesLimit) * 100)
: 0;
const callsPct = result.daily.limit > 0
? Math.round((result.daily.used / result.daily.limit) * 100)
: 0;
return [
`**H-ear API Usage**`,
`Tier: result.tier`,
`Minutes: result.period.minutesUsed.toLocaleString() / result.period.minutesLimit.toLocaleString() (minutesPct%)`,
`Today: result.daily.used.toLocaleString() / result.daily.limit.toLocaleString() calls (callsPct%)`,
`Remaining: result.daily.remaining.toLocaleString() calls`,
`Period: result.period.start to result.period.end`,
].join('\n');
}
export function formatJobsList(result: JobsResult): string {
const lines: string[] = [
`**Recent Jobs** (result.total total)`,
'',
];
if (result.jobs.length > 0) {
lines.push('| Job ID | Status | File | Events | Created |');
lines.push('|--------|--------|------|--------|---------|');
for (const job of result.jobs) {
const id = (job.jobId || '-').substring(0, 8);
const status = job.status === 'completed' ? 'done' : job.status;
const file = job.fileName || '-';
const events = job.eventCount ?? '-';
const created = job.createdAt ? job.createdAt.substring(0, 16).replace('T', ' ') : '-';
lines.push(`| id... | status | file | events | created |`);
}
if (result.hasMore) {
lines.push('', `_Showing result.jobs.length of result.total — use "jobs last N" for more._`);
}
} else {
lines.push('No jobs found.');
}
return lines.join('\n');
}
export function formatJobDetail(result: JobResult): string {
const lines: string[] = [
`**Job result.jobId.substring(0, 8)...**`,
`Status: result.status`,
];
if (result.fileName) lines.push(`File: result.fileName`);
if (result.duration) lines.push(`Duration: result.duration.toFixed(1)s`);
if (result.eventCount !== undefined) lines.push(`Events: result.eventCount`);
lines.push(`Created: result.createdAt`);
if (result.completedAt) lines.push(`Completed: result.completedAt`);
if (result.eventCount && result.eventCount > 0) {
lines.push('', `Call getJobEvents("result.jobId") for the per-event records, or formatJobWithEvents(job, events) to render a combined view.`);
}
if (result.reportUrl) {
lines.push('', `Full report: result.reportUrl`);
}
return lines.join('\n');
}
/**
* Render a job's metadata + per-event records in one block. Convenient when the
* caller has just chained getJob(jobId) and getJobEvents(jobId).
*/
export function formatJobWithEvents(job: JobResult, events: JobEventsResult): string {
return [formatJobDetail(job), '', formatJobEvents(events)].join('\n');
}
export function formatClassifySubmitted(accepted: AsyncAccepted): string {
return [
`**Analyzing Audio**`,
`Job ID: accepted.requestId`,
`Status: accepted.status`,
`Results will be delivered when ready.`,
'',
`Check status: \`job accepted.requestId\``,
].join('\n');
}
export function formatJobEvents(result: JobEventsResult): string {
const lines: string[] = [
`**Job Events** (result.total total)`,
`Job: result.jobId.substring(0, 8)...`,
'',
];
if (result.events.length > 0) {
lines.push('| Sound | Confidence | Category | Time |');
lines.push('|-------|-----------|----------|------|');
for (const event of result.events.slice(0, 20)) {
const name = event.tier2 || event.tier1;
const pct = `Math.round(event.confidence * 100)%`;
const cat = event.tier1;
const time = `event.startTime.toFixed(1)s`;
lines.push(`| name | pct | cat | time |`);
}
if (result.hasMore) {
lines.push('', `_Showing result.events.length of result.total — use pagination for more._`);
}
} else {
lines.push('No events found.');
}
return lines.join('\n');
}
export function formatJobAudio(result: JobAudioResult): string {
const lines: string[] = [
`**Job Audio**`,
`Job: result.jobId.substring(0, 8)...`,
`URL: result.audioUrl`,
`Expires: result.expiresAt`,
];
if (result.duration) lines.push(`Duration: result.duration.toFixed(1)s`);
if (result.mimeType) lines.push(`Type: result.mimeType`);
return lines.join('\n');
}
export function formatJobWaveform(result: JobWaveformResult): string {
const lines: string[] = [
`**Job Waveform**`,
`Job: result.jobId.substring(0, 8)...`,
`Waveform: result.waveformUrl`,
`Zoom: result.zoom (result.samplesPerPixel samples/px)`,
`Expires: result.expiresAt`,
];
if (result.audioUrl) lines.push(`Audio: result.audioUrl`);
if (result.duration) lines.push(`Duration: result.duration.toFixed(1)s`);
return lines.join('\n');
}
export function formatAlertRegistered(soundClass: string): string {
return `Alert registered. You'll be notified whenever **soundClass** is detected.`;
}
export function formatAlertDeregistered(soundClass: string): string {
return `Alert for **soundClass** has been removed.`;
}
export function formatWebhookList(result: EnterpriseWebhookListResult): string {
const lines: string[] = [
`**Webhooks** (result.webhooks.length/result.maxWebhooks)`,
'',
];
if (result.webhooks.length > 0) {
lines.push('| ID | URL | Events | Status | Failures |');
lines.push('|----|-----|--------|--------|----------|');
for (const w of result.webhooks) {
const id = w.id.substring(0, 8);
const url = w.url.length > 40 ? w.url.substring(0, 37) + '...' : w.url;
const events = w.events.join(', ');
lines.push(`| id... | url | events | w.status | w.failureCount |`);
}
} else {
lines.push('No webhooks registered.');
}
if (result.canCreate) {
lines.push('', `_result.maxWebhooks - result.webhooks.length webhook slot(s) available._`);
}
return lines.join('\n');
}
export function formatWebhookDetail(webhook: EnterpriseWebhook): string {
const lines: string[] = [
`**Webhook webhook.id.substring(0, 8)...**`,
`URL: webhook.url`,
`Events: webhook.events.join(', ')`,
`Status: webhook.status`,
`Failures: webhook.failureCount`,
];
if (webhook.taxonomyFilter) lines.push(`Taxonomy filter: webhook.taxonomyFilter.join(', ')`);
if (webhook.notificationTierDepth) {
lines.push(`Tier depth: webhook.notificationTierDepth`);
if (webhook.notificationTierValues) lines.push(`Tier values: webhook.notificationTierValues.join(', ')`);
}
if (webhook.description) lines.push(`Description: webhook.description`);
if (webhook.lastDeliveryAt) lines.push(`Last delivery: webhook.lastDeliveryAt (webhook.lastDeliveryStatus)`);
if (webhook.disabledReason) lines.push(`Disabled reason: webhook.disabledReason`);
lines.push(`Created: webhook.createdAt`);
return lines.join('\n');
}
export function formatWebhookCreated(result: EnterpriseWebhookCreateResult): string {
return [
`**Webhook Created**`,
`ID: result.webhook.id`,
`URL: result.webhook.url`,
`Events: result.webhook.events.join(', ')`,
'',
`**Secret: \`result.secret\`**`,
`_This secret is shown ONCE. Save it now for webhook signature verification._`,
].join('\n');
}
export function formatWebhookPing(result: PingResult): string {
const status = result.success ? 'Success' : 'Failed';
return [
`**Webhook Ping status**`,
`Delivery ID: result.deliveryId`,
`Response: result.responseStatus ?? 'N/A'`,
`Duration: result.durationMsms`,
].join('\n');
}
export function formatWebhookDeliveries(result: WebhookDeliveriesResult): string {
const lines: string[] = [
`**Webhook Deliveries** (result.deliveries.length)`,
'',
];
if (result.deliveries.length > 0) {
lines.push('| Event | Status | Success | Attempt | Time |');
lines.push('|-------|--------|---------|---------|------|');
for (const d of result.deliveries) {
const status = d.responseStatus ?? '-';
const success = d.success ? 'yes' : 'no';
const time = d.createdAt.substring(0, 16).replace('T', ' ');
lines.push(`| d.event | status | success | d.attempt | time |`);
}
} else {
lines.push('No delivery records found.');
}
return lines.join('\n');
}
FILE:src/index.ts
/**
* @h-ear/openclaw — OpenClaw skill for H-ear World audio classification.
*
* Exposes H-ear Enterprise API as conversational commands for messaging channels.
*/
import { HearApiClient, type ServerConfig } from '@h-ear/core';
import { resolveConfig } from './config.js';
// Commands
export { classifyCommand, classifySyncCommand } from './commands/classify.js';
export { classifyBatchCommand } from './commands/classify-batch.js';
export { soundsCommand } from './commands/sounds.js';
export { healthCommand } from './commands/health.js';
export { usageCommand } from './commands/usage.js';
export { jobsCommand, jobDetailCommand } from './commands/jobs.js';
export { jobEventsCommand } from './commands/job-events.js';
export { jobAudioCommand } from './commands/job-audio.js';
export { jobWaveformCommand } from './commands/job-waveform.js';
export { alertOnCommand, alertOffCommand } from './commands/alerts.js';
export { webhookListCommand, webhookDetailCommand, webhookCreateCommand, webhookPingCommand, webhookDeliveriesCommand } from './commands/webhooks.js';
// Formatter
export {
formatClassifyResult, formatClassesList, formatHealth,
formatUsage, formatJobsList, formatJobDetail,
formatJobEvents, formatJobAudio, formatJobWaveform,
formatAlertRegistered, formatAlertDeregistered,
formatWebhookList, formatWebhookDetail, formatWebhookCreated,
formatWebhookPing, formatWebhookDeliveries,
} from './formatter.js';
// Config
export { resolveConfig } from './config.js';
/** Create a configured skill instance with API client. */
export function createSkill(config?: ServerConfig): {
client: HearApiClient;
config: ServerConfig;
} {
const resolved = config ?? resolveConfig();
const client = new HearApiClient(resolved);
return { client, config: resolved };
}
FILE:tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noEmitOnError": true,
"noImplicitAny": true,
"noImplicitReturns": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}