Requirement 001 phase 6, S0: - Speech: the security class shows as "Sécurité" and is spoken as in maritime radio, "say-cure-ee-tay" three times. A hex run of 7 or more characters with a letter and a digit (commit, session, message ids) is spelled in the NATO alphabet; every other digit is spoken on its own in ICAO pronunciation (42305: four two tree zero fife). The spelling runs inside clip() before the length cut, so a phrase still fits SPEECH_LIMIT; the displayed text keeps its digits and hashes. - English: every orchestrator label, tooltip, message, error, script output, document and test string is English (Regeln -> Firewall, Hierarchie -> Hierarchy, Steuerung -> Controls, Ausführen -> Run, ...); the page declares lang="en" and formats dates as en-GB. Task filter ids are English. German stays only in the skill trigger phrases that quote the operator, and as identifiers: the view keys (view=) and the leitstand id. - tests/test_english.py scans every tracked text file for umlauts and German words, with an allowlist of the quoted trigger phrases. BREAKING CHANGE: the Stream search tokens art: and seit: are now kind: and since:; an old art: or seit: token counts as a plain word. Evidence: make test 657 OK, ruff and markdownlint clean (/tmp/s0-make-test-1.log); e2e 17 OK, none skipped (/tmp/s0-e2e-1.log); MEDUSA 86 files, 24 findings on reworded strings accepted after an independent skeptic (21 path:line, 0 rejected, no behaviour change in signing, approving, rules, denying, offices, events), rescan 0 open. 🎯 Fix: 93 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 89 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
8.2 KiB
JavaScript
184 lines
8.2 KiB
JavaScript
const CHANNELS = [...TO_ONE, ...TO_ALL];
|
|
const CHANNEL_HINTS = {
|
|
post: "Post arrives at the end of the turn.",
|
|
call: "Call arrives at once, with a read receipt.",
|
|
newsletter: "Newsletter arrives as post to all, at the end of their turn.",
|
|
broadcast: "Broadcast reaches all at once.",
|
|
"pan-pan": "PAN-PAN: urgent, to all, at once.",
|
|
security: "Sécurité: safety notice to all stations, now.",
|
|
mayday: "MAYDAY: all agents pause writing steps until you cancel it.",
|
|
};
|
|
const VOICE_POLL = 700;
|
|
const composer = {voice: "", recorder: null, timer: 0, started: 0};
|
|
|
|
function confirmMayday() {
|
|
return window.confirm("Send a mayday to all? All agents pause writing steps until you cancel it.");
|
|
}
|
|
|
|
async function radioSend(message) {
|
|
const label = `${CLASSES[message.cls] || message.cls} to ${message.to === "*" ? "all" : message.to}`;
|
|
const fields = {to: message.to, text: message.text, upload: message.upload, class: message.cls, voice: message.voice};
|
|
const signed = await signedProof("/api/messages/sign", fields, label);
|
|
if (!signed) {
|
|
return false;
|
|
}
|
|
const {proof} = signed;
|
|
const result = message.to === "*"
|
|
? await sendJson("/api/board/broadcast", {text: fields.text, class: fields.class, voice: fields.voice, proof})
|
|
: await sendJson("/api/messages", {...fields, proof});
|
|
if (!result.ok || !result.data) {
|
|
toast(`${label} failed`, fail(result), false);
|
|
return false;
|
|
}
|
|
toast(`${label}: ${result.data.ok ? "sent" : "stored, sending failed"}`, [...(result.data.id ? [`kvb-orchestrator msg ${result.data.id}`] : []), ...runLines(result.data)], result.data.ok);
|
|
await load();
|
|
return true;
|
|
}
|
|
|
|
function attachVoice(parts, note) {
|
|
composer.voice = note;
|
|
parts.attach.checked = false;
|
|
parts.replay.hidden = !note;
|
|
parts.replay.replaceChildren(...(note ? [icon("bi-soundwave"), h("span", {class: "muted"}, "Voice message"), voiceReplay(note), h("label", {class: "check"}, parts.attach, "Attach recording"), iconBtn("bi-trash", "Remove recording", "btn small ghost", () => attachVoice(parts, ""))] : []));
|
|
}
|
|
|
|
async function heardNote(note) {
|
|
const until = Date.now() + (state.speech.seconds + 60) * 1000;
|
|
while (Date.now() < until) {
|
|
const response = await fetch(`/api/voice/${note}`, {cache: "no-store"});
|
|
const data = response.ok ? await response.json() : {status: "failed", error: `${response.status} ${(await response.text()).trim()}`};
|
|
if (data.status !== "pending") {
|
|
return data;
|
|
}
|
|
await new Promise((resolve) => window.setTimeout(resolve, VOICE_POLL));
|
|
}
|
|
return {status: "failed", error: "no transcript in the expected time"};
|
|
}
|
|
|
|
async function keepRecording(blob, parts) {
|
|
parts.record.disabled = true;
|
|
try {
|
|
const sound = context();
|
|
if (!sound || !blob.size) {
|
|
throw new Error("no recording");
|
|
}
|
|
parts.status.textContent = "Converting …";
|
|
const stored = await send("/api/voice", await wavOf(blob, sound), "audio/wav");
|
|
if (!stored.ok || !stored.data) {
|
|
throw new Error(`${stored.status} ${stored.text}`.trim());
|
|
}
|
|
attachVoice(parts, stored.data.id);
|
|
parts.status.textContent = "Transcribing …";
|
|
const note = await heardNote(stored.data.id);
|
|
if (note.status === "failed") {
|
|
parts.status.textContent = "No transcript: write the text yourself.";
|
|
toast("Transcript failed", [note.error], false);
|
|
return;
|
|
}
|
|
parts.text.value = [parts.text.value.trim(), note.text].filter(Boolean).join(" ");
|
|
parts.text.dispatchEvent(new Event("input", {bubbles: true}));
|
|
parts.text.focus();
|
|
parts.status.textContent = note.text ? "Check the text, then send." : "Nothing understood: write the text yourself.";
|
|
} catch (error) {
|
|
parts.status.textContent = "";
|
|
toast("Recording failed", [String(error.message || error)], false);
|
|
} finally {
|
|
parts.record.disabled = false;
|
|
}
|
|
}
|
|
|
|
function recordLabel(parts, on) {
|
|
parts.record.setAttribute("aria-pressed", String(on));
|
|
parts.record.replaceChildren(icon(on ? "bi-stop-fill" : "bi-record-circle"), on ? "Stop" : "Record");
|
|
}
|
|
|
|
function stopRecording() {
|
|
if (composer.recorder && composer.recorder.state === "recording") {
|
|
composer.recorder.stop();
|
|
}
|
|
}
|
|
|
|
async function startRecording(parts) {
|
|
let media = null;
|
|
try {
|
|
media = await navigator.mediaDevices.getUserMedia({audio: {channelCount: 1, echoCancellation: true, noiseSuppression: true}});
|
|
} catch (error) {
|
|
toast("Microphone unavailable", [String(error.message || error)], false);
|
|
return;
|
|
}
|
|
const recorder = new MediaRecorder(media);
|
|
const chunks = [];
|
|
recorder.addEventListener("dataavailable", (event) => {
|
|
if (event.data.size) {
|
|
chunks.push(event.data);
|
|
}
|
|
});
|
|
recorder.addEventListener("stop", () => {
|
|
media.getTracks().forEach((track) => track.stop());
|
|
window.clearInterval(composer.timer);
|
|
composer.recorder = null;
|
|
recordLabel(parts, false);
|
|
keepRecording(new Blob(chunks, {type: recorder.mimeType}), parts);
|
|
}, {once: true});
|
|
Object.assign(composer, {recorder, started: Date.now()});
|
|
recorder.start();
|
|
recordLabel(parts, true);
|
|
composer.timer = window.setInterval(() => {
|
|
const seconds = Math.floor((Date.now() - composer.started) / 1000);
|
|
parts.status.textContent = `Recording ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} of at most ${state.speech.seconds} s`;
|
|
if (seconds >= state.speech.seconds) {
|
|
stopRecording();
|
|
}
|
|
}, 250);
|
|
}
|
|
|
|
function composerForm() {
|
|
const channel = h("select", {class: "field"}, CHANNELS.map(([id, label]) => h("option", {value: id}, label)));
|
|
const recipient = h("select", {class: "field"});
|
|
const toField = h("label", {}, "To", recipient);
|
|
const text = h("textarea", {"aria-label": "Message", placeholder: "Message to the agents …", maxlength: 20000});
|
|
const replay = h("div", {class: "voice-note", hidden: true});
|
|
const hint = h("p", {class: "muted"});
|
|
const status = h("span", {class: "muted composer-status", "aria-live": "polite"});
|
|
const record = h("button", {type: "button", class: "btn small", "aria-pressed": "false", title: "Record: one click starts, another stops; the hub transcribes, you check the text before sending"});
|
|
const submit = h("button", {type: "submit", class: "btn primary"}, icon("bi-send"), "Send");
|
|
const attach = h("input", {type: "checkbox", title: "Attach recording: the agents get the path of the sound file; otherwise only the checked text goes out"});
|
|
const parts = {record, status, text, replay, attach};
|
|
recordLabel(parts, false);
|
|
const gap = micGap();
|
|
if (gap) {
|
|
record.disabled = true;
|
|
record.title = `Record: ${gap}`;
|
|
}
|
|
record.addEventListener("click", () => (composer.recorder ? stopRecording() : startRecording(parts)));
|
|
const sync = () => {
|
|
const direct = TO_ONE.some(([id]) => id === channel.value);
|
|
toField.hidden = !direct;
|
|
hint.textContent = `${CHANNEL_HINTS[channel.value]} Your passkey signs every message.`;
|
|
};
|
|
channel.addEventListener("change", sync);
|
|
sync();
|
|
const form = h("form", {class: "composer"}, h("div", {class: "composer-grid"}, h("label", {}, "Channel", channel), toField), text, replay, hint,
|
|
h("div", {class: "actions"}, record, status, h("span", {class: "spacer"}), submit));
|
|
form.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const direct = TO_ONE.some(([id]) => id === channel.value);
|
|
if (!text.value.trim()) {
|
|
toast("Nothing to send", ["Write a text or record one; the transcript lands in the field for checking."], false);
|
|
return;
|
|
}
|
|
if ((direct && !recipient.value) || (channel.value === "mayday" && !confirmMayday())) {
|
|
return;
|
|
}
|
|
submit.disabled = true;
|
|
if (await radioSend({to: direct ? recipient.value : "*", text: text.value, upload: "", cls: channel.value, voice: attach.checked ? composer.voice : ""})) {
|
|
text.value = "";
|
|
status.textContent = "";
|
|
attachVoice(parts, "");
|
|
}
|
|
submit.disabled = false;
|
|
});
|
|
attachVoice(parts, composer.voice);
|
|
return {form, recipient};
|
|
}
|