openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
181 lines (180 loc) • 8.72 kB
JavaScript
import { t as levenshteinDistance } from "./levenshtein-distance-C0TL9gUO.js";
//#region src/talk/activation-name.ts
/**
* Realtime voice activation-name matching for direct spoken address.
*
* The matcher accepts short names at the leading or trailing edge of a
* transcript, strips the name before agent routing, and keeps fuzzy matching
* conservative so ordinary dictation does not trigger Talk turns.
*/
const REALTIME_VOICE_ACTIVATION_NAME_MAX_WORDS = 2;
/** Count alphanumeric words in a configured activation name. */
function realtimeVoiceActivationNameWordCount(value) {
return Array.from(value.matchAll(/[a-z0-9]+/gi)).length;
}
/** Normalize configured activation names while preserving word boundaries. */
function normalizeRealtimeVoiceActivationName(value) {
return value.toLowerCase().replace(/\s+/g, " ").trim() || void 0;
}
/** Extract the supported leading activation-name prefix from a longer phrase. */
function normalizeRealtimeVoiceActivationNamePrefix(value, maxWords = 2) {
const words = Array.from(value.matchAll(/[a-z0-9]+/gi), (match) => match[0]);
if (words.length === 0) return;
return words.slice(0, maxWords).join(" ");
}
/** Validate the configured activation name length bound. */
function isSupportedRealtimeVoiceActivationName(value, maxWords = 2) {
const wordCount = realtimeVoiceActivationNameWordCount(value);
return wordCount >= 1 && wordCount <= maxWords;
}
/** Normalize and reject unsupported activation names in one reusable step. */
function normalizeSupportedRealtimeVoiceActivationName(value, maxWords = 2) {
if (typeof value !== "string") return;
const normalized = normalizeRealtimeVoiceActivationName(value);
return normalized && isSupportedRealtimeVoiceActivationName(normalized, maxWords) ? normalized : void 0;
}
/** Prefer longer names first so nested names match the most specific option. */
function sortRealtimeVoiceActivationNames(names) {
return names.toSorted((left, right) => right.length - left.length || left.localeCompare(right));
}
/** Match and strip a configured activation name from either transcript edge. */
function matchRealtimeVoiceActivationName(text, activationNames, maxWords = 2) {
const preparedActivationNames = [];
for (const activationName of activationNames) {
const normalizedActivationName = normalizeActivationNameCandidate(activationName);
if (!normalizedActivationName) continue;
preparedActivationNames.push({
activationName,
compact: compactActivationName(normalizedActivationName)
});
}
if (preparedActivationNames.length === 0) return;
const candidates = [...leadingActivationNameCandidates(text, maxWords), ...trailingActivationNameCandidates(text, maxWords)].map((candidate) => ({
candidate,
compact: compactActivationName(candidate.heardName)
})).toSorted((left, right) => right.compact.length - left.compact.length);
for (const { candidate, compact: heardCompact } of candidates) for (const { activationName, compact: activationCompact } of preparedActivationNames) {
const exactMatch = heardCompact === activationCompact;
const fuzzyMatch = isFuzzyActivationNameMatch(candidate, heardCompact, activationCompact);
if (exactMatch || fuzzyMatch) return {
allowed: true,
text: stripEdgeActivationNameCandidate(text, candidate),
activationName,
heardName: candidate.heardName,
match: exactMatch ? "exact" : "fuzzy",
edge: candidate.edge
};
}
}
function normalizeActivationNameCandidate(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim() || void 0;
}
function compactActivationName(value) {
return value.replace(/[^a-z0-9]+/g, "");
}
function leadingActivationNameCandidates(text, maxWords) {
const nameStart = /^\s*(?:(?:hey|ok|okay)(?:\s*[-,:;]+\s*|\s+))?/i.exec(text)?.[0].length ?? 0;
const candidates = [];
const candidateStarts = nameStart > 0 ? [0, nameStart] : [0];
for (const startIndex of candidateStarts) {
const tokenPattern = /[a-z0-9]+/gi;
tokenPattern.lastIndex = startIndex;
const startCandidates = [];
for (let wordCount = 0; wordCount < maxWords; wordCount += 1) {
const token = tokenPattern.exec(text);
if (!token) break;
const previousEndIndex = wordCount === 0 ? startIndex : startCandidates[wordCount - 1]?.endIndex;
const between = text.slice(previousEndIndex, token.index);
if (wordCount > 0 && !/^[\s'-]+$/.test(between)) break;
const endIndex = token.index + token[0].length;
const heardName = normalizeActivationNameCandidate(text.slice(startIndex, endIndex));
if (!heardName) break;
const boundary = text.slice(endIndex).match(/^\s*([,.:;!?-]|$)/);
startCandidates.push({
edge: "leading",
heardName,
startIndex,
endIndex,
strongBoundary: Boolean(boundary)
});
}
candidates.push(...startCandidates);
}
return candidates;
}
function trailingActivationNameCandidates(text, maxWords) {
const tokens = Array.from(text.matchAll(/[a-z0-9]+/gi));
const candidates = [];
const tokenCount = Math.min(tokens.length, maxWords);
for (let wordCount = 1; wordCount <= tokenCount; wordCount += 1) {
const startToken = tokens[tokens.length - wordCount];
const endToken = tokens[tokens.length - 1];
if (!startToken || !endToken?.[0]) break;
const startIndex = startToken.index ?? 0;
const endIndex = (endToken.index ?? 0) + endToken[0].length;
if (!/^\s*(?:[,.:;!?-]+\s*)?$/.test(text.slice(endIndex))) break;
if (!/(^|[\s,.:;!?-])$/.test(text.slice(0, startIndex))) break;
const directAddressBoundary = /(^|[,.:;!?-]\s*)$/.test(text.slice(0, startIndex));
const trailingQuestion = /\?\s*$/.test(text);
if (wordCount > 1) {
const previousToken = tokens[tokens.length - wordCount + 1];
const between = previousToken ? text.slice(startIndex + startToken[0].length, previousToken.index) : "";
if (!/^[\s'-]+$/.test(between)) break;
}
const heardName = normalizeActivationNameCandidate(text.slice(startIndex, endIndex));
if (!heardName) break;
candidates.push({
edge: "trailing",
heardName,
startIndex,
endIndex,
strongBoundary: directAddressBoundary && trailingQuestion
});
}
return candidates;
}
function hasOnlyPhoneticSubstitutions(left, right) {
if (left.length !== right.length) return false;
const vowels = /* @__PURE__ */ new Set([
"a",
"e",
"i",
"o",
"u",
"y"
]);
const liquids = /* @__PURE__ */ new Set(["l", "r"]);
let substitutions = 0;
for (let index = 0; index < left.length; index += 1) {
const leftChar = left[index];
const rightChar = right[index];
if (leftChar === rightChar) continue;
const vowelLike = vowels.has(leftChar ?? "") && vowels.has(rightChar ?? "");
const liquidLike = liquids.has(leftChar ?? "") && liquids.has(rightChar ?? "");
if (!vowelLike && !liquidLike) return false;
substitutions += 1;
}
return substitutions > 0;
}
function commonPrefixLength(left, right) {
const limit = Math.min(left.length, right.length);
for (let index = 0; index < limit; index += 1) if (left[index] !== right[index]) return index;
return limit;
}
function isFuzzyActivationNameMatch(candidate, heardCompact, activationCompact) {
if (!heardCompact || !activationCompact || activationCompact.length < 5) return false;
if (!candidate.strongBoundary) return false;
if (heardCompact[0] !== activationCompact[0]) return false;
const distance = levenshteinDistance(heardCompact, activationCompact);
if (candidate.edge === "trailing") return heardCompact.length === activationCompact.length && hasOnlyPhoneticSubstitutions(heardCompact, activationCompact);
if (distance <= 1) return true;
if (distance === 2 && heardCompact.length >= 4 && activationCompact.length >= 5 && (heardCompact.length !== activationCompact.length || hasOnlyPhoneticSubstitutions(heardCompact, activationCompact) || commonPrefixLength(heardCompact, activationCompact) >= 6)) return true;
if (distance === 3 && heardCompact.length >= 7 && activationCompact.length >= 7 && heardCompact.length !== activationCompact.length && commonPrefixLength(heardCompact, activationCompact) >= 5) return true;
return false;
}
function stripEdgeActivationNameCandidate(text, candidate) {
if (candidate.edge === "leading") return text.slice(candidate.endIndex).replace(/^\s*(?:[-,:;.!?]+\s*)?/, "").trim();
return text.slice(0, candidate.startIndex).replace(/\s*(?:[-,:;.!?]+\s*)?$/, "").trim();
}
//#endregion
export { normalizeRealtimeVoiceActivationNamePrefix as a, sortRealtimeVoiceActivationNames as c, normalizeRealtimeVoiceActivationName as i, isSupportedRealtimeVoiceActivationName as n, normalizeSupportedRealtimeVoiceActivationName as o, matchRealtimeVoiceActivationName as r, realtimeVoiceActivationNameWordCount as s, REALTIME_VOICE_ACTIVATION_NAME_MAX_WORDS as t };