@perkos/perkos-voice 0.1.0
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/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/a2aEnrollment.d.ts +41 -0
- package/dist/a2aEnrollment.js +110 -0
- package/dist/acceptance.d.ts +25 -0
- package/dist/acceptance.js +147 -0
- package/dist/acceptanceCli.d.ts +2 -0
- package/dist/acceptanceCli.js +6 -0
- package/dist/adapters/livekit.d.ts +15 -0
- package/dist/adapters/livekit.js +236 -0
- package/dist/adapters/openaiSpeech.d.ts +24 -0
- package/dist/adapters/openaiSpeech.js +194 -0
- package/dist/adapters/openclaw.d.ts +58 -0
- package/dist/adapters/openclaw.js +236 -0
- package/dist/adapters/speech.d.ts +13 -0
- package/dist/adapters/speech.js +57 -0
- package/dist/adapters/zeroclaw.d.ts +12 -0
- package/dist/adapters/zeroclaw.js +36 -0
- package/dist/bootstrap.d.ts +2 -0
- package/dist/bootstrap.js +68 -0
- package/dist/bragiDelivery.d.ts +31 -0
- package/dist/bragiDelivery.js +263 -0
- package/dist/bragiDeliveryCli.d.ts +2 -0
- package/dist/bragiDeliveryCli.js +32 -0
- package/dist/capability.d.ts +15 -0
- package/dist/capability.js +84 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +188 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.js +107 -0
- package/dist/doctor.d.ts +55 -0
- package/dist/doctor.js +423 -0
- package/dist/doctorCli.d.ts +2 -0
- package/dist/doctorCli.js +36 -0
- package/dist/echoSuppression.d.ts +13 -0
- package/dist/echoSuppression.js +42 -0
- package/dist/fakes.d.ts +42 -0
- package/dist/fakes.js +80 -0
- package/dist/gateway.d.ts +46 -0
- package/dist/gateway.js +418 -0
- package/dist/grants.d.ts +22 -0
- package/dist/grants.js +40 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/installer.d.ts +41 -0
- package/dist/installer.js +83 -0
- package/dist/mediaMetrics.d.ts +15 -0
- package/dist/mediaMetrics.js +43 -0
- package/dist/mediaStages.d.ts +12 -0
- package/dist/mediaStages.js +48 -0
- package/dist/onboarding.d.ts +67 -0
- package/dist/onboarding.js +72 -0
- package/dist/openclaw-plugin.d.ts +16 -0
- package/dist/openclaw-plugin.js +47 -0
- package/dist/ports.d.ts +23 -0
- package/dist/ports.js +1 -0
- package/dist/presenceTone.d.ts +5 -0
- package/dist/presenceTone.js +43 -0
- package/dist/readiness.d.ts +27 -0
- package/dist/readiness.js +68 -0
- package/dist/service.d.ts +10 -0
- package/dist/service.js +42 -0
- package/dist/sessionControl.d.ts +55 -0
- package/dist/sessionControl.js +118 -0
- package/dist/speechErrors.d.ts +18 -0
- package/dist/speechErrors.js +26 -0
- package/dist/state-machine.d.ts +55 -0
- package/dist/state-machine.js +107 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +1 -0
- package/dist/voiceSubtask.d.ts +12 -0
- package/dist/voiceSubtask.js +46 -0
- package/dist/workCallContext.d.ts +14 -0
- package/dist/workCallContext.js +26 -0
- package/docs/external-agent-onboarding.md +152 -0
- package/external-agent-contract.schema.json +46 -0
- package/openclaw.plugin.json +27 -0
- package/package.json +78 -0
- package/scripts/hermes/install.mjs +70 -0
- package/scripts/run-with-env.mjs +23 -0
- package/scripts/zeroclaw/install.mjs +76 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-owned Working Call continuity rules.
|
|
3
|
+
*
|
|
4
|
+
* Ownership:
|
|
5
|
+
* - PerkOS API claim attaches a bounded `workChatBrief` for final_pair sessions.
|
|
6
|
+
* - PerkOS Voice gateway MUST inject these rules whenever a brief is present.
|
|
7
|
+
* - Agent soul / A2A / full chat agents do NOT own this path. Media voice is thin
|
|
8
|
+
* and must not depend on agent-specific policies that deny prior context.
|
|
9
|
+
*
|
|
10
|
+
* External owners implementing a custom runtime beside PerkOS Voice must apply
|
|
11
|
+
* the same rules when `session.workChatBrief` is present on claim.
|
|
12
|
+
*/
|
|
13
|
+
export const WORK_CALL_CONTEXT_RULES = "This is a Working Call bound to the user's work chat. " +
|
|
14
|
+
"If Work-chat context appears below, it is real prior context from earlier final turns in this same chat — " +
|
|
15
|
+
"use it when the user refers to prior discussion, plans, or projects. " +
|
|
16
|
+
"Never claim you have no prior context when Work-chat context is present. " +
|
|
17
|
+
"You do not browse the full chat UI or tools; you only have this brief. " +
|
|
18
|
+
"Prefer the live user message when it conflicts. " +
|
|
19
|
+
"Do not invent facts absent from the brief or the live turn. " +
|
|
20
|
+
"Do not read the brief aloud unless asked.";
|
|
21
|
+
export function formatWorkCallContextBlock(brief) {
|
|
22
|
+
const cleaned = brief.trim();
|
|
23
|
+
if (!cleaned)
|
|
24
|
+
return "";
|
|
25
|
+
return `${WORK_CALL_CONTEXT_RULES}\n\nWork-chat context:\n${cleaned}`;
|
|
26
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# External-agent onboarding contract
|
|
2
|
+
|
|
3
|
+
This contract standardizes installation of PerkOS Voice beside an external
|
|
4
|
+
agent runtime when PerkOS cannot inspect or operate the agent's chat surface.
|
|
5
|
+
The only required runtime integration is a private, authenticated,
|
|
6
|
+
non-streaming request/response endpoint. Tools and identity remain inside the
|
|
7
|
+
agent runtime.
|
|
8
|
+
|
|
9
|
+
## Required contract
|
|
10
|
+
|
|
11
|
+
`external-agent-contract.schema.json` is the machine-readable source of truth.
|
|
12
|
+
`ExternalAgentOnboardingContract` and `runExternalAgentPreflight` provide the
|
|
13
|
+
same contract for installers and tests.
|
|
14
|
+
|
|
15
|
+
- The runtime must return one bounded non-streaming response, support
|
|
16
|
+
cancellation, answer its health probe within 10 seconds, and complete a **spoken**
|
|
17
|
+
turn within a declared budget no greater than 60 seconds. A deployment should use
|
|
18
|
+
the smallest budget the runtime can meet consistently.
|
|
19
|
+
- If the runtime starts longer work (research, investigate, write a doc,
|
|
20
|
+
implement) **and** the caller asked for that process, the gateway speaks
|
|
21
|
+
within `VOICE_SPEECH_SLA_MS` (default 4s): it tells the caller a **subtask**
|
|
22
|
+
was launched and keeps the call listening. Normal conversation
|
|
23
|
+
(“¿recuerdas?”, “de qué hablamos”) stays on the live turn even if the
|
|
24
|
+
runtime is slow. Do not rely on the agent soul to decide this.
|
|
25
|
+
- If a live turn is still thinking after `VOICE_PRESENCE_MS` (default 1.5s),
|
|
26
|
+
the gateway plays a local non-verbal presence tone (soft ticks) so the
|
|
27
|
+
caller knows the line is alive. The tone stops the moment the runtime
|
|
28
|
+
answers or the subtask ack speaks. `VOICE_PRESENCE_MS=0` disables it.
|
|
29
|
+
Do not use OpenAI TTS for this cue.
|
|
30
|
+
- The gateway must connect outbound to LiveKit, receive and publish audio,
|
|
31
|
+
support barge-in, and have working STT and TTS adapters. Raw audio and
|
|
32
|
+
transcripts are never part of the preflight result; transcript persistence
|
|
33
|
+
is `off`.
|
|
34
|
+
- Control requires dynamic short-lived sessions, encrypted one-use M2M
|
|
35
|
+
delivery/discovery, and allow-listed capability publication. Agent, project,
|
|
36
|
+
audience and expiry bindings remain API-enforced.
|
|
37
|
+
- Health means the process is alive. Readiness means runtime, media, speech,
|
|
38
|
+
M2M/control and capability publication probes all passed. Capability must be
|
|
39
|
+
unavailable or expired whenever readiness is false.
|
|
40
|
+
|
|
41
|
+
The preflight emits only fixed stage codes. It accepts injected probe ports, so
|
|
42
|
+
CI uses fakes and production can test private dependencies without logging
|
|
43
|
+
addresses, credentials, request content, transcripts, audio, or upstream error
|
|
44
|
+
bodies. There is intentionally no `chat` probe.
|
|
45
|
+
|
|
46
|
+
## Idempotent install and rollback
|
|
47
|
+
|
|
48
|
+
An installer records only an immutable image revision and a non-secret config
|
|
49
|
+
revision. `evaluateExternalAgentInstall` returns:
|
|
50
|
+
|
|
51
|
+
- `noop` when image, config and mode-0600 secret permissions already match;
|
|
52
|
+
- `reconfigure` for config drift or unsafe secret permissions;
|
|
53
|
+
- `replace_release` when the immutable image revision changes.
|
|
54
|
+
|
|
55
|
+
Before either mutation, retain the current immutable image reference and a
|
|
56
|
+
root-only configuration snapshot. Apply one component change, restart only the
|
|
57
|
+
gateway, and require `/health` before checking `/ready`. If health or preflight
|
|
58
|
+
fails, restore both references, restart the gateway, and publish unavailable.
|
|
59
|
+
Never mutate the agent runtime, chat, relay, firewall or provider account as an
|
|
60
|
+
implicit install step.
|
|
61
|
+
|
|
62
|
+
## Acceptance harness
|
|
63
|
+
|
|
64
|
+
An external owner can implement every `ExternalAgentProbePorts` method locally.
|
|
65
|
+
The harness proves the integration without PerkOS chat access:
|
|
66
|
+
|
|
67
|
+
1. validate the versioned contract and install decision;
|
|
68
|
+
2. probe liveness and readiness;
|
|
69
|
+
3. submit a synthetic, non-secret bounded runtime turn and verify a non-empty
|
|
70
|
+
result within the declared budget;
|
|
71
|
+
4. probe LiveKit media, STT/TTS, M2M session control and capability publisher;
|
|
72
|
+
5. expose only `ready`, `checkedAt` and fixed failure codes;
|
|
73
|
+
6. publish ready capability only after the complete result is green.
|
|
74
|
+
|
|
75
|
+
Provider calls are replaced with fakes in unit tests. A production smoke test
|
|
76
|
+
is separate, consent-bound and limited to one short session. It verifies media,
|
|
77
|
+
runtime response, playback, cancellation/end, absence of raw persistence and
|
|
78
|
+
continued agent health.
|
|
79
|
+
|
|
80
|
+
## Voice doctor and control-plane health (external agents)
|
|
81
|
+
|
|
82
|
+
External owners (and their ops agents) **self-heal** without PerkOS reading
|
|
83
|
+
their chat. The product split is:
|
|
84
|
+
|
|
85
|
+
| Layer | Who | What |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `perkos-voice-doctor` | Owner host | Runs fixed probes against runtime, speech, control plane; prints JSON codes + playbooks; exit 1 if not ready |
|
|
88
|
+
| `perkos-voice-doctor --report` | Owner host | Same, then `POST .../voice-control/health` with codes only |
|
|
89
|
+
| Gateway readiness loop | Gateway process | Publishes capability + health snapshot ~every 30s |
|
|
90
|
+
| Failed call | Gateway + API | Session `failed` + optional `stage` / `healthCodes` → `voiceHealth` history |
|
|
91
|
+
| `GET /agents/:id/voice-health` | PerkOS owner UI | Last codes, recent events, repair playbooks (no chat/audio/secrets) |
|
|
92
|
+
|
|
93
|
+
### Owner commands
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Same env + VOICE_SECRET_FILE as the running gateway
|
|
97
|
+
npx perkos-voice-doctor
|
|
98
|
+
npx perkos-voice-doctor --report
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Stdout is JSON only:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"schemaVersion": 1,
|
|
106
|
+
"ready": false,
|
|
107
|
+
"codes": ["runtime_not_ready", "speech_unavailable"],
|
|
108
|
+
"checkedAt": "...",
|
|
109
|
+
"source": "doctor",
|
|
110
|
+
"playbooks": [{ "code": "runtime_not_ready", "title": "...", "ownerActions": ["..."], "platformNotes": ["..."] }]
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Fixed codes (allow-listed)
|
|
115
|
+
|
|
116
|
+
Preflight/install: `contract_invalid`, `config_invalid`, `runtime_unhealthy`,
|
|
117
|
+
`runtime_not_ready`, `runtime_response_failed`, `runtime_response_too_slow`,
|
|
118
|
+
`media_unavailable`, `speech_unavailable`, `control_plane_unavailable`,
|
|
119
|
+
`capability_publish_unavailable`.
|
|
120
|
+
|
|
121
|
+
Session outcomes: `session_grant_failed`, `session_media_failed`,
|
|
122
|
+
`session_runtime_failed`, `session_stage_failed`.
|
|
123
|
+
|
|
124
|
+
Never log or store: transcripts, audio, secrets, runtime URLs, upstream error
|
|
125
|
+
bodies, or agent chat content.
|
|
126
|
+
|
|
127
|
+
### Installer recommendation
|
|
128
|
+
|
|
129
|
+
On failure of `/ready` or doctor:
|
|
130
|
+
|
|
131
|
+
1. Map codes → playbook actions (restart runtime, rotate M2M, fix speech key).
|
|
132
|
+
2. Restart **only** the voice gateway (not the agent soul/chat unless the code
|
|
133
|
+
is runtime_*).
|
|
134
|
+
3. Re-run doctor until `ready: true`, then confirm capability available in the app.
|
|
135
|
+
|
|
136
|
+
PerkOS will not SSH into external hosts or open the agent’s chat surface.
|
|
137
|
+
|
|
138
|
+
## Working Call context (required for all agents)
|
|
139
|
+
|
|
140
|
+
PerkOS Voice media path is thin: it does **not** load the agent’s full A2A/chat
|
|
141
|
+
stack. Continuity across Working Calls is provided by the control plane:
|
|
142
|
+
|
|
143
|
+
- Claim may include `session.workChatBrief` for `final_pair` sessions only.
|
|
144
|
+
- If present, your runtime (or the PerkOS thin chat-completions adapter) **must**
|
|
145
|
+
treat that brief as real prior work-chat context for the live call.
|
|
146
|
+
- Do not answer “I have no access to previous conversations” when a brief is
|
|
147
|
+
present. You still must not invent facts outside the brief + live turn.
|
|
148
|
+
- Private Call (`chatCommit.policy = none`) has no brief and must not persist
|
|
149
|
+
turns to chat.
|
|
150
|
+
|
|
151
|
+
Installers should not put this rule only in agent soul/A2A prompts. The gateway
|
|
152
|
+
claim path is the source of truth so every external runtime behaves the same.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://perkos.xyz/schemas/voice/external-agent-v1.json",
|
|
4
|
+
"title": "PerkOS Voice external-agent onboarding contract",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["version", "runtime", "media", "speech", "control", "install"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"version": { "const": "perkos.voice.external-agent/v1" },
|
|
10
|
+
"runtime": {
|
|
11
|
+
"type": "object", "additionalProperties": false,
|
|
12
|
+
"required": ["mode", "nonStreaming", "cancellation", "responseTimeoutMs", "healthTimeoutMs"],
|
|
13
|
+
"properties": {
|
|
14
|
+
"mode": { "enum": ["responses", "chat_completions", "zeroclaw_webhook"] },
|
|
15
|
+
"nonStreaming": { "const": true },
|
|
16
|
+
"cancellation": { "const": true },
|
|
17
|
+
"responseTimeoutMs": { "type": "integer", "minimum": 1000, "maximum": 60000 },
|
|
18
|
+
"healthTimeoutMs": { "type": "integer", "minimum": 250, "maximum": 10000 }
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"media": {
|
|
22
|
+
"type": "object", "additionalProperties": false,
|
|
23
|
+
"required": ["livekit", "inboundAudio", "outboundAudio", "bargeIn"],
|
|
24
|
+
"properties": { "livekit": { "const": true }, "inboundAudio": { "const": true }, "outboundAudio": { "const": true }, "bargeIn": { "const": true } }
|
|
25
|
+
},
|
|
26
|
+
"speech": {
|
|
27
|
+
"type": "object", "additionalProperties": false,
|
|
28
|
+
"required": ["stt", "tts", "transcriptPersistence"],
|
|
29
|
+
"properties": { "stt": { "const": true }, "tts": { "const": true }, "transcriptPersistence": { "const": "off" } }
|
|
30
|
+
},
|
|
31
|
+
"control": {
|
|
32
|
+
"type": "object", "additionalProperties": false,
|
|
33
|
+
"required": ["dynamicSessions", "encryptedM2mDelivery", "oneTimeDiscovery", "capabilityHandshake"],
|
|
34
|
+
"properties": { "dynamicSessions": { "const": true }, "encryptedM2mDelivery": { "const": true }, "oneTimeDiscovery": { "const": true }, "capabilityHandshake": { "const": "allow_listed" } }
|
|
35
|
+
},
|
|
36
|
+
"install": {
|
|
37
|
+
"type": "object", "additionalProperties": false,
|
|
38
|
+
"required": ["immutableRelease", "secretFileMode", "healthPath", "readinessPath", "capabilityPath", "configRevision"],
|
|
39
|
+
"properties": {
|
|
40
|
+
"immutableRelease": { "const": true }, "secretFileMode": { "const": "0600" },
|
|
41
|
+
"healthPath": { "const": "/health" }, "readinessPath": { "const": "/ready" }, "capabilityPath": { "const": "/capabilities" },
|
|
42
|
+
"configRevision": { "type": "string", "minLength": 1 }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "perkos-voice",
|
|
3
|
+
"name": "PerkOS Voice",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Installs and supervises the PerkOS Voice gateway beside OpenClaw",
|
|
6
|
+
"author": "PerkOS",
|
|
7
|
+
"configSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": false,
|
|
10
|
+
"required": ["agentId", "agentName", "environment", "secretFile", "sessionControlEndpoint", "grantEndpoint", "capabilityPublishEndpoint"],
|
|
11
|
+
"properties": {
|
|
12
|
+
"agentId": { "type": "string", "minLength": 3 },
|
|
13
|
+
"agentName": { "type": "string", "minLength": 1 },
|
|
14
|
+
"environment": { "enum": ["dev", "qa", "production"] },
|
|
15
|
+
"secretFile": { "type": "string", "minLength": 1 },
|
|
16
|
+
"runtimeEndpoint": { "type": "string", "default": "http://127.0.0.1:18789/v1/responses" },
|
|
17
|
+
"runtimeProtocol": { "enum": ["responses", "chat_completions"], "default": "responses" },
|
|
18
|
+
"runtimeModel": { "type": "string" },
|
|
19
|
+
"sessionControlEndpoint": { "type": "string", "format": "uri" },
|
|
20
|
+
"grantEndpoint": { "type": "string", "format": "uri" },
|
|
21
|
+
"capabilityPublishEndpoint": { "type": "string", "format": "uri" },
|
|
22
|
+
"port": { "type": "number", "default": 18081 }
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"activation": { "onStartup": true }
|
|
26
|
+
}
|
|
27
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@perkos/perkos-voice",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Voice gateway contracts and runtime adapters for PerkOS agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"perkos-voice": "dist/cli.js",
|
|
9
|
+
"perkos-voice-doctor": "dist/doctorCli.js",
|
|
10
|
+
"perkos-voice-bragi-delivery": "dist/bragiDeliveryCli.js",
|
|
11
|
+
"perkos-voice-acceptance": "dist/acceptanceCli.js",
|
|
12
|
+
"perkos-voice-install-hermes": "scripts/hermes/install.mjs",
|
|
13
|
+
"perkos-voice-install-zeroclaw": "scripts/zeroclaw/install.mjs"
|
|
14
|
+
},
|
|
15
|
+
"types": "dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./types": {
|
|
22
|
+
"types": "./dist/types.d.ts",
|
|
23
|
+
"default": "./dist/types.js"
|
|
24
|
+
},
|
|
25
|
+
"./openclaw": {
|
|
26
|
+
"types": "./dist/openclaw-plugin.d.ts",
|
|
27
|
+
"default": "./dist/openclaw-plugin.js"
|
|
28
|
+
},
|
|
29
|
+
"./a2a": {
|
|
30
|
+
"types": "./dist/a2aEnrollment.d.ts",
|
|
31
|
+
"default": "./dist/a2aEnrollment.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist/**",
|
|
36
|
+
"external-agent-contract.schema.json",
|
|
37
|
+
"docs/external-agent-onboarding.md",
|
|
38
|
+
"README.md",
|
|
39
|
+
"openclaw.plugin.json",
|
|
40
|
+
"scripts/hermes/install.mjs",
|
|
41
|
+
"scripts/zeroclaw/install.mjs",
|
|
42
|
+
"scripts/run-with-env.mjs",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc",
|
|
47
|
+
"lint": "tsc --noEmit",
|
|
48
|
+
"test": "tsc -p tsconfig.test.json && node --test .test-dist/*.test.js .test-dist/adapters/*.test.js",
|
|
49
|
+
"acceptance:local": "node dist/acceptanceCli.js"
|
|
50
|
+
},
|
|
51
|
+
"keywords": [
|
|
52
|
+
"perkos",
|
|
53
|
+
"voice",
|
|
54
|
+
"livekit",
|
|
55
|
+
"openclaw",
|
|
56
|
+
"hermes",
|
|
57
|
+
"agent"
|
|
58
|
+
],
|
|
59
|
+
"author": "PerkOS <julio.cruz@eb-ms.net>",
|
|
60
|
+
"license": "MIT",
|
|
61
|
+
"repository": {
|
|
62
|
+
"type": "git",
|
|
63
|
+
"url": "git+https://github.com/PerkOS-xyz/PerkOS-Voice.git"
|
|
64
|
+
},
|
|
65
|
+
"openclaw": {
|
|
66
|
+
"extensions": ["./dist/openclaw-plugin.js"],
|
|
67
|
+
"install": {
|
|
68
|
+
"npmSpec": "@perkos/perkos-voice",
|
|
69
|
+
"localPath": "extensions/perkos-voice",
|
|
70
|
+
"defaultChoice": "npm"
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@types/node": "^22.10.0",
|
|
75
|
+
"typescript": "^5.7.0"
|
|
76
|
+
},
|
|
77
|
+
"dependencies": { "@livekit/rtc-node": "0.13.33" }
|
|
78
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { homedir, platform, userInfo } from "node:os";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
function value(flag) {
|
|
7
|
+
const index = process.argv.indexOf(flag);
|
|
8
|
+
return index < 0 ? undefined : process.argv[index + 1];
|
|
9
|
+
}
|
|
10
|
+
function fail(message) { process.stderr.write(`perkos-voice-install-hermes: ${message}\n`); process.exit(2); }
|
|
11
|
+
function xml(value) { return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
|
12
|
+
|
|
13
|
+
if (process.argv.includes("--help")) {
|
|
14
|
+
process.stdout.write(`Usage: perkos-voice-install-hermes --emit --agent-id ID --agent-name NAME --environment dev|qa|production --voice-env-file PATH --secret-file PATH\n`);
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
if (!process.argv.includes("--emit")) fail("only the reviewable --emit mode is supported in v1");
|
|
18
|
+
const agentId = value("--agent-id");
|
|
19
|
+
const agentName = value("--agent-name");
|
|
20
|
+
const environment = value("--environment");
|
|
21
|
+
const envFile = value("--voice-env-file");
|
|
22
|
+
const secretFile = value("--secret-file");
|
|
23
|
+
if (!agentId || !agentName || !envFile || !secretFile) fail("agent, environment, env-file and secret-file are required");
|
|
24
|
+
if (!["dev", "qa", "production"].includes(environment)) fail("environment must be dev, qa or production");
|
|
25
|
+
|
|
26
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
27
|
+
const cli = resolve(root, "dist", "cli.js");
|
|
28
|
+
const envLauncher = resolve(root, "scripts", "run-with-env.mjs");
|
|
29
|
+
const label = `xyz.perkos.voice.${agentName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.${environment}`;
|
|
30
|
+
const common = {
|
|
31
|
+
VOICE_AGENT_ID: agentId,
|
|
32
|
+
VOICE_CANONICAL_AGENT_NAME: agentName,
|
|
33
|
+
VOICE_SECRET_FILE: secretFile,
|
|
34
|
+
PERKOS_VOICE_RUNTIME: "hermes",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
if (platform() === "darwin") {
|
|
38
|
+
const pairs = Object.entries(common).map(([key, entry]) => ` <key>${key}</key>\n <string>${xml(entry)}</string>`).join("\n");
|
|
39
|
+
process.stdout.write(`<?xml version="1.0" encoding="UTF-8"?>
|
|
40
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
41
|
+
<plist version="1.0"><dict>
|
|
42
|
+
<key>Label</key><string>${label}</string>
|
|
43
|
+
<key>ProgramArguments</key><array><string>${xml(process.execPath)}</string><string>${xml(envLauncher)}</string><string>--voice-env-file</string><string>${xml(envFile)}</string></array>
|
|
44
|
+
<key>EnvironmentVariables</key><dict>${pairs}</dict>
|
|
45
|
+
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
|
|
46
|
+
<key>StandardOutPath</key><string>${xml(`${homedir()}/Library/Logs/${label}.log`)}</string>
|
|
47
|
+
<key>StandardErrorPath</key><string>${xml(`${homedir()}/Library/Logs/${label}.err.log`)}</string>
|
|
48
|
+
</dict></plist>\n`);
|
|
49
|
+
} else {
|
|
50
|
+
process.stdout.write(`[Unit]
|
|
51
|
+
Description=PerkOS Voice gateway for ${agentName} (${environment})
|
|
52
|
+
After=network-online.target
|
|
53
|
+
Wants=network-online.target
|
|
54
|
+
|
|
55
|
+
[Service]
|
|
56
|
+
Type=simple
|
|
57
|
+
User=${userInfo().username}
|
|
58
|
+
EnvironmentFile=${envFile}
|
|
59
|
+
Environment=VOICE_AGENT_ID=${agentId}
|
|
60
|
+
Environment=VOICE_CANONICAL_AGENT_NAME=${agentName}
|
|
61
|
+
Environment=VOICE_SECRET_FILE=${secretFile}
|
|
62
|
+
Environment=PERKOS_VOICE_RUNTIME=hermes
|
|
63
|
+
ExecStart=${process.execPath} ${cli}
|
|
64
|
+
Restart=on-failure
|
|
65
|
+
RestartSec=5
|
|
66
|
+
|
|
67
|
+
[Install]
|
|
68
|
+
WantedBy=default.target
|
|
69
|
+
`);
|
|
70
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const index = process.argv.indexOf("--voice-env-file");
|
|
8
|
+
if (index < 0 || !process.argv[index + 1]) throw new Error("--voice-env-file is required");
|
|
9
|
+
const allowed = /^(VOICE_|PERKOS_VOICE_|OPENAI_|OPENCLAW_)[A-Z0-9_]+$/;
|
|
10
|
+
const loaded = {};
|
|
11
|
+
for (const raw of readFileSync(process.argv[index + 1], "utf8").split(/\r?\n/)) {
|
|
12
|
+
const line = raw.trim();
|
|
13
|
+
if (!line || line.startsWith("#")) continue;
|
|
14
|
+
const separator = line.indexOf("=");
|
|
15
|
+
if (separator < 1) throw new Error("invalid environment line");
|
|
16
|
+
const key = line.slice(0, separator).trim();
|
|
17
|
+
if (!allowed.test(key) || key.includes("SECRET") || key.includes("CREDENTIAL") || key.includes("API_KEY")) throw new Error(`unsafe environment key: ${key}`);
|
|
18
|
+
loaded[key] = line.slice(separator + 1).trim();
|
|
19
|
+
}
|
|
20
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
21
|
+
const child = spawn(process.execPath, [resolve(root, "dist", "cli.js")], { env: { ...process.env, ...loaded }, stdio: "inherit" });
|
|
22
|
+
for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => child.kill(signal));
|
|
23
|
+
child.once("exit", (code, signal) => process.exitCode = signal ? 1 : code ?? 1);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { homedir, platform, userInfo } from "node:os";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
function value(flag) { const index = process.argv.indexOf(flag); return index < 0 ? undefined : process.argv[index + 1]; }
|
|
7
|
+
function fail(message) { process.stderr.write(`perkos-voice-install-zeroclaw: ${message}\n`); process.exit(2); }
|
|
8
|
+
function xml(input) { return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
|
9
|
+
function loopbackWebhook(input) {
|
|
10
|
+
let url;
|
|
11
|
+
try { url = new URL(input); } catch { fail("--zeroclaw-url must be a valid URL"); }
|
|
12
|
+
if (!new Set(["127.0.0.1", "localhost", "::1"]).has(url.hostname)) fail("--zeroclaw-url must stay on loopback");
|
|
13
|
+
if (url.pathname !== "/webhook" || url.search || url.hash || url.username || url.password) fail("--zeroclaw-url must be the clean /webhook endpoint");
|
|
14
|
+
return url.toString();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (process.argv.includes("--help")) {
|
|
18
|
+
process.stdout.write("Usage: perkos-voice-install-zeroclaw --emit --agent-id ID --agent-name NAME --environment dev|qa|production --voice-env-file PATH --secret-file PATH [--zeroclaw-url http://127.0.0.1:42617/webhook]\n");
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
if (!process.argv.includes("--emit")) fail("only the reviewable --emit mode is supported in v1");
|
|
22
|
+
const agentId = value("--agent-id");
|
|
23
|
+
const agentName = value("--agent-name");
|
|
24
|
+
const environment = value("--environment");
|
|
25
|
+
const envFile = value("--voice-env-file");
|
|
26
|
+
const secretFile = value("--secret-file");
|
|
27
|
+
if (!agentId || !agentName || !envFile || !secretFile) fail("agent, environment, env-file and secret-file are required");
|
|
28
|
+
if (!["dev", "qa", "production"].includes(environment)) fail("environment must be dev, qa or production");
|
|
29
|
+
const endpoint = loopbackWebhook(value("--zeroclaw-url") || "http://127.0.0.1:42617/webhook");
|
|
30
|
+
|
|
31
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
32
|
+
const cli = resolve(root, "dist", "cli.js");
|
|
33
|
+
const envLauncher = resolve(root, "scripts", "run-with-env.mjs");
|
|
34
|
+
const slug = agentName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
35
|
+
const label = `xyz.perkos.voice.${slug}.${environment}`;
|
|
36
|
+
const common = {
|
|
37
|
+
VOICE_AGENT_ID: agentId,
|
|
38
|
+
VOICE_CANONICAL_AGENT_NAME: agentName,
|
|
39
|
+
VOICE_SECRET_FILE: secretFile,
|
|
40
|
+
PERKOS_VOICE_RUNTIME: "zeroclaw",
|
|
41
|
+
PERKOS_VOICE_RUNTIME_PROTOCOL: "zeroclaw_webhook",
|
|
42
|
+
PERKOS_VOICE_RUNTIME_ENDPOINT: endpoint,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (platform() === "darwin") {
|
|
46
|
+
const pairs = Object.entries(common).map(([key, entry]) => ` <key>${key}</key>\n <string>${xml(entry)}</string>`).join("\n");
|
|
47
|
+
process.stdout.write(`<?xml version="1.0" encoding="UTF-8"?>
|
|
48
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
49
|
+
<plist version="1.0"><dict>
|
|
50
|
+
<key>Label</key><string>${label}</string>
|
|
51
|
+
<key>ProgramArguments</key><array><string>${xml(process.execPath)}</string><string>${xml(envLauncher)}</string><string>--voice-env-file</string><string>${xml(envFile)}</string></array>
|
|
52
|
+
<key>EnvironmentVariables</key><dict>${pairs}</dict>
|
|
53
|
+
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
|
|
54
|
+
<key>StandardOutPath</key><string>${xml(`${homedir()}/Library/Logs/${label}.log`)}</string>
|
|
55
|
+
<key>StandardErrorPath</key><string>${xml(`${homedir()}/Library/Logs/${label}.err.log`)}</string>
|
|
56
|
+
</dict></plist>\n`);
|
|
57
|
+
} else {
|
|
58
|
+
const environmentLines = Object.entries(common).map(([key, entry]) => `Environment=${key}=${entry}`).join("\n");
|
|
59
|
+
process.stdout.write(`[Unit]
|
|
60
|
+
Description=PerkOS Voice gateway for ZeroClaw ${agentName} (${environment})
|
|
61
|
+
After=network-online.target
|
|
62
|
+
Wants=network-online.target
|
|
63
|
+
|
|
64
|
+
[Service]
|
|
65
|
+
Type=simple
|
|
66
|
+
User=${userInfo().username}
|
|
67
|
+
EnvironmentFile=${envFile}
|
|
68
|
+
${environmentLines}
|
|
69
|
+
ExecStart=${process.execPath} ${cli}
|
|
70
|
+
Restart=on-failure
|
|
71
|
+
RestartSec=5
|
|
72
|
+
|
|
73
|
+
[Install]
|
|
74
|
+
WantedBy=default.target
|
|
75
|
+
`);
|
|
76
|
+
}
|