web+ios: clean conversational name replies in the setup/welcome flows (#594)
"What should folks here call you?" invites conversational answers ā and the
"I'm {name}" suggestion chip literally primes them ā but the raw reply became
the screen name verbatim (a real member spent days as "I'm Dermot"). Both
flow engines now support input.clean: "name": strip one leading greeting
("hi,"), one leading preamble ("I'm", "my name is", "call me", ā¦, smart
apostrophes included), and trailing punctuation, keeping the original if
stripping would leave nothing. Cleaning runs at answer capture, before the
32-char cap, so the effect, the record, AND the "Lovely to meet you, {name}!"
echo all use the cleaned name.
Twin helpers (cleanNameAnswer in flow.js ā FlowEngine.swift) with mirrored
test cases; the name steps in all four flow documents (setup + welcome, JS +
JSON) opt in.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+159
−7
changelog/2026-07-10-conversational-name-replies.md+3 −0ios/LChatCore/Sources/LChatCore/FlowEngine.swift+45 −0ios/LChatCore/Sources/LChatCore/Flows/setup.json+2 −1ios/LChatCore/Sources/LChatCore/Flows/welcome.json+2 −1ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift+25 −1test/flow.test.mjs+36 −1web/static/apps/flow.js+44 −1web/static/apps/setup.flow.js+1 −1web/static/apps/welcome.flow.js+1 −1
diff --git a/changelog/2026-07-10-conversational-name-replies.md b/changelog/2026-07-10-conversational-name-replies.md| new file mode 100644 |
| index 0000000..83278c9 |
| --- /dev/null |
| +++ b/changelog/2026-07-10-conversational-name-replies.md |
| @@ -0,0 +1,3 @@ |
| +š¤ When Eleven asks what folks should call you, answering the way people |
| +naturally do ā "I'm Dermot", "hi, my name is Ana." ā now saves just the name. |
| +Before, the whole reply became your name, word for word. |
diff --git a/ios/LChatCore/Sources/LChatCore/FlowEngine.swift b/ios/LChatCore/Sources/LChatCore/FlowEngine.swift| index f18b6ef..ae40ca2 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/FlowEngine.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/FlowEngine.swift |
| @@ -135,6 +135,48 @@ public final class FlowEnv { |
| } |
| } |
| +// MARK: - the name-step answer cleaner (input.clean == "name") |
| + |
| +// People answer a conversational "what should folks here call you?" |
| +// conversationally ā "I'm Dermot", "hi, my name is Ana." ā and the raw reply |
| +// used to become their screen name verbatim (a real member spent days as |
| +// "I'm Dermot"). Strip one leading greeting, one leading preamble, and any |
| +// trailing punctuation; if stripping eats everything, keep the trimmed |
| +// original ā never clean a name to nothing. Prefix lists are matched on a |
| +// lowercased, straight-apostrophe copy (iOS keyboards type ā), longest |
| +// preamble first so "i'm called" wins over "i'm". |
| +// Twin: cleanNameAnswer in web/static/apps/flow.js. |
| +private let nameGreetings = ["hiya", "howdy", "hello", "hey", "hi"] |
| +private let namePreambles = [ |
| + "you can call me", "everyone calls me", "people call me", "they call me", |
| + "i'm called", "i am called", "my name is", "my name's", "the name's", |
| + "name is", "name's", "call me", "i am", "i'm", "im", "it's", "its", |
| +] |
| + |
| +func cleanNameAnswer(_ raw: String) -> String { |
| + let original = raw.trimmingCharacters(in: .whitespacesAndNewlines) |
| + var s = original |
| + func lower() -> String { s.lowercased().replacingOccurrences(of: "\u{2019}", with: "'") } |
| + for g in nameGreetings { |
| + guard lower().hasPrefix(g) else { continue } |
| + // The greeting must end at punctuation-or-space ("hi," yes, "hilary" no). |
| + var i = s.index(s.startIndex, offsetBy: g.count) |
| + if i < s.endIndex, ",.!".contains(s[i]) { i = s.index(after: i) } |
| + if i < s.endIndex, s[i] == " " { |
| + s = String(s[s.index(after: i)...]).trimmingCharacters(in: .whitespaces) |
| + } |
| + break |
| + } |
| + let l = lower() |
| + for p in namePreambles where l.hasPrefix(p + " ") { |
| + s = String(s.dropFirst(p.count + 1)).trimmingCharacters(in: .whitespaces) |
| + break |
| + } |
| + while let last = s.last, ".!,;:".contains(last) { s.removeLast() } |
| + s = s.trimmingCharacters(in: .whitespaces) |
| + return s.isEmpty ? original : s |
| +} |
| + |
| // MARK: - the io surface |
| /// One tappable chip on a bubble. `spentLabel` is its past-tense form once |
| @@ -448,6 +490,9 @@ public final class FlowEngine { |
| go { |
| Task { @MainActor in |
| var text = answer.text.trimmingCharacters(in: .whitespacesAndNewlines) |
| + // Clean BEFORE the cap so a chatty preamble can't eat |
| + // into it ("my name is " + 25 chars keeps the name). |
| + if input["clean"].stringValue == "name" { text = cleanNameAnswer(text) } |
| if case .number(let maxLen) = input["maxLen"] { text = String(text.prefix(Int(maxLen))) } |
| self.env.vars["answer"] = .string(text) |
| if input["required"].truthy && text.isEmpty { |
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json| index a8bb17d..43a98f6 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| @@ -41,7 +41,8 @@ |
| "input": { |
| "kind": "text", |
| "required": true, |
| - "maxLen": 32 |
| + "maxLen": 32, |
| + "clean": "name" |
| }, |
| "effect": { |
| "verb": "setMyName", |
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json| index a00381d..aa33c55 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json |
| @@ -187,7 +187,8 @@ |
| "input": { |
| "kind": "text", |
| "required": true, |
| - "maxLen": 32 |
| + "maxLen": 32, |
| + "clean": "name" |
| }, |
| "effect": { |
| "verb": "setMyName", |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift| index c8cecd7..6938442 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift |
| @@ -153,6 +153,29 @@ final class FlowEngineTests: XCTestCase { |
| // MARK: the native joiner welcome run |
| + // Twin of the cleanNameAnswer cases in test/flow.test.mjs ā both engines |
| + // must clean a conversational name reply identically. |
| + func testCleanNameAnswer() { |
| + XCTAssertEqual(cleanNameAnswer("I'm Dermot"), "Dermot") |
| + XCTAssertEqual(cleanNameAnswer("I\u{2019}m Dermot"), "Dermot") // iOS smart apostrophe |
| + XCTAssertEqual(cleanNameAnswer("i am dermot"), "dermot") // case kept as typed |
| + XCTAssertEqual(cleanNameAnswer("My name is Ana."), "Ana") |
| + XCTAssertEqual(cleanNameAnswer("Hi, I'm Dermot!"), "Dermot") |
| + XCTAssertEqual(cleanNameAnswer("hello my name's Bo"), "Bo") |
| + XCTAssertEqual(cleanNameAnswer("you can call me Dermo"), "Dermo") |
| + XCTAssertEqual(cleanNameAnswer("It's Maud"), "Maud") |
| + XCTAssertEqual(cleanNameAnswer("Im Dermot"), "Dermot") // apostrophe skipped |
| + |
| + XCTAssertEqual(cleanNameAnswer("Dermot"), "Dermot") |
| + XCTAssertEqual(cleanNameAnswer(" Dermot "), "Dermot") |
| + XCTAssertEqual(cleanNameAnswer("Hilary"), "Hilary") // "hi" needs a boundary |
| + XCTAssertEqual(cleanNameAnswer("Ima Jean"), "Ima Jean") // "im" needs a space |
| + XCTAssertEqual(cleanNameAnswer("Mary-Kate O'Brien"), "Mary-Kate O'Brien") |
| + // Stripping must never clean a name to nothing ā keep the original. |
| + XCTAssertEqual(cleanNameAnswer("I'm"), "I'm") |
| + XCTAssertEqual(cleanNameAnswer(""), "") |
| + } |
| + |
| func testNativeWelcomeRun() async throws { |
| let flow = try moduleFlow("welcome") |
| let io = Harness() |
| @@ -172,7 +195,8 @@ final class FlowEngineTests: XCTestCase { |
| XCTAssertEqual(access?.value, "native") |
| XCTAssertTrue(io.transcript.contains { $0.extra.key == "q-name" }) |
| - await io.answer("Paul") |
| + // A conversational reply is cleaned to the bare name (input.clean). |
| + await io.answer("Hi, I'm Paul!") |
| XCTAssertTrue(io.verbLog.contains { $0.0 == "setMyName" && $0.1.first == .string("Paul") }) |
| // The photo step auto-skips under native ā finish follows directly. |
| XCTAssertFalse(io.transcript.contains { $0.extra.key == "q-photo" }) |
diff --git a/test/flow.test.mjs b/test/flow.test.mjs| index 0ae2416..ace9e0f 100644 |
| --- a/test/flow.test.mjs |
| +++ b/test/flow.test.mjs |
| @@ -5,7 +5,7 @@ |
| // fixture discipline the Swift interpreter will inherit. |
| import assert from "node:assert/strict"; |
| import { test } from "node:test"; |
| -import { runFlow, renderText, evalCond } from "../web/static/apps/flow.js"; |
| +import { runFlow, renderText, evalCond, cleanNameAnswer } from "../web/static/apps/flow.js"; |
| import SETUP_FLOW from "../web/static/apps/setup.flow.js"; |
| import WELCOME_FLOW from "../web/static/apps/welcome.flow.js"; |
| @@ -84,6 +84,41 @@ function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, my |
| return h; |
| } |
| +await test("cleanNameAnswer strips conversational replies down to the name", () => { |
| + // The exact live failure: the "I'm {name}" chip primes typed replies too. |
| + assert.equal(cleanNameAnswer("I'm Dermot"), "Dermot"); |
| + assert.equal(cleanNameAnswer("Iām Dermot"), "Dermot"); // iOS smart apostrophe |
| + assert.equal(cleanNameAnswer("i am dermot"), "dermot"); // case kept as typed |
| + assert.equal(cleanNameAnswer("My name is Ana."), "Ana"); |
| + assert.equal(cleanNameAnswer("Hi, I'm Dermot!"), "Dermot"); |
| + assert.equal(cleanNameAnswer("hello my name's Bo"), "Bo"); |
| + assert.equal(cleanNameAnswer("you can call me Dermo"), "Dermo"); |
| + assert.equal(cleanNameAnswer("It's Maud"), "Maud"); |
| + assert.equal(cleanNameAnswer("Im Dermot"), "Dermot"); // apostrophe skipped |
| +}); |
| + |
| +await test("cleanNameAnswer leaves plain names (and lookalikes) alone", () => { |
| + assert.equal(cleanNameAnswer("Dermot"), "Dermot"); |
| + assert.equal(cleanNameAnswer(" Dermot "), "Dermot"); |
| + assert.equal(cleanNameAnswer("Hilary"), "Hilary"); // "hi" needs a boundary |
| + assert.equal(cleanNameAnswer("Ima Jean"), "Ima Jean"); // "im" needs a space |
| + assert.equal(cleanNameAnswer("Mary-Kate O'Brien"), "Mary-Kate O'Brien"); |
| + // Stripping must never clean a name to nothing ā keep the original. |
| + assert.equal(cleanNameAnswer("I'm"), "I'm"); |
| + assert.equal(cleanNameAnswer(""), ""); |
| +}); |
| + |
| +await test("the name step cleans the typed answer ā verb, record, and echo all use it", async () => { |
| + const h = harness(); |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + await h.answer("Hi, I'm Dermot!"); |
| + assert.deepEqual(h.verbLog[0], ["setMyName", "Dermot"], "the screen name is the cleaned name"); |
| + assert.deepEqual(h.records[0], { step: "name", label: "noted: you're Dermot", value: "Dermot" }); |
| + const group = h.transcript.find((t) => /Lovely to meet you/.test(t.text)); |
| + assert.match(group.text, /^Lovely to meet you, Dermot! š¤/, "the echo shows the cleaned name"); |
| +}); |
| + |
| await test("flows are pure JSON ā shippable to native interpreters", () => { |
| for (const FLOW of [SETUP_FLOW, WELCOME_FLOW]) { |
| assert.deepStrictEqual(JSON.parse(JSON.stringify(FLOW)), FLOW); |
diff --git a/web/static/apps/flow.js b/web/static/apps/flow.js| index a4b64ff..1be33f8 100644 |
| --- a/web/static/apps/flow.js |
| +++ b/web/static/apps/flow.js |
| @@ -17,6 +17,45 @@ |
| // delay(ms, fn) ā a cancellable timer (probes); tests fire it |
| // } |
| +// ---- the name-step answer cleaner (input.clean === "name") --------------- |
| +// People answer a conversational "what should folks here call you?" |
| +// conversationally ā "I'm Dermot", "hi, my name is Ana." ā and the raw reply |
| +// used to become their screen name verbatim (a real member spent days as |
| +// "I'm Dermot"). Strip one leading greeting, one leading preamble, and any |
| +// trailing punctuation; if stripping eats everything, keep the trimmed |
| +// original ā never clean a name to nothing. Prefix lists are matched on a |
| +// lowercased, straight-apostrophe copy (iOS keyboards type ā), longest |
| +// preamble first so "i'm called" wins over "i'm". |
| +// Twin: FlowEngine.cleanNameAnswer (native runs the same flows). |
| +const NAME_GREETINGS = ["hiya", "howdy", "hello", "hey", "hi"]; |
| +const NAME_PREAMBLES = [ |
| + "you can call me", "everyone calls me", "people call me", "they call me", |
| + "i'm called", "i am called", "my name is", "my name's", "the name's", |
| + "name is", "name's", "call me", "i am", "i'm", "im", "it's", "its", |
| +]; |
| + |
| +export function cleanNameAnswer(raw) { |
| + const original = (raw || "").trim(); |
| + let s = original; |
| + const lower = () => s.toLowerCase().replaceAll("ā", "'"); |
| + for (const g of NAME_GREETINGS) { |
| + if (!lower().startsWith(g)) continue; |
| + let i = g.length; // greeting must end at punctuation-or-space ("hi," yes, "hilary" no) |
| + if (i < s.length && ",.!".includes(s[i])) i++; |
| + if (i < s.length && s[i] === " ") s = s.slice(i + 1).trim(); |
| + break; |
| + } |
| + const l = lower(); |
| + for (const p of NAME_PREAMBLES) { |
| + if (!l.startsWith(p + " ")) continue; |
| + s = s.slice(p.length + 1).trim(); |
| + break; |
| + } |
| + while (s && ".!,;:".includes(s[s.length - 1])) s = s.slice(0, -1); |
| + s = s.trim(); |
| + return s || original; |
| +} |
| + |
| // ---- tiny expression + template language --------------------------------- |
| // Conditions are dotted paths into the env with an optional leading "!": |
| // "vars.photoAlt", "!vars.backedUp", "space.nameSet", "bridge.enrolled", |
| @@ -204,7 +243,11 @@ export function runFlow(flow, env, io) { |
| if (step.input.kind === "text") { |
| io.ask(env.threadId, (text) => |
| stepCtx.go(async () => { |
| - env.vars.answer = (text || "").trim().slice(0, step.input.maxLen || Infinity); |
| + // Clean BEFORE the cap so a chatty preamble can't eat into it |
| + // ("my name is " + 25 chars must keep the whole name). |
| + let answer = (text || "").trim(); |
| + if (step.input.clean === "name") answer = cleanNameAnswer(answer); |
| + env.vars.answer = answer.slice(0, step.input.maxLen || Infinity); |
| // A required answer left empty re-asks the whole step (say-dedupe by |
| // key means no duplicate bubbles ā the input just re-arms). |
| if (step.input.required && !env.vars.answer) return enter(step.id); |
diff --git a/web/static/apps/setup.flow.js b/web/static/apps/setup.flow.js| index 64aab5e..b1858d3 100644 |
| --- a/web/static/apps/setup.flow.js |
| +++ b/web/static/apps/setup.flow.js |
| @@ -31,7 +31,7 @@ export default { |
| bubbles: [ |
| { text: "First ā what should everyone here call you? Reply with your name.", hint: "Type your name" }, |
| ], |
| - input: { kind: "text", required: true, maxLen: 32 }, |
| + input: { kind: "text", required: true, maxLen: 32, clean: "name" }, |
| effect: { verb: "setMyName", args: ["$answer"] }, |
| on: { |
| ok: { |
diff --git a/web/static/apps/welcome.flow.js b/web/static/apps/welcome.flow.js| index cc499f9..0dafbf3 100644 |
| --- a/web/static/apps/welcome.flow.js |
| +++ b/web/static/apps/welcome.flow.js |
| @@ -134,7 +134,7 @@ export default { |
| goto: "photo", |
| }, |
| ], |
| - input: { kind: "text", required: true, maxLen: 32 }, |
| + input: { kind: "text", required: true, maxLen: 32, clean: "name" }, |
| effect: { verb: "setMyName", args: ["$answer"] }, |
| on: { |
| ok: { |