diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 822146b..322ec30 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -1300,7 +1300,11 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn if (isNonPublicRepoIdentitySpeech(rawContent)) { throw new Error(`Repo identity ${identity.id} SAY content is not public speech.`); } - const content = normalizePublicRepoIdentitySpeech(rawContent); + const content = normalizePublicRepoIdentitySpeech(rawContent, { + identityId: identity.id, + displayName: identity.displayName, + repoName: identity.repoName, + }); if (isNonPublicRepoIdentitySpeech(content)) { throw new Error(`Repo identity ${identity.id} SAY content is not public speech.`); } diff --git a/apps/worker/src/repo-face-speech.ts b/apps/worker/src/repo-face-speech.ts index c4c3adc..ab9f07f 100644 --- a/apps/worker/src/repo-face-speech.ts +++ b/apps/worker/src/repo-face-speech.ts @@ -7,6 +7,12 @@ export interface RepoIdentityPostIntent { replyToMessageId?: string; } +export interface PublicRepoIdentitySpeechNormalizationOptions { + identityId?: string; + displayName?: string; + repoName?: string; +} + type RepoFaceActionBlock = { kind: "say" | "state_note" | "article" | "reddit_thread" | "bifrost_topic" | "update_request"; fields: Record; @@ -91,11 +97,17 @@ export function isNonPublicRepoIdentitySpeech(value: string): boolean { ].includes(normalized); } -export function normalizePublicRepoIdentitySpeech(value: string): string { - return value +export function normalizePublicRepoIdentitySpeech( + value: string, + options: PublicRepoIdentitySpeechNormalizationOptions = {}, +): string { + return stripLeadingSelfLabel( + value .replace(/```[A-Za-z0-9_-]*\s*\r?\n([\s\S]*?)\r?\n```/g, (_match, body: string) => body.trim()) .replace(/`([^`\r\n]+)`/g, (_match, body: string) => `"${body.trim()}"`) - .trim(); + .trim(), + options, + ); } function stripSingleMarkdownFence(value: string): string { @@ -204,3 +216,55 @@ function optionalDslString(value: string | undefined): string | undefined { function requiredDslString(value: string | undefined): string | undefined { return optionalDslString(value); } + +function stripLeadingSelfLabel( + value: string, + options: PublicRepoIdentitySpeechNormalizationOptions, +): string { + const aliases = [ + options.displayName, + options.identityId, + options.repoName, + ] + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => normalizeSpeakerKey(entry)); + + if (aliases.length === 0) { + return value; + } + + const match = value.match(/^([^\r\n::-]{1,80})(\s*[::]\s*|\s+-\s+)([\s\S]*)$/); + if (!match) { + return value; + } + + const candidate = normalizeSpeakerKey( + match[1].replace(/^["'“”‘’`]+|["'“”‘’`]+$/g, "").trim(), + ); + if (!candidate || !aliases.includes(candidate)) { + return value; + } + + return match[3].trimStart(); +} + +function normalizeSpeakerKey(value: string): string { + return foldKana( + value + .normalize("NFKC") + .toLowerCase() + .replace(/\s+/g, "") + .replace(/["'“”‘’`・.]+/g, ""), + ); +} + +function foldKana(value: string): string { + let output = ""; + for (const char of value) { + const code = char.charCodeAt(0); + output += code >= 0x30a1 && code <= 0x30f6 + ? String.fromCharCode(code - 0x60) + : char; + } + return output; +} diff --git a/scripts/smoke-repo-face-speech-boundary.ts b/scripts/smoke-repo-face-speech-boundary.ts index 8f41b05..c22ca55 100644 --- a/scripts/smoke-repo-face-speech-boundary.ts +++ b/scripts/smoke-repo-face-speech-boundary.ts @@ -72,6 +72,18 @@ const cases: Case[] = [ "show \"rear pair drifting 38 ms\", \"geometry confidence low\", \"world model provisional\".", detail: "Repo Faces can mention technical labels without speaking in code spans.", }, + { + name: "leading self-label is stripped with kana-folding", + run: () => normalizePublicRepoIdentitySpeech( + "メタめ: We need one real owner for the public name.", + { + identityId: "metame", + displayName: "メタメ", + repoName: "メタメ", + }, + ) === "We need one real owner for the public name.", + detail: "The public speech boundary owns the speaker label, so mixed-script self-renames in SAY content must be stripped before posting.", + }, ]; const failures = cases.filter((entry) => !entry.run());