Skip to content

Commit 1eafbf7

Browse files
committed
Add undo, back, and flip commands; harden click-mid-readout switching
Three new voice commands (REQ-ANS-017, REQ-NAV-009, REQ-NAV-010): - undo (also heard as "undue"): reverts the last answer the session entered — cells that were empty before are cleared, letters the entry overwrote are restored — then re-selects that clue and prompts "say the answer again, or say spell it". One level deep; with nothing to revert it says so. In spelling mode "undo" keeps meaning remove-a-letter. - back: previous clue in list order, wrapping from the first Across to the last Down. Filled entries are NOT skipped — back exists to revisit and fix what's already there. - flip: jumps to the crossing clue (the perpendicular entry at the current clue's first crossed cell) — across ⇄ down. Plumbing: new UNDO machine action executed via clearEntry + enterAnswer with the watcher paused, new UNDO_RESULT event, and lastEntry bookkeeping captured at ENTER time so undo restores the exact pre-entry pattern. Click-mid-readout fix-up (REQ-NAV-008): following a click is now scoped to moments that end in listening. While an accepted answer is being confirmed and written (between "Fits!" and the letters landing), a click no longer cancels the flow — previously it silently discarded the accepted word. An end-to-end orchestrator test now pins the full click-during-readout behavior: speech cut immediately, clicked clue read next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017zPpG6mrZdmsUVNUv1MZ9Z
1 parent edb6aaa commit 1eafbf7

11 files changed

Lines changed: 326 additions & 40 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,12 @@ DOM numbers (REQ-MODEL-001) — the page adapter stays dumb.
9696

9797
### Machine events (into `machine.reduce`)
9898
`START{snapshot}` · `TTS_DONE` · `HEARD{alternatives:[{transcript,confidence}]}` · `BARGE_IN` ·
99-
`STT_ERROR{code}` · `ENTRY_RESULT{ok,snapshot}` · `PAGE_EVENT{kind,snapshot}` · `TOGGLE_OFF`
99+
`STT_ERROR{code}` · `ENTRY_RESULT{ok,snapshot}` · `UNDO_RESULT{ok,snapshot}` ·
100+
`PAGE_EVENT{kind,snapshot}` · `TOGGLE_OFF`
100101

101102
### Machine actions (out of `machine.reduce`)
102-
`SAY{say:{kind,...}}` · `LISTEN` · `ENTER{clueId,word}` · `SELECT_CLUE{clueId}` · `END`
103+
`SAY{say:{kind,...}}` · `LISTEN` · `ENTER{clueId,word}` · `UNDO{clueId,cells}` ·
104+
`SELECT_CLUE{clueId}` · `END`
103105

