Finish Phase 22: the half of Petal that works with the tunnel down

Grammar lite, the false-friend list, the daily invitation and the offline
miscollocations — the four remaining §5–§6 items, all client-side and all
alive on a box that cannot reach the model.

The offline collocations forced a schema change. `type` had been doubling
as the answer to "which engine found this" — `mechanics` meant offline —
and that stops being true the moment an offline rule proposes a
collocation. Migration 0013 adds `source` (llm | local) and every pass now
scopes its DELETE by engine; without it the coach silently wiped every
offline chunk on the page. Existing rows backfill by type, so a pre-0013
collocation row is claimed as the coach's, which it was: the offline list
did not exist yet.

The rule pack is hand-curated rather than mined, and the entries left out
are the point — `married with` is wrong until "married with children",
`arrive to` wants at or in depending on the noun. A pack running on every
keystroke must not correct correct writing.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 15:05:55 -07:00
parent e9b8595456
commit 1bbc8fc8d3
20 changed files with 1678 additions and 50 deletions
@@ -13,6 +13,11 @@ interface Props {
editTick: number
acceptTick: number
text: string
// The open document is still empty — the one state the daily writing
// invitation is offered in.
blankPage: boolean
// Called when she takes the invitation up, with the English prompt.
onAcceptInvitation: (prompt: string) => void
}
// Emoji placeholder per mood, used for any mood a companion has no Lottie for.
@@ -30,17 +35,38 @@ const STORAGE_KEY = 'petal.companion'
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
// break reminders. Clicking the mascot opens a picker to switch companions
// (the choice persists in localStorage).
export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Props) {
export function PetalCompanion({
wordCount,
saveStatus,
llmDown,
editTick,
acceptTick,
text,
blankPage,
onAcceptInvitation,
}: Props) {
const t = usePack()
const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
const {
mood,
bubble,
dismiss,
holdBubble,
releaseBubble,
acceptInvite,
declineInvite,
setInviteHandler,
} = useCompanion({
wordCount,
saveStatus,
llmDown,
editTick,
acceptTick,
text,
blankPage,
})
useEffect(() => setInviteHandler(onAcceptInvitation), [setInviteHandler, onAcceptInvitation])
const [companionId, setCompanionId] = useState<string>(
() => readPref(STORAGE_KEY) || DEFAULT_COMPANION,
)
@@ -179,6 +205,36 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
>
{bubble.en}
</p>
{/* The daily invitation's two answers. "Not today" is a real button
sitting level with the other one, not a small grey escape — a no
that has to be hunted for isn't much of a no. */}
{bubble.invite && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
acceptInvite(bubble.invite!.prompt)
}}
className="rounded-full px-3.5 py-1.5 text-sm font-bold"
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
>
{t.companion.inviteAccept}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
declineInvite()
}}
className="rounded-full px-3.5 py-1.5 text-sm font-semibold"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
>
{t.companion.inviteDecline}
</button>
</div>
)}
</div>
)}
@@ -0,0 +1,261 @@
import { afterEach, describe, expect, it } from 'vitest'
import { analyzeProse, mechanicsFindings } from './prose'
import { resetPackForTests, setPackLang } from '../../i18n'
// Phase 22's offline half: the grammar-lite rule pack, the embedded
// miscollocation list, the false-friend heads-up, and the per-pair L1 rules.
//
// The bar these tests enforce is the one the pack promises: **precision over
// recall**. Every rule is pinned in two directions — the mistake it must catch,
// and the correct English next to it that it must leave alone. A rule that
// cannot be guarded that way was left out of the pack rather than tested
// loosely here.
afterEach(() => resetPackForTests())
const rules = (text: string) => analyzeProse(text).map((h) => h.rule)
const findings = (text: string) => mechanicsFindings(text)
// The one-click fix a given rule produced, if any.
function fix(text: string, original: string) {
return findings(text).find((f) => f.original.toLowerCase() === original.toLowerCase())
}
// Every finding must be able to anchor: its span has to be exactly the text it
// claims, or the editor applies the edit to the wrong characters.
function expectExactSpans(text: string) {
for (const f of findings(text)) {
expect(text.slice(f.from, f.to), `span mismatch for "${f.original}"`).toBe(f.original)
}
return findings(text)
}
describe('prepositions', () => {
it('depend of → depend on, keeping the writer\'s own verb form', () => {
expect(fix('It all depends of the weather on the day we travel.', 'depends of')?.replacement).toBe(
'depends on',
)
expect(fix('Depending of the weather we will go to the beach today.', 'Depending of')?.replacement).toBe(
'Depending on',
)
})
it('discuss about → discuss (the preposition simply goes)', () => {
expect(fix('We discussed about the plan for a long time yesterday.', 'discussed about')?.replacement).toBe(
'discussed',
)
})
it('explain me → explain to me', () => {
expect(fix('Can you explain me the rules of this game again please.', 'explain me')?.replacement).toBe(
'explain to me',
)
})
it('listen the radio → listen to the radio', () => {
expect(fix('I listen the radio every morning while I make my coffee.', 'listen the')?.replacement).toBe(
'listen to the',
)
})
// The pairings deliberately NOT in the list, because they are only usually
// wrong. Each of these is correct English and must stay silent.
it('leaves the correct prepositions alone', () => {
expect(rules('It all depends on the weather on the day we travel.')).not.toContain('preposition')
expect(rules('We discussed the plan for a long time yesterday afternoon.')).not.toContain('preposition')
expect(rules('I listen to the radio every morning while I make coffee.')).not.toContain('preposition')
// Left out of the pack on purpose — "married with children" is a phrase,
// "arrive to" wants at or in, "different than" is ordinary American usage.
expect(rules('She is married with children and lives near the old harbour.')).not.toContain('preposition')
expect(rules('This result is different than the one we saw last week.')).not.toContain('preposition')
})
})
describe('doubled comparatives', () => {
it('more better → better', () => {
expect(fix('This one is more better than the other one we tried.', 'more better')?.replacement).toBe('better')
})
it('most easiest → easiest', () => {
expect(fix('That was the most easiest question on the whole exam paper.', 'most easiest')?.replacement).toBe(
'easiest',
)
})
// The guard the generic /\w+er/ pattern would have failed: these are correct.
it('leaves ordinary "more/most + adjective" alone', () => {
expect(rules('She is more clever than anyone else in the whole class.')).not.toContain('doublecomp')
expect(rules('He was the most eager student in the room that morning.')).not.toContain('doublecomp')
expect(rules('This is the most beautiful garden I have ever seen here.')).not.toContain('doublecomp')
})
})
describe('people is', () => {
it('people is → people are, and people has → people have', () => {
expect(fix('Many people is waiting outside the hall in the rain.', 'people is')?.replacement).toBe('people are')
expect(fix('Some people has never seen the sea in their whole life.', 'people has')?.replacement).toBe(
'people have',
)
})
it('leaves the plural alone', () => {
expect(rules('Many people are waiting outside the hall in the rain.')).not.toContain('peopleare')
})
})
describe('miscollocations', () => {
// The whole point of the family: these file as 'collocation', not
// 'mechanics', so an accepted chunk plants in the vocabulary garden exactly
// as one the LLM coach proposed would.
it('files as the collocation family, not as mechanics', () => {
const f = fix('I had to do a decision about the job offer quickly.', 'do a decision')
expect(f?.replacement).toBe('make a decision')
expect(f?.type).toBe('collocation')
})
it('mechanics fixes keep their own family', () => {
expect(fix('I saw the the cat in the garden this morning.', 'the the')?.type).toBe('mechanics')
})
it('agrees with the tense the writer was already using', () => {
expect(fix('She did a mistake on the form and had to start again.', 'did a mistake')?.replacement).toBe(
'made a mistake',
)
expect(fix('He is making his homework at the kitchen table right now.', 'making his homework')?.replacement).toBe(
'doing his homework',
)
})
it('say me → tell me', () => {
expect(fix('Please say me what happened at the meeting this afternoon.', 'say me')?.replacement).toBe('tell me')
})
it('make a photo → take a photo', () => {
expect(fix('We made a photo together in front of the old church.', 'made a photo')?.replacement).toBe(
'took a photo',
)
})
it('strong rain → heavy rain', () => {
expect(fix('There was strong rain all afternoon and we stayed inside.', 'strong rain')?.replacement).toBe(
'heavy rain',
)
})
// "strong wind" is the correct pairing, and the rule that fixes "big wind"
// must not propose it as a change to itself.
it('never proposes a phrase identical to what she wrote', () => {
expect(rules('There was a strong wind blowing across the open field today.')).not.toContain('collocation')
expect(fix('There was a big wind blowing across the open field today.', 'big wind')?.replacement).toBe(
'strong wind',
)
})
it('leaves the correct pairings alone', () => {
expect(rules('I had to make a decision about the job offer quickly.')).not.toContain('collocation')
expect(rules('She does her homework at the kitchen table every evening.')).not.toContain('collocation')
expect(rules('We took a photo together in front of the old church.')).not.toContain('collocation')
})
})
describe('false friends', () => {
it('flags a Portuguese false friend for the pt-PT pair, awareness-only', () => {
setPackLang('pt-PT')
const hints = analyzeProse('I will eventually finish the report before the end of the week.')
const ff = hints.find((h) => h.rule === 'falsefriend')
expect(ff).toBeDefined()
// Never a card: the word may well be the one she meant, and a one-click
// "fix" would be Petal deciding that for her.
expect(ff?.fix).toBeUndefined()
expect(findings('I will eventually finish the report before the end of the week.')).toEqual([])
})
it('raises at most one per pass — a heads-up, not a sweep', () => {
setPackLang('pt-PT')
const hints = analyzeProse(
'Actually I did not pretend to assist the lecture at the library this week.',
)
expect(hints.filter((h) => h.rule === 'falsefriend')).toHaveLength(1)
})
it('says nothing for the zh pair, which has no false friends at all', () => {
setPackLang('zh')
expect(rules('I will eventually finish the report before the end of the week.')).not.toContain('falsefriend')
})
})
describe('per-pair L1 interference', () => {
it('pt-PT: "have 30 years" → "am 30 years old"', () => {
setPackLang('pt-PT')
expect(fix('My sister I have 30 years and she is older than me.', 'I have 30 years')?.replacement).toBe(
'I am 30 years old',
)
// The subject and tense she wrote in are carried into the correction.
expect(fix('When we met she had twenty years and I was still at school.', 'she had twenty years')?.replacement).toBe(
'she was twenty years old',
)
})
it('pt-PT: "I am agree" → "I agree"', () => {
setPackLang('pt-PT')
expect(fix('I am agree with everything that was said at the meeting.', 'I am agree')?.replacement).toBe('I agree')
})
it('pt-PT: "since three years" → "for three years"', () => {
setPackLang('pt-PT')
expect(fix('I have lived in this city since three years and I love it.', 'since three years')?.replacement).toBe(
'for three years',
)
})
it('pt-PT: leaves "since" with a starting point alone', () => {
setPackLang('pt-PT')
expect(rules('I have lived in this city since 2020 and I still love it.')).not.toContain('since')
})
it('zh: "very like" → "really like", and "open the light" → "turn on the light"', () => {
setPackLang('zh')
expect(fix('I very like the small garden behind my grandmother house.', 'very like')?.replacement).toBe(
'really like',
)
expect(fix('Please open the light before you come into the dark room.', 'open the light')?.replacement).toBe(
'turn on the light',
)
expect(fix('She closed the television and went straight to bed last night.', 'closed the television')?.replacement).toBe(
'turned off the television',
)
})
it('zh: although…but is awareness-only — the "but" is too common to anchor a card to', () => {
setPackLang('zh')
const text = 'Although it was raining hard, but we still went to the park.'
expect(rules(text)).toContain('althoughbut')
expect(findings(text).some((f) => f.original.includes('but'))).toBe(false)
})
it('zh: leaves "very + adjective" alone', () => {
setPackLang('zh')
expect(rules('I am very happy about the small garden behind the house.')).not.toContain('veryverb')
})
// The gating is the reason these rules can be confident. A rule that is a
// near-certainty for one L1 is only a guess for another, and a guess does not
// belong in a rule pack that runs on every keystroke.
it('does not run one pair\'s interference rules for the other pair', () => {
setPackLang('zh')
expect(rules('I have 30 years and I still live near the old harbour.')).not.toContain('haveyears')
setPackLang('pt-PT')
expect(rules('I very like the small garden behind my grandmother house.')).not.toContain('veryverb')
})
})
describe('spans stay exact across every new rule', () => {
it('anchors each finding to the text it claims', () => {
setPackLang('pt-PT')
expectExactSpans(
'I have 30 years and I am agree that we depends of the weather, and she did a mistake since three years.',
)
setPackLang('zh')
expectExactSpans('I very like to open the light, and many people is more better at it than me.')
})
})
@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { markInvited, mayInvite, todayKey } from './invitation'
import { resetPackForTests, setPackLang } from '../../i18n'
import { zh } from '../../i18n/packs/zh'
import { ptPT } from '../../i18n/packs/pt-PT'
import { declined, invitations } from './tips'
// The suite runs without a DOM, so localStorage is stubbed the same way
// prefs.test.ts does it.
function fakeStorage(): Storage {
const map = new Map<string, string>()
return {
get length() {
return map.size
},
key: (i: number) => [...map.keys()][i] ?? null,
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
clear: () => map.clear(),
} as Storage
}
beforeEach(() => vi.stubGlobal('localStorage', fakeStorage()))
afterEach(() => {
vi.unstubAllGlobals()
resetPackForTests()
})
describe('once a day', () => {
it('offers, then does not offer again the same day', () => {
expect(mayInvite()).toBe(true)
markInvited()
expect(mayInvite()).toBe(false)
})
it('offers again the next day', () => {
const monday = new Date(2026, 6, 27, 10, 0)
const tuesday = new Date(2026, 6, 28, 9, 0)
markInvited(monday)
expect(mayInvite(monday)).toBe(false)
expect(mayInvite(tuesday)).toBe(true)
})
// The whole promise of §5c: nothing is counting. A month away has to look
// exactly like a day away, because the alternative is a streak, and a streak
// punishes exactly the person this feature is for.
it('treats a month away the same as a day away', () => {
const june = new Date(2026, 5, 1, 10, 0)
const july = new Date(2026, 6, 27, 10, 0)
markInvited(june)
expect(mayInvite(july)).toBe(true)
// And after being asked again, still only ever one stored value: a date.
markInvited(july)
expect(localStorage.getItem('petal.invited')).toBe(todayKey(july))
// One stored value, and it is a date. Nothing accumulates.
expect(localStorage.length).toBe(1)
})
it('uses the writer\'s local day, so a late night and the small hours differ', () => {
const lateNight = new Date(2026, 6, 27, 23, 30)
const smallHours = new Date(2026, 6, 28, 1, 15)
markInvited(lateNight)
expect(mayInvite(smallHours)).toBe(true)
})
})
describe('the invitation copy', () => {
const PACKS = [
{ name: 'zh', pack: zh },
{ name: 'pt-PT', pack: ptPT },
]
it('every pack offers something to write about, and a way to say no', () => {
for (const { name, pack } of PACKS) {
expect(pack.companion.invitations.length, name).toBeGreaterThan(3)
expect(pack.companion.inviteAccept.trim(), name).not.toBe('')
expect(pack.companion.inviteDecline.trim(), name).not.toBe('')
expect(pack.companion.declined.native.trim(), name).not.toBe('')
}
})
// The copy is bound by the same rule as the timing: no streaks, no guilt, no
// counting of days. This is the part a well-meaning future edit would undo —
// "day 4 in a row!" is a natural thing to write and the wrong thing to say.
it('never invokes a streak, a target, or a missed day', () => {
const forbidden =
/streak|in a row|every day|don't break|dont break|missed|behind|连续|打卡|坚持|seguidos|todos os dias|falhaste/i
for (const { name, pack } of PACKS) {
const copy = [
...pack.companion.invitations.flatMap((l) => [l.native, l.en]),
pack.companion.inviteAccept,
pack.companion.inviteDecline,
pack.companion.declined.native,
pack.companion.declined.en,
]
for (const line of copy) {
expect(line, `${name}: ${line}`).not.toMatch(forbidden)
}
}
})
it('reads from the pair in force, like every other companion line', () => {
setPackLang('pt-PT')
expect(invitations()).toBe(ptPT.companion.invitations)
expect(declined()).toBe(ptPT.companion.declined)
setPackLang('zh')
expect(invitations()).toBe(zh.companion.invitations)
})
})
@@ -0,0 +1,36 @@
// When the companion may offer its daily invitation to write.
//
// A handful of lines, pulled out of the timing engine on purpose: this is the
// part of §5c with the ethics in it, and it should be readable and testable on
// its own rather than buried in a heartbeat.
//
// The rule is a *date*, not a count and not a run of days. Petal remembers the
// last day it asked and nothing else — so there is no streak to break, no tally
// of days missed, and nothing that gets worse for being away a week. Coming
// back after a month looks exactly like coming back tomorrow, which is the only
// version of this feature worth shipping to someone learning a language.
//
// Either answer spends the day's invitation. Being asked again after saying no
// would turn "not today" into a negotiation.
import { readPref, writePref } from '../../lib/prefs'
const INVITED_KEY = 'petal.invited'
// The local calendar day. Local rather than UTC because "today" is the writer's
// day: a nudge at 11pm and another at 1am would otherwise be two different days.
export function todayKey(now = new Date()): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
// mayInvite reports whether today's invitation is still unspent.
export function mayInvite(now = new Date()): boolean {
return readPref(INVITED_KEY) !== todayKey(now)
}
// markInvited spends it — called when the invitation is *offered*, not when it
// is accepted, because declining has to count too.
export function markInvited(now = new Date()): void {
writePref(INVITED_KEY, todayKey(now))
}
+446 -7
View File
@@ -19,6 +19,7 @@
// fix-bearing hints so the same span never appears as both a bubble and a card.
import { pack } from '../../i18n'
import type { PairLang } from '../../i18n'
import type { Line } from './tips'
// The pair's prose copy. Read per finding rather than captured once, so a rule
@@ -41,8 +42,16 @@ export interface Fix {
from: number
to: number
replacement: string
// Which suggestion family the card belongs to. Omitted means 'mechanics' — a
// fix to *this* sentence. The miscollocation rules set 'collocation', because
// what they hand over is a reusable chunk: the writer sees the same rail and
// the same warm phrasing as the LLM coach, and an accepted chunk plants in the
// vocabulary garden exactly as the coach's would.
family?: FindingFamily
}
export type FindingFamily = 'mechanics' | 'collocation'
// A deterministic suggestion-card finding, derived from an applyable hint. Mirrors
// the backend's card shape (original/replacement/explanation + span) so the card
// pipeline can persist it as the 'mechanics' family. `explanation` is the English
@@ -53,6 +62,9 @@ export interface MechanicsFinding {
original: string
replacement: string
explanation: string
// The family the server should file it under (see Fix.family). Always sent, so
// the backend never has to infer it from the endpoint it arrived on.
type: FindingFamily
}
// ── small text helpers ──────────────────────────────────────────────────────
@@ -630,19 +642,438 @@ function thanThen(text: string, out: ProseHint[]) {
}
}
// "people is" — people is already plural in English, and every language Petal
// pairs with has a singular word for it (人 / a gente / les gens / la gente).
// Universal rather than per-pair for exactly that reason.
const PEOPLE_IS_RE = /\b(people)\s+(is|was|has)\b/gi
const PEOPLE_PLURAL: Record<string, string> = { is: 'are', was: 'were', has: 'have' }
function peopleAre(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
PEOPLE_IS_RE.lastIndex = 0
while ((m = PEOPLE_IS_RE.exec(text))) {
const plural = PEOPLE_PLURAL[m[2].toLowerCase()]
const fixed = `${m[1]} ${matchCase(m[2], plural)}`
out.push({
id: `peopleare:${m.index}`,
rule: 'peopleare',
native: P().peopleArePlural(plural),
en: `“People” is plural in English: “people ${plural}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
})
}
}
// ── preposition pairs ───────────────────────────────────────────────────────
// The verbs and adjectives whose preposition English simply *decides* for you.
// There is no rule to learn here — "depend" takes "on" and that is the end of
// it — which is exactly what makes the offline pack the right place for them:
// they are data, not judgement, and a lookup is instant.
//
// Every entry is a pairing that is wrong in essentially all contexts. The ones
// that are only *usually* wrong were left out on purpose: "married with" is a
// mistake until "married with children", "arrive to" wants at or in depending
// on the noun, "different than" is ordinary American English. Precision over
// recall — a confident wrong correction costs more than a quiet miss.
interface PrepRule {
// Matched case-insensitively, with \b at both ends. The first group is the
// head word (kept, casing preserved), the rest is replaced wholesale.
re: RegExp
// The corrected phrase, with $1 standing for the captured head word.
to: string
}
const PREPOSITIONS: PrepRule[] = [
{ re: /\b(depend|depends|depended|depending)\s+of\b/gi, to: '$1 on' },
{ re: /\b(discuss|discusses|discussed|discussing)\s+about\b/gi, to: '$1' },
{ re: /\b(participate|participates|participated|participating)\s+to\b/gi, to: '$1 in' },
{ re: /\b(interested)\s+(?:about|for)\b/gi, to: '$1 in' },
{ re: /\b(responsible)\s+of\b/gi, to: '$1 for' },
{ re: /\b(capable)\s+to\b/gi, to: '$1 of' },
{ re: /\b(afraid)\s+(?:from|of to)\b/gi, to: '$1 of' },
{ re: /\b(according)\s+with\b/gi, to: '$1 to' },
{ re: /\b(explain|explains|explained)\s+(me|us|him|her|them)\b/gi, to: '$1 to $2' },
// "listen the radio" — the object of "listen" always arrives through "to".
{ re: /\b(listen|listens|listened|listening)\s+(the|a|an|my|your|his|her|our|their|this|that|these|those|music|me|him|us|them)\b/gi, to: '$1 to $2' },
]
function prepositions(text: string, out: ProseHint[]) {
for (const rule of PREPOSITIONS) {
let m: RegExpExecArray | null
rule.re.lastIndex = 0
while ((m = rule.re.exec(text))) {
// Rebuild the corrected phrase from the captures so the head word keeps the
// writer's own casing ("Depending of" → "Depending on").
const fixed = rule.to.replace(/\$(\d)/g, (_, d: string) => m![Number(d)] ?? '')
if (fixed === m[0]) continue
out.push({
id: `prep:${m.index}:${key(m[0])}`,
rule: 'preposition',
native: P().preposition(m[0].trim(), fixed),
en: `In English it's “${fixed}”, not “${m[0].trim()}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
})
}
}
}
// Doubled comparatives and superlatives — "more better", "most easiest". The
// -er/-est ending already carries the comparison, so the "more"/"most" is the
// part that goes. An explicit form list rather than a generic /\w+er/ pattern,
// which would catch "more clever" and "most eager" (both perfectly correct).
const COMPARATIVE_FORMS =
'better|worse|greater|older|younger|bigger|smaller|larger|faster|slower|higher|' +
'lower|cheaper|stronger|weaker|easier|harder|earlier|later|sooner|longer|' +
'shorter|taller|richer|poorer|happier|safer|nicer|closer|warmer|colder'
const SUPERLATIVE_FORMS =
'best|worst|greatest|oldest|youngest|biggest|smallest|largest|fastest|slowest|' +
'highest|lowest|cheapest|strongest|weakest|easiest|hardest|earliest|latest|' +
'soonest|longest|shortest|tallest|richest|poorest|happiest|safest|nicest|' +
'closest|warmest|coldest'
const DOUBLE_COMPARATIVE_RE = new RegExp(
`\\b(more)\\s+(${COMPARATIVE_FORMS})\\b|\\b(most)\\s+(${SUPERLATIVE_FORMS})\\b`,
'gi',
)
function doubleComparative(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
DOUBLE_COMPARATIVE_RE.lastIndex = 0
while ((m = DOUBLE_COMPARATIVE_RE.exec(text))) {
const lead = m[1] ?? m[3]
const word = m[2] ?? m[4]
out.push({
id: `doublecomp:${m.index}`,
rule: 'doublecomp',
native: P().doubleComparative(lead, word),
en: `${word}” is already the comparison — “${lead}” isn't needed: just “${word}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(lead, word) },
})
}
}
// ── miscollocations (the collocation family, offline half) ──────────────────
// A few dozen entries of curated data doing what the collocation coach does
// with a model behind a VPN. These are the do/make, say/tell, heavy-rain pairs
// that fill every ESL collocation workbook: the writer's grammar is perfect and
// the pairing is simply not the one English uses.
//
// They file as 'collocation', not 'mechanics', because that is what they are —
// and it earns the writer the rest of the family's behaviour for free: the same
// card, and a phrase card planted in the vocabulary garden when she accepts.
// The writer never needs to know which engine spoke.
//
// Each entry is a whole-phrase swap so the card can anchor by string, and the
// object is captured rather than listed, so "do a serious mistake" is caught
// alongside "do a mistake".
interface CollocationRule {
re: RegExp
to: string
}
const MISCOLLOCATIONS: CollocationRule[] = [
// do / make — the classic pair, in both directions.
{ re: /\b(do|does|did|doing)\s+(a|an|the|my|your|his|her|our|their)\s+(decision|mistake|mistakes|effort|question|questions|progress|joke|jokes|favou?r)\b/gi, to: 'MAKE' },
{ re: /\b(make|makes|made|making)\s+(my|your|his|her|our|their|the)\s+(homework|housework|laundry|dishes|shopping)\b/gi, to: 'DO' },
{ re: /\b(make|makes|made|making)\s+(a|an|the|my|your|his|her|our|their)\s+(photo|photos|picture|pictures|shower|bath|walk|trip|nap|break|exam|exams|test|bus|taxi|train)\b/gi, to: 'TAKE' },
{ re: /\b(make|makes|made|making)\s+(a|an|the)\s+(party|baby|good time|meeting)\b/gi, to: 'HAVE' },
{ re: /\b(make|makes|made|making)\s+(a|an|the|my|your|his|her)\s+(question|questions)\b/gi, to: 'ASK' },
{ re: /\b(make|makes|made|making|do|does|did|doing)\s+attention\b/gi, to: 'PAY_ATTENTION' },
// say / tell — "say me" for "tell me" is near-universal among learners.
{ re: /\b(say|says|said|saying)\s+(me|him|her|us|them)\b/gi, to: 'TELL' },
{ re: /\b(say|says|said|saying)\s+(a|the)\s+(lie|lies|truth|joke|jokes|story|stories)\b/gi, to: 'TELL_A' },
// Weather and intensity — English picks a different adjective per noun.
{ re: /\b(strong|big|hard|huge)\s+(rain|snow|traffic|fog)\b/gi, to: 'HEAVY' },
{ re: /\b(strong|heavy|big)\s+(wind|winds)\b/gi, to: 'STRONG_WIND' },
]
// Rebuild the corrected phrase for one miscollocation match. Kept as code rather
// than a `to` template because the verb has to agree with the writer's own tense
// ("did a mistake" → "made a mistake"), and only the verb form knows that.
const VERB_FORMS: Record<string, Record<string, string>> = {
make: { base: 'make', s: 'makes', past: 'made', ing: 'making' },
do: { base: 'do', s: 'does', past: 'did', ing: 'doing' },
take: { base: 'take', s: 'takes', past: 'took', ing: 'taking' },
have: { base: 'have', s: 'has', past: 'had', ing: 'having' },
ask: { base: 'ask', s: 'asks', past: 'asked', ing: 'asking' },
tell: { base: 'tell', s: 'tells', past: 'told', ing: 'telling' },
pay: { base: 'pay', s: 'pays', past: 'paid', ing: 'paying' },
}
// Which slot of VERB_FORMS the writer's own verb occupies, so the replacement
// lands in the same tense she was writing in.
function verbSlot(verb: string): string {
const v = verb.toLowerCase()
if (v.endsWith('ing')) return 'ing'
if (v === 'did' || v === 'made' || v === 'took' || v === 'had' || v === 'told' || v === 'said' || v === 'paid' || v.endsWith('ed')) return 'past'
if (v === 'does' || v === 'says' || v === 'has' || v.endsWith('s')) return 's'
return 'base'
}
function collocationFix(m: RegExpExecArray, to: string): string | null {
const slot = verbSlot(m[1])
const conj = (v: string) => matchCase(m[1], VERB_FORMS[v][slot])
switch (to) {
case 'MAKE':
case 'DO':
case 'TAKE':
case 'HAVE':
case 'ASK':
return `${conj(to.toLowerCase())} ${m[2]} ${m[3]}`
case 'TELL':
return `${conj('tell')} ${m[2]}`
case 'TELL_A':
return `${conj('tell')} ${m[2]} ${m[3]}`
case 'PAY_ATTENTION':
return `${conj('pay')} attention`
case 'HEAVY':
return `${matchCase(m[1], 'heavy')} ${m[2]}`
case 'STRONG_WIND':
return m[1].toLowerCase() === 'strong' ? null : `${matchCase(m[1], 'strong')} ${m[2]}`
default:
return null
}
}
function miscollocations(text: string, out: ProseHint[]) {
for (const rule of MISCOLLOCATIONS) {
let m: RegExpExecArray | null
rule.re.lastIndex = 0
while ((m = rule.re.exec(text))) {
const fixed = collocationFix(m, rule.to)
if (!fixed || fixed.toLowerCase() === m[0].toLowerCase()) continue
out.push({
id: `colloc:${m.index}:${key(m[0])}`,
rule: 'collocation',
native: P().collocation(m[0].trim(), fixed),
en: `English usually pairs these differently: “${fixed}” rather than “${m[0].trim()}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed, family: 'collocation' },
})
}
}
}
// ── false friends ───────────────────────────────────────────────────────────
// A word that looks like one of hers and means something else. This is the
// mistake that makes a learner feel foolish rather than merely corrected, so
// Petal only ever raises an eyebrow: awareness-only, one per pass, and never a
// replacement. "Actually" really might be the word she wanted — the flag says
// what it means in English and lets her decide.
//
// The list is per pair and lives in the langpack (a zh pair has none: the trap
// needs a shared script to spring). See Pack.falseFriends.
function falseFriends(text: string, out: ProseHint[]) {
const list = pack().falseFriends
const words = Object.keys(list)
if (words.length === 0) return
// Escaped, because these keys are pack data: a future author writing "e.g."
// should get a heads-up, not a pattern that quietly matches everything.
const re = new RegExp(`\\b(${words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\b`, 'gi')
let m: RegExpExecArray | null
while ((m = re.exec(text))) {
const entry = list[m[1].toLowerCase()]
if (!entry) continue
out.push({
id: `falsefriend:${m[1].toLowerCase()}`,
rule: 'falsefriend',
native: entry.native,
en: entry.en,
})
return // one per pass — a heads-up, not a sweep
}
}
// ── per-pair L1 interference ────────────────────────────────────────────────
// Mistakes that are not "English mistakes" at all but the writer's own language
// showing through: *ter 30 anos* becomes "have 30 years", 很喜欢 becomes "very
// like", 开灯 becomes "open the light". They are gated by pair precisely so they
// can be confident — a pattern that is a near-certainty for a Portuguese speaker
// is only a guess for anybody else, and a guess doesn't belong in a rule pack.
//
// The rules a pair *doesn't* get are as deliberate as the ones it does. Mandarin
// drops articles and slips he/she, both of which the plan names — and neither is
// detectable from the text alone. "She said he was late" is a perfect sentence
// whichever pronoun was meant, and no offline rule can tell a missing "the" from
// a mass noun. Flagging them would mean correcting correct writing, which is the
// one thing this pack promises not to do.
const NUMBER_WORD =
'one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|' +
'fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|' +
'fifty|sixty|seventy|eighty|ninety'
// *Ter X anos* / *avoir X ans* / *tener X años*: age is something you *have* in
// every Romance language and something you *are* in English.
const HAVE_YEARS_RE = new RegExp(
`\\b(I|you|we|they|he|she)\\s+(have|has|had)\\s+(\\d{1,3}|${NUMBER_WORD})\\s+years(\\s+old)?\\b`,
'gi',
)
// The English "to be" that matches the subject and the tense she wrote in.
function beFor(subject: string, verb: string): string {
const past = verb.toLowerCase() === 'had'
const s = subject.toLowerCase()
if (s === 'i') return past ? 'was' : 'am'
if (s === 'he' || s === 'she') return past ? 'was' : 'is'
return past ? 'were' : 'are'
}
function haveYears(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
HAVE_YEARS_RE.lastIndex = 0
while ((m = HAVE_YEARS_RE.exec(text))) {
const fixed = `${m[1]} ${beFor(m[1], m[2])} ${m[3]} years old`
out.push({
id: `haveyears:${m.index}`,
rule: 'haveyears',
native: P().ageIsNotHave(m[3]),
en: `In English you *are* your age: “${fixed}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
})
}
}
// *Estou de acordo* / *je suis d'accord*: agreement is a verb in English, so the
// "to be" in front of it has nothing to do.
const AM_AGREE_RE = /\b(I|you|we|they|he|she)\s+(am|are|is|was|were)\s+agree\b/gi
function amAgree(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
AM_AGREE_RE.lastIndex = 0
while ((m = AM_AGREE_RE.exec(text))) {
const past = /^(was|were)$/i.test(m[2])
const verb = past ? 'agreed' : m[1].toLowerCase() === 'he' || m[1].toLowerCase() === 'she' ? 'agrees' : 'agree'
const fixed = `${m[1]} ${verb}`
out.push({
id: `amagree:${m.index}`,
rule: 'amagree',
native: P().agreeIsAVerb,
en: `“Agree” is already the verb: “${fixed}”, not “${m[0].trim()}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
})
}
}
// *desde há três anos* / *depuis trois ans*: a stretch of time takes "for";
// "since" wants the moment it started.
const SINCE_DURATION_RE = new RegExp(
`\\b(since)\\s+((?:\\d{1,3}|${NUMBER_WORD}|a few|several|many)\\s+(?:years|months|weeks|days|hours|minutes))\\b`,
'gi',
)
function sinceDuration(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
SINCE_DURATION_RE.lastIndex = 0
while ((m = SINCE_DURATION_RE.exec(text))) {
const fixed = `${matchCase(m[1], 'for')} ${m[2]}`
out.push({
id: `since:${m.index}`,
rule: 'since',
native: P().forNotSince(m[2]),
en: `For a length of time use “for”: “${fixed}”. “Since” names when it started.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
})
}
}
// 很喜欢 — 很 modifies adjectives *and* verbs in Mandarin, so "very" arrives in
// front of English verbs, where it cannot go.
const VERY_VERB_RE =
/\b(very)\s+(like|likes|liked|want|wants|wanted|enjoy|enjoys|enjoyed|hope|hopes|hoped|miss|misses|missed|need|needs|needed|love|loves|loved|agree|agrees|agreed|understand|understands)\b/gi
function veryVerb(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
VERY_VERB_RE.lastIndex = 0
while ((m = VERY_VERB_RE.exec(text))) {
const fixed = `${matchCase(m[1], 'really')} ${m[2]}`
out.push({
id: `veryverb:${m.index}`,
rule: 'veryverb',
native: P().veryBeforeVerb(m[2]),
en: `“Very” goes with adjectives, not verbs — “${fixed}” (or “${m[2]} … very much”).`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
})
}
}
// 开灯 / 关电视 — Mandarin opens and closes appliances; English turns them on
// and off.
const TURN_FORMS: Record<string, string> = {
base: 'turn',
s: 'turns',
past: 'turned',
ing: 'turning',
}
const OPEN_LIGHT_RE =
/\b(open|opens|opened|close|closes|closed)\s+(the|a|my|your|his|her|our|their)\s+(light|lights|lamp|tv|television|radio|computer|fan|heater|air conditioner|air-conditioner)\b/gi
function openTheLight(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
OPEN_LIGHT_RE.lastIndex = 0
while ((m = OPEN_LIGHT_RE.exec(text))) {
const opening = m[1].toLowerCase().startsWith('open')
const turn = matchCase(m[1], TURN_FORMS[verbSlot(m[1])] ?? 'turn')
const fixed = `${turn} ${opening ? 'on' : 'off'} ${m[2]} ${m[3]}`
out.push({
id: `openlight:${m.index}`,
rule: 'openlight',
native: P().turnOnNotOpen(m[3], opening),
en: `In English you turn a ${m[3]} ${opening ? 'on' : 'off'}: “${fixed}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
})
}
}
// 虽然…但是 is a matched pair in Mandarin; English takes one or the other, never
// both. Awareness-only: which half to drop is the writer's call, and the "but"
// on its own is far too common a word to anchor a card to.
const ALTHOUGH_BUT_RE = /\b(although|though|even though)\b[^.!?]{0,120}?,?\s+but\b/gi
function althoughBut(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
ALTHOUGH_BUT_RE.lastIndex = 0
while ((m = ALTHOUGH_BUT_RE.exec(text))) {
out.push({
id: `althoughbut:${key(m[0])}`,
rule: 'althoughbut',
native: P().althoughOrBut(m[1]),
en: `English uses “${m[1]}” or “but”, not both — one of them can go.`,
})
return
}
}
type Rule = (text: string, out: ProseHint[]) => void
// Which interference rules belong to which pair. A pair with no entry simply
// runs the shared pack, which is the correct behaviour for a language Petal has
// not studied yet rather than a gap to fill with guesses.
const L1_RULES: Partial<Record<PairLang, Rule[]>> = {
zh: [veryVerb, openTheLight, althoughBut],
'pt-PT': [haveYears, amAgree, sinceDuration],
fr: [haveYears, amAgree, sinceDuration],
es: [haveYears, amAgree, sinceDuration],
}
// ── orchestration ───────────────────────────────────────────────────────────
// Rules run in priority order — the ones the writer cares most about first, so
// that when several fire at once the companion leads with the weightiest note.
// The false-friend heads-up sits near the top: of everything here it is the one
// that costs her most to find out about later.
const RULES: Array<(text: string, out: ProseHint[]) => void> = [
runOns,
commaSplices,
falseFriends,
antecedents,
oxford,
miscollocations,
articles,
uncountables,
properCaps,
subjectVerbAgreement,
peopleAre,
prepositions,
doubleComparative,
pluralAfterNumber,
doubleDeterminer,
thereIsPlural,
@@ -656,6 +1087,13 @@ const RULES: Array<(text: string, out: ProseHint[]) => void> = [
spaceAfterPunct,
]
// Every rule that runs for this writer: the shared pack, plus the interference
// rules belonging to her pair. Read per call rather than built once — the pair
// isn't known until /api/me answers, and the checker runs long before and after.
function rulesFor(): Rule[] {
return [...RULES, ...(L1_RULES[pack().code] ?? [])]
}
// analyzeProse returns context-aware hints, highest-priority first. It bails on
// text too short to advise on (mid-thought drafts shouldn't get picked apart).
// Hints that carry a `fix` are applyable (they also surface as suggestion cards);
@@ -664,26 +1102,27 @@ export function analyzeProse(text: string): ProseHint[] {
const englishWords = text.match(ENGLISH_WORD_RE)?.length ?? 0
if (englishWords < 8) return []
const out: ProseHint[] = []
for (const rule of RULES) rule(text, out)
for (const rule of rulesFor()) rule(text, out)
return out
}
// mechanicsFindings returns every applyable deterministic fix in the text, as
// suggestion-card findings with exact spans. No word-count floor: a doubled word
// or a stray lowercase “i” is worth fixing even in a short draft, the way a
// spell-checker would. The card pipeline persists these as the 'mechanics'
// family; collisions with the LLM cards are resolved server-side (mechanics
// wins, since its span is exact).
// spell-checker would. The card pipeline persists these under the family each
// finding names (mechanics, or collocation for the miscollocation rules);
// collisions with the LLM cards are resolved server-side (the offline card wins,
// since its span is exact).
export function mechanicsFindings(text: string): MechanicsFinding[] {
const hints: ProseHint[] = []
for (const rule of RULES) rule(text, hints)
for (const rule of rulesFor()) rule(text, hints)
const found: MechanicsFinding[] = []
for (const h of hints) {
if (!h.fix) continue
const { from, to, replacement } = h.fix
const { from, to, replacement, family } = h.fix
const original = text.slice(from, to)
if (!original || original === replacement) continue
found.push({ from, to, original, replacement, explanation: h.en })
found.push({ from, to, original, replacement, explanation: h.en, type: family ?? 'mechanics' })
}
// Two rules can occasionally claim overlapping spans (e.g. a doubled word that
// also reads as stacked determiners). Resolve to one card per stretch of text:
+2
View File
@@ -18,6 +18,8 @@ export const errors = (): Line[] => pack().companion.errors
export const greeting = (): Line => pack().companion.greeting
export const welcomeBack = (): Line => pack().companion.welcomeBack
export const milestoneLine = (n: number): Line => pack().companion.milestone(n)
export const invitations = (): Line[] => pack().companion.invitations
export const declined = (): Line => pack().companion.declined
// Word-count milestones worth a little cheer — every 100 words, on up. A count,
// not copy: the same in every language.
+101 -4
View File
@@ -4,15 +4,18 @@ import {
MILESTONES,
bedtime,
breaks,
declined,
encouragements,
errors,
greeting,
invitations,
milestoneLine,
pick,
tips,
welcomeBack,
type Line,
} from './tips'
import { markInvited, mayInvite } from './invitation'
import { analyzeProse } from './prose'
import { personalCheer, warmPersonalCheers } from './journalCheers'
import { playPop, playSound, type SoundName } from '../../audio/sounds'
@@ -22,9 +25,12 @@ import { isBedtime } from '../../lib/night'
// otherwise to an emoji placeholder (see PetalCompanion).
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate'
export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error' | 'bedtime'
export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error' | 'bedtime' | 'invite'
export interface Bubble extends Line {
tone: BubbleTone
// Present only on the daily invitation: the two answers it can be given. The
// bubble is otherwise a thing to read, so this is the one that grows buttons.
invite?: { prompt: string }
}
interface Signals {
@@ -40,6 +46,10 @@ interface Signals {
// The document's plain text, used by the rules-based prose checker to offer
// context-aware writing notes instead of only generic tips.
text: string
// True when the open document is still empty — she has Petal in front of her
// and nothing started. The only condition under which the daily invitation is
// offered: a writer already mid-paragraph does not need to be invited.
blankPage: boolean
}
// Timing knobs (ms). Tuned to feel present but never naggy.
@@ -48,6 +58,18 @@ const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break
const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window
const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
const BEDTIME_GAP = 30 * 60_000 // at most one "go to bed" nudge per this window
// How long an empty page sits there before the kitten offers something to write
// about. Long enough that a writer who opened Petal knowing what she wanted to
// say is already typing, short enough to still be an offer rather than an
// interruption.
const INVITE_AFTER_MS = 50_000
// …and the window closes: past this the session has its own shape, and an
// invitation would be arriving out of nowhere.
const INVITE_WINDOW_MS = 8 * 60_000
// The once-a-day rule itself lives in ./invitation — it is the part of this
// feature with a promise in it (a date, never a streak), and it reads better
// stated once than tangled into the heartbeat below.
// The late-night window itself (isBedtime) lives in ../../lib/night so the
// companion nag and the night-mode theme/starfall share one definition.
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by
@@ -64,7 +86,17 @@ const now = () => Date.now()
// length of the native + English lines so denser advice stays up long enough
// to actually finish reading.
function readBubbleMs(b: Bubble): number {
const base = b.tone === 'cheer' ? CHEER_MS : b.tone === 'bedtime' ? BUBBLE_MS + 4_000 : BUBBLE_MS
// An invitation is the one bubble with a decision in it, so it gets the
// longest look — and it still leaves on its own, which is a third way of
// saying no that costs nothing.
const base =
b.tone === 'cheer'
? CHEER_MS
: b.tone === 'invite'
? BUBBLE_MS + 12_000
: b.tone === 'bedtime'
? BUBBLE_MS + 4_000
: BUBBLE_MS
const chars = b.native.length + b.en.length
return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR)
}
@@ -72,7 +104,15 @@ function readBubbleMs(b: Bubble): number {
// useCompanion is the behavior engine: it watches writing signals and decides
// when the kitten speaks, what mood it shows, and how to pace itself so the
// companion feels alive without interrupting. UI-agnostic — returns state only.
export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Signals) {
export function useCompanion({
wordCount,
saveStatus,
llmDown,
editTick,
acceptTick,
text,
blankPage,
}: Signals) {
const [mood, setMood] = useState<Mood>('idle')
const [bubble, setBubble] = useState<Bubble | null>(null)
@@ -84,6 +124,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
const lastBedtime = useRef(0)
const nextMilestone = useRef(0) // index into MILESTONES
const sleeping = useRef(false)
const invited = useRef(false) // this session, alongside the stored date
// Latest text for the prose checker, read lazily by the heartbeat (kept in a
// ref so per-keystroke changes don't re-arm the interval).
@@ -144,6 +185,27 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
return { ...pick(tips()), tone: 'tip' }
}, [])
// The daily invitation. Accepting hands the prompt back to the app (it titles
// the blank page with it, so the question stays in view while she answers it);
// declining costs nothing at all and says so. Either answer spends the day's
// one invitation — being asked twice after saying no would make "no" a
// negotiation.
const blankRef = useRef(blankPage)
blankRef.current = blankPage
const onInviteRef = useRef<((prompt: string) => void) | undefined>(undefined)
const acceptInvite = useCallback((prompt: string) => {
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
setBubble(null)
setMood('happy')
onInviteRef.current?.(prompt)
}, [])
const declineInvite = useCallback(() => {
say({ ...declined(), tone: 'tip' })
}, [say])
const dismiss = useCallback(() => {
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
@@ -264,6 +326,26 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
const t = now()
const idleFor = t - lastActivity.current
// An empty page, a little way into the session, and no invitation yet
// today: offer something small to write about. Checked before the idle
// branch on purpose — sitting in front of a blank page without typing is
// exactly the state this is for, and it is the one state the nap rule
// would otherwise swallow.
const sinceStart = t - sessionStart.current
if (
blankRef.current &&
!invited.current &&
sinceStart > INVITE_AFTER_MS &&
sinceStart < INVITE_WINDOW_MS &&
mayInvite()
) {
invited.current = true
markInvited()
const line = pick(invitations())
say({ ...line, tone: 'invite', invite: { prompt: line.en } })
return
}
if (idleFor > IDLE_MS) {
sleeping.current = true
if (!bubble) setMood('sleeping')
@@ -297,6 +379,12 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
return () => clearInterval(id)
}, [bubble, say, nextTip])
// The app's handler for an accepted invitation, kept in a ref so a new
// callback identity never re-arms the heartbeat.
const setInviteHandler = useCallback((fn: (prompt: string) => void) => {
onInviteRef.current = fn
}, [])
useEffect(
() => () => {
clearTimeout(bubbleTimer.current)
@@ -305,5 +393,14 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
[],
)
return { mood, bubble, dismiss, holdBubble, releaseBubble }
return {
mood,
bubble,
dismiss,
holdBubble,
releaseBubble,
acceptInvite,
declineInvite,
setInviteHandler,
}
}