@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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +218 -0
  3. package/dist/a2aEnrollment.d.ts +41 -0
  4. package/dist/a2aEnrollment.js +110 -0
  5. package/dist/acceptance.d.ts +25 -0
  6. package/dist/acceptance.js +147 -0
  7. package/dist/acceptanceCli.d.ts +2 -0
  8. package/dist/acceptanceCli.js +6 -0
  9. package/dist/adapters/livekit.d.ts +15 -0
  10. package/dist/adapters/livekit.js +236 -0
  11. package/dist/adapters/openaiSpeech.d.ts +24 -0
  12. package/dist/adapters/openaiSpeech.js +194 -0
  13. package/dist/adapters/openclaw.d.ts +58 -0
  14. package/dist/adapters/openclaw.js +236 -0
  15. package/dist/adapters/speech.d.ts +13 -0
  16. package/dist/adapters/speech.js +57 -0
  17. package/dist/adapters/zeroclaw.d.ts +12 -0
  18. package/dist/adapters/zeroclaw.js +36 -0
  19. package/dist/bootstrap.d.ts +2 -0
  20. package/dist/bootstrap.js +68 -0
  21. package/dist/bragiDelivery.d.ts +31 -0
  22. package/dist/bragiDelivery.js +263 -0
  23. package/dist/bragiDeliveryCli.d.ts +2 -0
  24. package/dist/bragiDeliveryCli.js +32 -0
  25. package/dist/capability.d.ts +15 -0
  26. package/dist/capability.js +84 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +188 -0
  29. package/dist/config.d.ts +47 -0
  30. package/dist/config.js +107 -0
  31. package/dist/doctor.d.ts +55 -0
  32. package/dist/doctor.js +423 -0
  33. package/dist/doctorCli.d.ts +2 -0
  34. package/dist/doctorCli.js +36 -0
  35. package/dist/echoSuppression.d.ts +13 -0
  36. package/dist/echoSuppression.js +42 -0
  37. package/dist/fakes.d.ts +42 -0
  38. package/dist/fakes.js +80 -0
  39. package/dist/gateway.d.ts +46 -0
  40. package/dist/gateway.js +418 -0
  41. package/dist/grants.d.ts +22 -0
  42. package/dist/grants.js +40 -0
  43. package/dist/index.d.ts +23 -0
  44. package/dist/index.js +23 -0
  45. package/dist/installer.d.ts +41 -0
  46. package/dist/installer.js +83 -0
  47. package/dist/mediaMetrics.d.ts +15 -0
  48. package/dist/mediaMetrics.js +43 -0
  49. package/dist/mediaStages.d.ts +12 -0
  50. package/dist/mediaStages.js +48 -0
  51. package/dist/onboarding.d.ts +67 -0
  52. package/dist/onboarding.js +72 -0
  53. package/dist/openclaw-plugin.d.ts +16 -0
  54. package/dist/openclaw-plugin.js +47 -0
  55. package/dist/ports.d.ts +23 -0
  56. package/dist/ports.js +1 -0
  57. package/dist/presenceTone.d.ts +5 -0
  58. package/dist/presenceTone.js +43 -0
  59. package/dist/readiness.d.ts +27 -0
  60. package/dist/readiness.js +68 -0
  61. package/dist/service.d.ts +10 -0
  62. package/dist/service.js +42 -0
  63. package/dist/sessionControl.d.ts +55 -0
  64. package/dist/sessionControl.js +118 -0
  65. package/dist/speechErrors.d.ts +18 -0
  66. package/dist/speechErrors.js +26 -0
  67. package/dist/state-machine.d.ts +55 -0
  68. package/dist/state-machine.js +107 -0
  69. package/dist/types.d.ts +62 -0
  70. package/dist/types.js +1 -0
  71. package/dist/voiceSubtask.d.ts +12 -0
  72. package/dist/voiceSubtask.js +46 -0
  73. package/dist/workCallContext.d.ts +14 -0
  74. package/dist/workCallContext.js +26 -0
  75. package/docs/external-agent-onboarding.md +152 -0
  76. package/external-agent-contract.schema.json +46 -0
  77. package/openclaw.plugin.json +27 -0
  78. package/package.json +78 -0
  79. package/scripts/hermes/install.mjs +70 -0
  80. package/scripts/run-with-env.mjs +23 -0
  81. 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
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
+ }