eleven / ocho Public

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>
Paul Campbell f2af1e7e636b9a1493fd98f5dca9f9015efa7bc9 parent 6fca403
9 files changed, +159 −7
  • changelog/2026-07-10-conversational-name-replies.md +3 −0
  • ios/LChatCore/Sources/LChatCore/FlowEngine.swift +45 −0
  • ios/LChatCore/Sources/LChatCore/Flows/setup.json +2 −1
  • ios/LChatCore/Sources/LChatCore/Flows/welcome.json +2 −1
  • ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift +25 −1
  • test/flow.test.mjs +36 −1
  • web/static/apps/flow.js +44 −1
  • web/static/apps/setup.flow.js +1 −1
  • web/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: {