104106
The machine is a **pure reducer**: same state + event → same actions, every time. That makes the
105107
entire dialog policy (the trickiest behavior in the product) unit-testable as data — every
@@ -119,6 +121,8 @@ dispatch(event):
119121
intent cancels the utterance and dispatches BARGE_IN — REQ-CMD-006)
120122
LISTEN → stt.listenOnce() → dispatch(HEARD | STT_ERROR)
121123
ENTER → pause watcher → pageClient.enterAnswer(...) → dispatch(ENTRY_RESULT)
124+
UNDO → pause watcher → clearEntry(blank-before cells) + enterAnswer(overwritten
125+
letters) → dispatch(UNDO_RESULT) — reverts the last entry (REQ-ANS-017)
122126
SELECT_CLUE → pageClient.selectClue(...)
123127
END → teardown (cancel tts/stt, stop watcher, disconnect port)
124128
```

docs/MANUAL-TESTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,12 @@ Covers: REQ-CMD-006
190190
2. Start again; mid-readout say something that is NOT a stop word (e.g. "hello there").
191191
3. **PASS:** step 1 — the readout halts promptly and you get the short sign-off, session over.
192192
Step 2 — the readout continues unaffected and your words are not treated as an answer.
193+
194+
### MT-26 — Undo, back, flip
195+
Covers: REQ-ANS-017 REQ-NAV-009 REQ-NAV-010
196+
1. Enter a fitting answer by voice; after the session moves on, say "undo".
197+
2. Say "back". Then say "flip".
198+
3. **PASS:** undo re-selects the answered clue, empties exactly the cells that answer filled
199+
(letters that were there beforehand come back), and prompts you to say it again or spell it;
200+
"back" moves to the previous clue in the list — including filled ones — and reads it; "flip"
201+
jumps to the crossing clue and reads it; the page highlight follows every move.

docs/REQUIREMENTS.md

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -399,12 +399,36 @@ entities. The readout must convey what the eye would see.
399399
- If the user clicks a different clue/cell on the page during the session, the conversation MUST
400400
follow it immediately: abandon whatever it was doing (an in-flight readout is cut short;
401401
spelling/disambiguation/confirmation modes reset) and read the newly selected clue. Selection
402-
changes caused by our own writing/navigation MUST NOT trigger this (no echo loops).
402+
changes caused by our own writing/navigation MUST NOT trigger this (no echo loops). One
403+
exception: while an accepted answer is being confirmed and written (between "Fits!" and the
404+
letters landing), the click is NOT followed — following would silently discard the answer.
403405
- **Accept:** Given a session on 1A — listening, mid-readout, or in a sub-mode — when the page
404406
selection changes to 3D (user click), then 3D is read; when selection events arrive for the clue
405407
we already track, nothing happens.
406408
- **Verify:** unit `tests/unit/machine.test.js`; manual MT-13.
407409

410+
#### REQ-NAV-009 — "back" goes to the previous clue
411+
- **Status:** Active · **Level:** MUST
412+
- *back* / *go back* / *previous* MUST move to the previous clue in list order — wrapping from the
413+
first Across to the last Down — read it, and sync the page highlight. Unlike *next*
414+
(REQ-NAV-003), filled entries are NOT skipped: going back exists to revisit and fix what is
415+
already there. The active strategy does not affect *back* (list order always — "previous" under
416+
most-filled has no stable meaning).
417+
- **Accept:** Given the current clue is 6 Across with 1 Across filled, when the user says "back",
418+
then 1 Across is selected and read; given the current clue is the first Across, then "back"
419+
lands on the last Down.
420+
- **Verify:** unit `tests/unit/machine.test.js`; manual MT-26.
421+
422+
#### REQ-NAV-010 — "flip" switches to the crossing clue
423+
- **Status:** Active · **Level:** MUST
424+
- *flip* MUST switch from the current clue to the perpendicular clue that crosses it (the crossing
425+
at the entry's first crossed cell, REQ-MODEL-002), read it, and sync the page highlight. Flipping
426+
again returns to a clue in the original direction. When the entry has no crossing at all, say so
427+
briefly and keep listening.
428+
- **Accept:** Given the current clue is 1 Across, when the user says "flip", then 1 Down (the
429+
crossing at its first cell) is selected and read.
430+
- **Verify:** unit `tests/unit/machine.test.js`; manual MT-26.
431+
408432
---
409433

410434
## 8. Answers: hearing, checking, entering (ANS)
@@ -594,6 +618,21 @@ This is the heart of the product. Speech recognition is *phonetic*; crossword an
594618
"yes" rewrites the entry.
595619
- **Verify:** unit `tests/unit/machine.test.js`.
596620

621+
#### REQ-ANS-017 — Undo the last entered answer
622+
- **Status:** Active · **Level:** MUST
623+
- *undo* (STT often hears it as "undue" — both MUST work) MUST revert the most recent answer the
624+
session entered: every cell of that entry returns to what it held just before the entry —
625+
previously empty cells are cleared, overwritten letters are restored. The conversation MUST move
626+
back to that clue (page highlight synced) and prompt the user to say the answer again or spell
627+
it. With no entry to revert (none made yet, or right after an undo), reply that there is nothing
628+
to undo. Undo history is one level deep — a second consecutive *undo* does not go further back.
629+
In spelling mode, *undo* keeps its spelling meaning (remove the last letter, REQ-ANS-011).
630+
- **Accept:** Given HEART was entered into an empty 1 Across and the session moved on, when the
631+
user says "undo", then the five cells are empty again, 1 Across is selected, and the prompt asks
632+
to say it again or spell it; a repeated "undo" reports nothing to undo. Given HEIST was entered
633+
over crossing letters via *anyway*, then "undo" restores those letters.
634+
- **Verify:** unit `tests/unit/machine.test.js`, `tests/unit/matching.test.js`; manual MT-26.
635+
597636
---
598637

599638
## 9. Hints (HINT)
@@ -630,6 +669,9 @@ This is the heart of the product. Speech recognition is *phonetic*; crossword an
630669
| Intent | Utterances |
631670
|---|---|
632671
| next | next · next clue · next one · pass · pass on this · skip · skip it · skip this one · move on |
672+
| back | back · go back · previous · previous clue · previous one |
673+
| flip | flip · flip it · switch direction · change direction · other direction |
674+
| undo | undo · undue · undo that · undo it · take that back · take it back |
633675
| repeat | repeat · repeat that · again · say again · say that again · read it again · what · come again |
634676
| hint | hint · hints · give me a hint · what do i have · what's there · what's filled in · read the letters · pattern |
635677
| help | help · what can i say · commands · options |
@@ -653,8 +695,8 @@ This is the heart of the product. Speech recognition is *phonetic*; crossword an
653695

654696
#### REQ-CMD-002 — Help
655697
- **Status:** Active · **Level:** MUST
656-
- *help* MUST speak a one-breath summary of the core commands (answer, pass/next, repeat, hint,
657-
spell, stop) and keep listening.
698+
- *help* MUST speak a one-breath summary of the core commands (answer, pass/next, back, flip,
699+
repeat, hint, spell, undo, stop) and keep listening.
658700
- **Accept:** Given "help", then the help utterance is spoken and the clue stays current.
659701
- **Verify:** unit `tests/unit/machine.test.js`; manual MT-16.
660702

extension/src/app/orchestrator.js

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import { parseCommand } from '../matching/commands.js';
1010
* @param {{speak(text):Promise, cancel():void}} deps.tts
1111
* @param {{listenOnce():Promise, stop():void}} deps.stt
1212
* @param {object} deps.pageClient
13-
* {snapshot, enterAnswer(cells), selectClue(clueId), watch(cb), unwatch, pauseWatch, resumeWatch}
13+
* {snapshot, enterAnswer(cells), clearEntry(cellIndices), selectClue(clueId),
14+
* watch(cb), unwatch, pauseWatch, resumeWatch}
1415
* @param {object} [deps.ui] {caption(role, text), listening(bool)}
1516
* @param {() => void} [deps.onEnd]
1617
* @param {() => number} [deps.now] clock, injectable for tests
@@ -96,6 +97,28 @@ export function createOrchestrator({ tts, stt, pageClient, ui = {}, onEnd = () =
9697
}
9798
break;
9899
}
100+
case 'UNDO': {
101+
// Revert the last entry (REQ-ANS-017): null letters mean "clear the cell",
102+
// real letters mean "restore what our entry overwrote".
103+
try {
104+
pageClient.pauseWatch?.();
105+
const clears = action.cells.filter((c) => !c.letter).map((c) => c.index);
106+
const restores = action.cells.filter((c) => c.letter);
107+
let ok = true;
108+
let snap = null;
109+
if (clears.length) ({ ok, snapshot: snap } = await pageClient.clearEntry(clears));
110+
if (restores.length) {
111+
const res = await pageClient.enterAnswer(restores);
112+
ok = ok && res.ok;
113+
snap = res.snapshot;
114+
}
115+
pageClient.resumeWatch?.();
116+
enqueue({ type: 'UNDO_RESULT', ok, snapshot: snap ?? await pageClient.snapshot() });
117+
} catch {
118+
pageLost();
119+
}
120+
break;
121+
}
99122
case 'SELECT_CLUE': {
100123
try {
101124
await pageClient.selectClue(action.clueId);
@@ -142,9 +165,12 @@ export function createOrchestrator({ tts, stt, pageClient, ui = {}, onEnd = () =
142165
if (event.kind === 'solved' || event.kind === 'selection') stt.stop();
143166
if (event.kind === 'selection') {
144167
// REQ-NAV-008: clicking another clue takes effect NOW — cut any readout short.
145-
// Our own SELECT_CLUE echoes back with the clue we already track, so it never cancels.
168+
// Our own SELECT_CLUE echoes back with the clue we already track, so it never
169+
// cancels; neither does a click the machine won't follow (entry in flight).
146170
const sel = event.snapshot?.selection?.clueId;
147-
if (sel && sel !== state.clueId) tts.cancel();
171+
const willFollow = state.phase === 'listening'
172+
|| (state.phase === 'speaking' && state.after === 'listen');
173+
if (sel && sel !== state.clueId && willFollow) tts.cancel();
148174
}
149175
}
150176
queue.push(event);

extension/src/conversation/machine.js

Lines changed: 73 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
// Conversation policy: pure reducer (state, event) → {state, actions}.
22
// No browser APIs, no English strings (see phrases.js), no DOM (see page-adapter).
33
//
4-
// Events in: START TTS_DONE HEARD BARGE_IN STT_ERROR ENTRY_RESULT PAGE_EVENT TOGGLE_OFF
5-
// Actions out: SAY LISTEN ENTER SELECT_CLUE END
4+
// Events in: START TTS_DONE HEARD BARGE_IN STT_ERROR ENTRY_RESULT UNDO_RESULT
5+
// PAGE_EVENT TOGGLE_OFF
6+
// Actions out: SAY LISTEN ENTER UNDO SELECT_CLUE END
67
//
78
// Half-duplex invariant (REQ-SPCH-005): LISTEN is never emitted in the same action
89
// batch as SAY; listening starts only after speech finishes (TTS_DONE → after:'listen').
910
// The shell's stop-only barge-in listener (REQ-CMD-006) surfaces here as BARGE_IN.
1011

1112
import { buildModel } from '../puzzle-model/model.js';
12-
import { nextClue } from './strategies.js';
13+
import { nextClue, prevClue } from './strategies.js';
1314
import { evaluate, collectSpelledLetters } from '../matching/evaluate.js';
1415
import { parseCommand } from '../matching/commands.js';
1516

@@ -48,22 +49,37 @@ function readClue(state, extra = {}, leadActions = []) {
4849
return speak(state, [...leadActions, say(clueSay(state.model, state.clueId, extra))], 'listen');
4950
}
5051

51-
function advance(state, leadSays = []) {
52-
const next = nextClue(state.model, state.clueId, state.strategy);
53-
if (!next) {
54-
// Nothing unfilled anywhere (REQ-LIFE-006 / REQ-NAV-003).
55-
return listenAgain(state, [...leadSays, { kind: 'grid-full-wrong' }]);
56-
}
57-
const s = {
52+
/** Land on a clue with a clean slate: sub-mode, buffers, and pending work all reset. */
53+
function moveTo(state, clueId) {
54+
return {
5855
...state,
59-
clueId: next.clueId,
56+
clueId,
6057
mode: 'normal',
6158
rejected: [],
6259
lastProposed: null,
6360
pendingWord: null,
61+
pendingEntry: null,
6462
spellBuffer: [],
6563
disambigWords: [],
6664
};
65+
}
66+
67+
/** Navigate to a clue, sync the page, and read it (back/flip — REQ-NAV-009/010). */
68+
function goTo(state, clueId) {
69+
const s = moveTo(state, clueId);
70+
return speak(s, [
71+
{ type: 'SELECT_CLUE', clueId },
72+
say(clueSay(s.model, clueId)),
73+
], 'listen');
74+
}
75+
76+
function advance(state, leadSays = []) {
77+
const next = nextClue(state.model, state.clueId, state.strategy);
78+
if (!next) {
79+
// Nothing unfilled anywhere (REQ-LIFE-006 / REQ-NAV-003).
80+
return listenAgain(state, [...leadSays, { kind: 'grid-full-wrong' }]);
81+
}
82+
const s = moveTo(state, next.clueId);
6783
return speak(s, [
6884
...leadSays.map(say),
6985
{ type: 'SELECT_CLUE', clueId: next.clueId },
@@ -131,6 +147,30 @@ function handleCommand(state, cmd) {
131147
switch (cmd.command) {
132148
case 'next':
133149
return advance(state);
150+
case 'back': // REQ-NAV-009: previous in list order, filled entries included
151+
return goTo(state, prevClue(state.model, state.clueId).clueId);
152+
case 'flip': { // REQ-NAV-010: jump to the crossing clue
153+
const clue = state.model.clue(state.clueId);
154+
let cross = null;
155+
for (let i = 0; i < clue.cellIndices.length && !cross; i++) {
156+
cross = state.model.crossingAt(state.clueId, i);
157+
}
158+
if (!cross) return listenAgain(state, [{ kind: 'no-crossing' }]);
159+
return goTo(state, cross.clueId);
160+
}
161+
case 'undo': { // REQ-ANS-017: revert the last entry we made
162+
if (!state.lastEntry) return listenAgain(state, [{ kind: 'nothing-to-undo' }]);
163+
const { clueId, before } = state.lastEntry;
164+
const cells = state.model.clue(clueId).cellIndices
165+
.map((index, i) => ({ index, letter: before[i] })); // letter null → clear the cell
166+
return {
167+
state: { ...moveTo(state, clueId), phase: 'undoing', lastEntry: null },
168+
actions: [
169+
{ type: 'SELECT_CLUE', clueId },
170+
{ type: 'UNDO', clueId, cells },
171+
],
172+
};
173+
}
134174
case 'repeat':
135175
return readClue({ ...state, mode: 'normal' }); // REQ-READ-009
136176
case 'hint': {
@@ -307,6 +347,7 @@ function onStart(state, { snapshot }) {
307347
disambigWords: [],
308348
pendingWord: null,
309349
pendingEntry: null,
350+
lastEntry: null,
310351
rejected: [],
311352
lastProposed: null,
312353
sttRetried: false,
@@ -325,7 +366,13 @@ function onTtsDone(state) {
325366
if (state.after === 'enter') {
326367
const word = state.pendingEntry.word;
327368
return {
328-
state: { ...state, phase: 'entering' },
369+
// Remember what the entry held BEFORE this write, so "undo" can revert it
370+
// exactly — clearing what we added, restoring what we overwrote (REQ-ANS-017).
371+
state: {
372+
...state,
373+
phase: 'entering',
374+
lastEntry: { clueId: state.clueId, before: state.model.patternFor(state.clueId) },
375+
},
329376
actions: [{
330377
type: 'ENTER',
331378
clueId: state.clueId,
@@ -380,33 +427,32 @@ function onPageEvent(state, { kind, snapshot }) {
380427
);
381428
}
382429
// REQ-NAV-008: a click reaches us while listening AND mid-readout (the shell cuts the
383-
// audio short); only 'entering' and the farewell (after:'end') are off-limits.
430+
// audio short). Off-limits: entering, the farewell, and the beat between an accepted
431+
// answer and its letters landing (after:'enter') — following there would silently
432+
// discard the answer.
384433
const interactive = state.phase === 'listening'
385-
|| (state.phase === 'speaking' && state.after !== 'end');
434+
|| (state.phase === 'speaking' && state.after === 'listen');
386435
if (!interactive) return { state, actions: [] };
387436
const model = buildModel(snapshot);
388437
if (kind === 'selection') {
389438
const sel = snapshot.selection?.clueId;
390439
if (sel && sel !== state.clueId && model.clue(sel)) {
391440
// The click wins over whatever was in progress: reset any sub-mode and pending work.
392-
return readClue({
393-
...state,
394-
model,
395-
clueId: sel,
396-
mode: 'normal',
397-
rejected: [],
398-
lastProposed: null,
399-
pendingWord: null,
400-
pendingEntry: null,
401-
spellBuffer: [],
402-
disambigWords: [],
403-
});
441+
return readClue({ ...moveTo(state, sel), model });
404442
}
405443
}
406444
// grid change (user typed manually) or same-clue selection: absorb the fresh state.
407445
return { state: { ...state, model }, actions: [] };
408446
}
409447

448+
// REQ-ANS-017: the page finished reverting our last entry.
449+
function onUndoResult(state, { ok, snapshot }) {
450+
if (state.phase !== 'undoing') return { state, actions: [] };
451+
const s = { ...state, model: buildModel(snapshot) };
452+
if (!ok) return listenAgain(s, [{ kind: 'entry-failed' }]);
453+
return listenAgain(s, [{ kind: 'undone' }]); // prompt: say it again, or spell it
454+
}
455+
410456
// REQ-CMD-006: "stop" heard by the barge-in listener while we were speaking.
411457
function onBargeIn(state) {
412458
if (state.phase !== 'speaking') return { state, actions: [] };
@@ -436,6 +482,7 @@ export function reduce(state, event) {
436482
case 'BARGE_IN': return onBargeIn(state);
437483
case 'STT_ERROR': return onSttError(state, event);
438484
case 'ENTRY_RESULT': return onEntryResult(state, event);
485+
case 'UNDO_RESULT': return onUndoResult(state, event);
439486
case 'PAGE_EVENT': return onPageEvent(state, event);
440487
case 'TOGGLE_OFF': // REQ-LIFE-002: instant, silent teardown
441488
return { state: { ...state, phase: 'done' }, actions: [{ type: 'END' }] };

extension/src/conversation/phrases.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export function render(say) {
130130
return `${letters}. ${summary}`;
131131
}
132132
case 'help':
133-
return 'You can: say an answer, or answer followed by a word. Say pass or next to skip, repeat to hear the clue again, hint for the letters so far, spell it to spell, anyway to enter over a clash, switch to most filled to change order, or goodbye to stop.';
133+
return 'You can: say an answer, or answer followed by a word. Say pass or next to skip, back for the previous clue, flip for the crossing clue, repeat to hear the clue again, hint for the letters so far, spell it to spell, undo to take back the last answer, anyway to enter over a clash, switch to most filled to change order, or goodbye to stop.';
134134
case 'didnt-catch':
135135
return "Sorry, I didn't catch that. Say an answer, or say help.";
136136
case 'misheard-reprompt':
@@ -141,6 +141,12 @@ export function render(say) {
141141
return say.letters.length ? `${say.letters.join(', ')}.` : 'Nothing yet.';
142142
case 'spell-cancelled':
143143
return 'Okay, back to normal answers.';
144+
case 'undone':
145+
return 'Undone — those letters are out. Say the answer again, or say spell it.';
146+
case 'nothing-to-undo':
147+
return "There's nothing to undo yet.";
148+
case 'no-crossing':
149+
return 'No crossing clue there.';
144150
case 'strategy-ack':
145151
return say.strategy === 'most-filled' ? 'Okay — most filled first.' : 'Okay — in list order.';
146152
case 'goodbye':

extension/src/conversation/strategies.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,16 @@ export function nextClue(model, fromId, strategy = 'list-order') {
3636
}
3737
return null;
3838
}
39+
40+
/**
41+
* Previous clue in list order, wrapping from the first Across to the last Down
42+
* (REQ-NAV-009). Unlike nextClue, filled entries are NOT skipped — "back" exists
43+
* to revisit and fix what is already there. Always list order, whatever the
44+
* active strategy: "previous" has no stable meaning under most-filled.
45+
* @returns {{clueId: string}}
46+
*/
47+
export function prevClue(model, fromId) {
48+
const order = model.orderedClueIds;
49+
const start = Math.max(order.indexOf(fromId), 0);
50+
return { clueId: order[(start - 1 + order.length) % order.length] };
51+
}

0 commit comments

Comments
 (0)