@app-vitals/agent-runtime 0.0.1 → 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/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +38 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
package/voice.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { type SpawnOptions, spawn } from "node:child_process";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { resolvePiperVoicePaths, validatePiperVoice } from "./piper-voice.ts";
|
|
7
|
+
|
|
8
|
+
const VOICE_DIR = join(tmpdir(), "shipwright-agent-voice");
|
|
9
|
+
const WHISPER_MAX_BYTES = 25 * 1024 * 1024; // 25MB
|
|
10
|
+
|
|
11
|
+
export interface VoiceConfig {
|
|
12
|
+
groqApiKey?: string;
|
|
13
|
+
elevenLabsApiKey?: string;
|
|
14
|
+
voiceId?: string;
|
|
15
|
+
piperVoice?: string;
|
|
16
|
+
whisperServiceUrl?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// DI seam for the whisper-svc HTTP client — replaces spawnFn from the old local-Whisper path
|
|
20
|
+
export type WhisperClientFn = (
|
|
21
|
+
audioBuffer: Buffer,
|
|
22
|
+
audioPath: string,
|
|
23
|
+
) => Promise<string | null>;
|
|
24
|
+
|
|
25
|
+
type SpawnFn = (
|
|
26
|
+
command: string,
|
|
27
|
+
args: string[],
|
|
28
|
+
options?: SpawnOptions,
|
|
29
|
+
) => ReturnType<typeof spawn>;
|
|
30
|
+
|
|
31
|
+
const defaultSpawn: SpawnFn = (cmd, args, opts) => spawn(cmd, args, opts ?? {});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates an HTTP client that transcribes audio via the self-hosted Whisper pod
|
|
35
|
+
* the chart ships: onerahmet/openai-whisper-asr-webservice.
|
|
36
|
+
*
|
|
37
|
+
* That image exposes a single ASR endpoint — `POST /asr` — with the audio
|
|
38
|
+
* attached as the multipart field `audio_file`. The behaviour is selected via
|
|
39
|
+
* query params; with `output=txt` the service returns the transcription as a
|
|
40
|
+
* plain-text body (NOT JSON). We pin:
|
|
41
|
+
* - task=transcribe (transcribe, not translate-to-English)
|
|
42
|
+
* - output=txt (plain text body, simplest to consume)
|
|
43
|
+
* - encode=true (let ffmpeg inside the image decode arbitrary input)
|
|
44
|
+
*
|
|
45
|
+
* Accepts an optional fetchFn for dependency injection in tests (avoids
|
|
46
|
+
* global.fetch mocking).
|
|
47
|
+
*/
|
|
48
|
+
export function makeWhisperSvcClient(
|
|
49
|
+
serviceUrl: string,
|
|
50
|
+
fetchFn: typeof fetch = globalThis.fetch,
|
|
51
|
+
): WhisperClientFn {
|
|
52
|
+
return async (
|
|
53
|
+
audioBuffer: Buffer,
|
|
54
|
+
audioPath: string,
|
|
55
|
+
): Promise<string | null> => {
|
|
56
|
+
const formData = new FormData();
|
|
57
|
+
const blob = new Blob([new Uint8Array(audioBuffer)]);
|
|
58
|
+
// onerahmet's ASR endpoint reads the upload from the `audio_file` field.
|
|
59
|
+
formData.append(
|
|
60
|
+
"audio_file",
|
|
61
|
+
blob,
|
|
62
|
+
audioPath.split("/").pop() ?? "audio.wav",
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const base = serviceUrl.replace(/\/+$/, "");
|
|
66
|
+
const url = `${base}/asr?encode=true&task=transcribe&output=txt`;
|
|
67
|
+
|
|
68
|
+
let resp: Response;
|
|
69
|
+
try {
|
|
70
|
+
resp = await fetchFn(url, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
body: formData,
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.warn("[voice] whisper-svc request failed:", err);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!resp.ok) {
|
|
80
|
+
console.warn(
|
|
81
|
+
"[voice] whisper-svc transcription failed:",
|
|
82
|
+
resp.status,
|
|
83
|
+
await resp.text(),
|
|
84
|
+
);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// output=txt → the body IS the transcription (no JSON envelope).
|
|
89
|
+
const text = (await resp.text()).trim();
|
|
90
|
+
return text.length > 0 ? text : null;
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Transcribe an audio file via Groq Whisper API.
|
|
96
|
+
* Returns the transcription text, or null on any error.
|
|
97
|
+
*/
|
|
98
|
+
async function transcribeGroq(
|
|
99
|
+
audioPath: string,
|
|
100
|
+
fileBuffer: Buffer,
|
|
101
|
+
apiKey: string,
|
|
102
|
+
fetchFn: typeof fetch = globalThis.fetch,
|
|
103
|
+
): Promise<string | null> {
|
|
104
|
+
const formData = new FormData();
|
|
105
|
+
const blob = new Blob([new Uint8Array(fileBuffer)]);
|
|
106
|
+
formData.append("file", blob, audioPath.split("/").pop() ?? "audio.webm");
|
|
107
|
+
formData.append("model", "whisper-large-v3");
|
|
108
|
+
|
|
109
|
+
let resp: Response;
|
|
110
|
+
try {
|
|
111
|
+
resp = await fetchFn(
|
|
112
|
+
"https://api.groq.com/openai/v1/audio/transcriptions",
|
|
113
|
+
{
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
116
|
+
body: formData,
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error("[voice] Groq STT request failed:", err);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!resp.ok) {
|
|
125
|
+
console.error("[voice] Groq STT failed:", resp.status, await resp.text());
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const data = (await resp.json()) as { text: string };
|
|
130
|
+
return data.text ?? null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Transcribe an audio file.
|
|
135
|
+
* Fallback chain: Groq (if key set) → whisper-svc HTTP (if URL set or client injected) → null
|
|
136
|
+
*
|
|
137
|
+
* Accepts an optional fetchFn for DI in tests (avoids global.fetch mocking).
|
|
138
|
+
* Accepts an optional whisperClientFn for tests that inject a custom WhisperClientFn directly.
|
|
139
|
+
*/
|
|
140
|
+
export async function transcribeAudio(
|
|
141
|
+
audioPath: string,
|
|
142
|
+
voiceConfig: VoiceConfig = {},
|
|
143
|
+
fetchFn?: typeof fetch,
|
|
144
|
+
whisperClientFn?: WhisperClientFn,
|
|
145
|
+
): Promise<string | null> {
|
|
146
|
+
let fileBuffer: Buffer;
|
|
147
|
+
try {
|
|
148
|
+
fileBuffer = await readFile(audioPath);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.warn("[voice] could not read audio file:", err);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (fileBuffer.byteLength > WHISPER_MAX_BYTES) {
|
|
155
|
+
console.warn(
|
|
156
|
+
`[voice] audio file exceeds 25MB limit (${fileBuffer.byteLength} bytes) — skipping transcription`,
|
|
157
|
+
);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const resolvedFetch = fetchFn ?? globalThis.fetch;
|
|
162
|
+
|
|
163
|
+
const apiKey = voiceConfig.groqApiKey;
|
|
164
|
+
if (apiKey) {
|
|
165
|
+
const groqResult = await transcribeGroq(
|
|
166
|
+
audioPath,
|
|
167
|
+
fileBuffer,
|
|
168
|
+
apiKey,
|
|
169
|
+
resolvedFetch,
|
|
170
|
+
);
|
|
171
|
+
if (groqResult !== null) {
|
|
172
|
+
return groqResult;
|
|
173
|
+
}
|
|
174
|
+
console.warn(
|
|
175
|
+
"[voice] Groq transcription failed — falling back to whisper-svc",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const clientFn =
|
|
180
|
+
whisperClientFn ??
|
|
181
|
+
(voiceConfig.whisperServiceUrl
|
|
182
|
+
? makeWhisperSvcClient(voiceConfig.whisperServiceUrl, resolvedFetch)
|
|
183
|
+
: null);
|
|
184
|
+
|
|
185
|
+
if (!clientFn) return null;
|
|
186
|
+
return clientFn(fileBuffer, audioPath);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Synthesize speech from text using ElevenLabs (if key available) or the
|
|
191
|
+
* self-hosted Piper TTS binary baked into the image.
|
|
192
|
+
* Returns the path to the generated audio file, or null on failure.
|
|
193
|
+
*
|
|
194
|
+
* Accepts an optional fetchFn for DI in tests (avoids global.fetch mocking).
|
|
195
|
+
* Accepts an optional spawnFn for Piper subprocess injection in tests.
|
|
196
|
+
*/
|
|
197
|
+
export async function synthesizeSpeech(
|
|
198
|
+
text: string,
|
|
199
|
+
voiceConfig: VoiceConfig = {},
|
|
200
|
+
fetchFn?: typeof fetch,
|
|
201
|
+
spawnFn: SpawnFn = defaultSpawn,
|
|
202
|
+
): Promise<string | null> {
|
|
203
|
+
mkdirSync(VOICE_DIR, { recursive: true });
|
|
204
|
+
|
|
205
|
+
const elevenKey = voiceConfig.elevenLabsApiKey;
|
|
206
|
+
if (elevenKey) {
|
|
207
|
+
return synthesizeElevenLabs(
|
|
208
|
+
text,
|
|
209
|
+
elevenKey,
|
|
210
|
+
voiceConfig.voiceId,
|
|
211
|
+
fetchFn ?? globalThis.fetch,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return synthesizePiper(text, voiceConfig, spawnFn);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function synthesizeElevenLabs(
|
|
219
|
+
text: string,
|
|
220
|
+
apiKey: string,
|
|
221
|
+
voiceId?: string,
|
|
222
|
+
fetchFn: typeof fetch = globalThis.fetch,
|
|
223
|
+
): Promise<string | null> {
|
|
224
|
+
const vid = voiceId ?? "CwhRBWXzGAHq8TQ4Fs17"; // Roger
|
|
225
|
+
|
|
226
|
+
let resp: Response;
|
|
227
|
+
try {
|
|
228
|
+
resp = await fetchFn(`https://api.elevenlabs.io/v1/text-to-speech/${vid}`, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: {
|
|
231
|
+
"xi-api-key": apiKey,
|
|
232
|
+
"Content-Type": "application/json",
|
|
233
|
+
},
|
|
234
|
+
body: JSON.stringify({ text, model_id: "eleven_flash_v2_5" }),
|
|
235
|
+
});
|
|
236
|
+
} catch (err) {
|
|
237
|
+
console.error("[voice] ElevenLabs TTS request failed:", err);
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!resp.ok) {
|
|
242
|
+
console.error(
|
|
243
|
+
"[voice] ElevenLabs TTS failed:",
|
|
244
|
+
resp.status,
|
|
245
|
+
await resp.text(),
|
|
246
|
+
);
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const outPath = join(VOICE_DIR, `${Date.now()}-response.mp3`);
|
|
251
|
+
await writeFile(outPath, Buffer.from(await resp.arrayBuffer()));
|
|
252
|
+
return outPath;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Synthesize speech via the self-hosted Piper TTS binary baked into the
|
|
257
|
+
* image. Unlike a network-based TTS provider, Piper is a local
|
|
258
|
+
* binary that reads text from stdin and writes audio directly to
|
|
259
|
+
* `--output_file`. It never requires the network and produces WAV — not
|
|
260
|
+
* MP3 — output, since ffmpeg stays absent from the image (no transcode
|
|
261
|
+
* step; Piper's native WAV output is used directly).
|
|
262
|
+
*
|
|
263
|
+
* The voice is resolved via validatePiperVoice() (falls back to
|
|
264
|
+
* DEFAULT_PIPER_VOICE when voiceConfig.piperVoice is unset or not
|
|
265
|
+
* discovered) and resolvePiperVoicePaths() (only the .onnx model path is
|
|
266
|
+
* needed for --model — Piper auto-discovers the sibling .onnx.json).
|
|
267
|
+
*
|
|
268
|
+
* Never throws — resolves null on every failure path (spawn error, non-zero
|
|
269
|
+
* exit), mirroring synthesizeElevenLabs's never-throw contract.
|
|
270
|
+
*/
|
|
271
|
+
function synthesizePiper(
|
|
272
|
+
text: string,
|
|
273
|
+
voiceConfig: VoiceConfig = {},
|
|
274
|
+
spawnFn: SpawnFn = defaultSpawn,
|
|
275
|
+
): Promise<string | null> {
|
|
276
|
+
const outPath = join(VOICE_DIR, `${Date.now()}-response.wav`);
|
|
277
|
+
|
|
278
|
+
const voiceName = validatePiperVoice(voiceConfig.piperVoice);
|
|
279
|
+
const { onnxPath } = resolvePiperVoicePaths(voiceName);
|
|
280
|
+
|
|
281
|
+
return new Promise((resolve) => {
|
|
282
|
+
let proc: ReturnType<typeof spawn>;
|
|
283
|
+
try {
|
|
284
|
+
proc = spawnFn("piper", ["--model", onnxPath, "--output_file", outPath]);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
console.error("[voice] piper spawn error:", err);
|
|
287
|
+
resolve(null);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const stderrChunks: Buffer[] = [];
|
|
292
|
+
proc.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
|
|
293
|
+
|
|
294
|
+
let errorFired = false;
|
|
295
|
+
proc.on("error", (err) => {
|
|
296
|
+
errorFired = true;
|
|
297
|
+
console.error("[voice] piper spawn error:", err);
|
|
298
|
+
resolve(null);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
proc.on("close", (code) => {
|
|
302
|
+
if (errorFired) return;
|
|
303
|
+
if (code !== 0) {
|
|
304
|
+
const stderr = Buffer.concat(stderrChunks).toString();
|
|
305
|
+
console.error("[voice] piper failed with exit code", code, stderr);
|
|
306
|
+
resolve(null);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
resolve(outPath);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
proc.stdin?.write(text);
|
|
313
|
+
proc.stdin?.end();
|
|
314
|
+
});
|
|
315
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/voice.unit.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Documents and locks the contract between makeWhisperSvcClient and the
|
|
5
|
+
* self-hosted Whisper pod the chart ships (onerahmet/openai-whisper-asr-webservice).
|
|
6
|
+
*
|
|
7
|
+
* That image exposes a single ASR endpoint:
|
|
8
|
+
* POST /asr?encode=true&task=transcribe&output=txt
|
|
9
|
+
* with the audio attached as the multipart field `audio_file`, and (with
|
|
10
|
+
* output=txt) returns the transcription as a plain-text body — NOT JSON.
|
|
11
|
+
*
|
|
12
|
+
* These tests use an injected fetchFn (no global.fetch mutation) per the
|
|
13
|
+
* test-isolation contract.
|
|
14
|
+
*
|
|
15
|
+
* synthesizeSpeech dispatch coverage (ElevenLabs vs Piper, including the
|
|
16
|
+
* fs-touching Piper success/failure paths) lives in voice.integration.test.ts
|
|
17
|
+
* — this file is pure logic, no I/O of any kind, per docs/test-readiness/test-system.md.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
21
|
+
import { makeWhisperSvcClient } from "./voice.ts";
|
|
22
|
+
|
|
23
|
+
describe("makeWhisperSvcClient — onerahmet /asr contract", () => {
|
|
24
|
+
test("POSTs to the /asr endpoint of the service URL", async () => {
|
|
25
|
+
const fetchFn = mock(
|
|
26
|
+
async (_url: string, _opts: RequestInit) =>
|
|
27
|
+
new Response("hello world", { status: 200 }),
|
|
28
|
+
);
|
|
29
|
+
const client = makeWhisperSvcClient(
|
|
30
|
+
"http://whisper:9000",
|
|
31
|
+
fetchFn as unknown as typeof fetch,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
await client(Buffer.from("audio"), "clip.webm");
|
|
35
|
+
|
|
36
|
+
const [url] = fetchFn.mock.calls[0] as [string, RequestInit];
|
|
37
|
+
expect(url.startsWith("http://whisper:9000/asr")).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("includes the onerahmet query params (task=transcribe, output=txt, encode=true)", async () => {
|
|
41
|
+
const fetchFn = mock(
|
|
42
|
+
async (_url: string, _opts: RequestInit) =>
|
|
43
|
+
new Response("ok", { status: 200 }),
|
|
44
|
+
);
|
|
45
|
+
const client = makeWhisperSvcClient(
|
|
46
|
+
"http://whisper:9000",
|
|
47
|
+
fetchFn as unknown as typeof fetch,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
await client(Buffer.from("audio"), "clip.webm");
|
|
51
|
+
|
|
52
|
+
const [url] = fetchFn.mock.calls[0] as [string, RequestInit];
|
|
53
|
+
const qs = new URL(url).searchParams;
|
|
54
|
+
expect(qs.get("task")).toBe("transcribe");
|
|
55
|
+
expect(qs.get("output")).toBe("txt");
|
|
56
|
+
expect(qs.get("encode")).toBe("true");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("attaches the audio under the `audio_file` multipart field", async () => {
|
|
60
|
+
const bodies: FormData[] = [];
|
|
61
|
+
const fetchFn = mock(async (_url: string, opts: RequestInit) => {
|
|
62
|
+
bodies.push(opts.body as FormData);
|
|
63
|
+
return new Response("ok", { status: 200 });
|
|
64
|
+
});
|
|
65
|
+
const client = makeWhisperSvcClient(
|
|
66
|
+
"http://whisper:9000",
|
|
67
|
+
fetchFn as unknown as typeof fetch,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
await client(Buffer.from("audio"), "/tmp/recording.webm");
|
|
71
|
+
|
|
72
|
+
const form = bodies[0];
|
|
73
|
+
expect(form).toBeInstanceOf(FormData);
|
|
74
|
+
const file = form.get("audio_file") as File | null;
|
|
75
|
+
expect(file).not.toBeNull();
|
|
76
|
+
expect(file?.name).toBe("recording.webm");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("parses the plain-text (output=txt) body as the transcription", async () => {
|
|
80
|
+
const fetchFn = mock(
|
|
81
|
+
async (_url: string, _opts: RequestInit) =>
|
|
82
|
+
new Response(" transcribed words ", { status: 200 }),
|
|
83
|
+
);
|
|
84
|
+
const client = makeWhisperSvcClient(
|
|
85
|
+
"http://whisper:9000",
|
|
86
|
+
fetchFn as unknown as typeof fetch,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const result = await client(Buffer.from("audio"), "clip.webm");
|
|
90
|
+
expect(result).toBe("transcribed words");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("returns null on a non-2xx ASR response", async () => {
|
|
94
|
+
const fetchFn = mock(async () => new Response("boom", { status: 500 }));
|
|
95
|
+
const client = makeWhisperSvcClient(
|
|
96
|
+
"http://whisper:9000",
|
|
97
|
+
fetchFn as unknown as typeof fetch,
|
|
98
|
+
);
|
|
99
|
+
const warn = console.warn;
|
|
100
|
+
console.warn = () => {};
|
|
101
|
+
const result = await client(Buffer.from("audio"), "clip.webm");
|
|
102
|
+
console.warn = warn;
|
|
103
|
+
expect(result).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Bootstrap
|
|
2
|
+
|
|
3
|
+
_You're seeing this because this is a fresh workspace. Run the ritual, then delete this file._
|
|
4
|
+
|
|
5
|
+
## First-Run Ritual
|
|
6
|
+
|
|
7
|
+
1. **Introduce yourself** — open with: "Hey. I just came online. Who am I? Who are you?"
|
|
8
|
+
2. **Discover your identity** — learn your name, role, vibe, and emoji collaboratively
|
|
9
|
+
3. **Determine deployment type** — is this a solo deployment (one human) or a team?
|
|
10
|
+
4. **Update IDENTITY.md** — fill in the fields and add the correct import:
|
|
11
|
+
- Solo: add `@USER.md` at the bottom
|
|
12
|
+
- Team: add `@TEAM.md` at the bottom
|
|
13
|
+
5. **Create USER.md or TEAM.md** — fill it in with context about your human(s)
|
|
14
|
+
6. **Clone the repo** — `git clone <repo-url> repos/<repo>` so it's available for research
|
|
15
|
+
7. **Walk through the dev loop** — explain that `shipwright-dev-task`, `shipwright-review`, and
|
|
16
|
+
`shipwright-patch` are enabled by default, each running every minute via `/shipwright:dev-task`,
|
|
17
|
+
`/shipwright:review`, and `/shipwright:patch` respectively. But none of them self-discovers
|
|
18
|
+
work — they're explicit-target-only executors. Candidate selection happens once per tick in the
|
|
19
|
+
`shipwright-loop` cron: it merges candidates from all enabled phases and dispatches exactly one
|
|
20
|
+
winning item via strict age-based FIFO, with the target task id or `org/repo#number` embedded
|
|
21
|
+
directly in the prompt. A standalone phase cron with `shipwright-loop` disabled is silently inert
|
|
22
|
+
— its prompt carries no target, so every tick just responds `[silent]`.
|
|
23
|
+
Note: `shipwright-deploy` is another phase cron on the same every-minute schedule, but it's
|
|
24
|
+
opt-in — enable it via the accounts API when the agent is ready for autonomous deployment.
|
|
25
|
+
8. **Delete this file** — once bootstrap is complete, remove BOOTSTRAP.md
|
|
26
|
+
|
|
27
|
+
## What You're Building
|
|
28
|
+
|
|
29
|
+
After bootstrap, the load chain looks like:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
CLAUDE.md
|
|
33
|
+
@SOUL.md ← personality/tone — yours to evolve
|
|
34
|
+
@IDENTITY.md ← who you are + who you work with
|
|
35
|
+
@USER.md ← solo: your human's context
|
|
36
|
+
(or @TEAM.md) ← team: the whole crew
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
SOUL.md is yours — it won't be overwritten on upgrades. Make it accurate.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
_Delete this file when the ritual is complete._
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOOTSTRAP.md.template content regression guard — ABF-1.1
|
|
3
|
+
*
|
|
4
|
+
* Verifies the first-run bootstrap template's cron-walkthrough step describes
|
|
5
|
+
* the shipwright-loop-driven, explicit-target-only pipeline model (mirrors
|
|
6
|
+
* the former CLAUDE template content test / DPF-1.1), not the stale "every 30 min,
|
|
7
|
+
* self-discovering pickup of approved todos" model it replaced.
|
|
8
|
+
*
|
|
9
|
+
* Content-assertion only: readFileSync, no I/O beyond local file reads.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
// The template sits beside this test, so this works in any repo layout.
|
|
16
|
+
const TEMPLATE_PATH = join(import.meta.dir, "BOOTSTRAP.md.template");
|
|
17
|
+
|
|
18
|
+
function readTemplate(): string {
|
|
19
|
+
return readFileSync(TEMPLATE_PATH, "utf8");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("BOOTSTRAP.md.template — shipwright-loop model", () => {
|
|
23
|
+
it("mentions the shipwright-loop cron as the driver", () => {
|
|
24
|
+
expect(readTemplate()).toContain("shipwright-loop");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("describes dev-task/review/patch as explicit-target-only executors", () => {
|
|
28
|
+
expect(readTemplate().toLowerCase()).toContain("explicit-target-only");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("mentions strict age-based FIFO work selection", () => {
|
|
32
|
+
expect(readTemplate().toLowerCase()).toContain("fifo");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("notes a standalone phase cron with shipwright-loop disabled is silently inert", () => {
|
|
36
|
+
expect(readTemplate().toLowerCase()).toContain("silently inert");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does not claim the phase crons run every 30 minutes", () => {
|
|
40
|
+
expect(readTemplate()).not.toContain("30 min");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does not claim approved todos are picked up automatically without a driving loop", () => {
|
|
44
|
+
expect(readTemplate()).not.toContain(
|
|
45
|
+
"Once a task is approved, it gets picked up automatically.",
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|