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
407 lines
16 KiB
TypeScript
407 lines
16 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import type { SaveStatus } from '../../hooks/useAutoSave'
|
|
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'
|
|
import { isBedtime } from '../../lib/night'
|
|
|
|
// The kitten's expression. Maps to a Lottie animation when assets are present,
|
|
// otherwise to an emoji placeholder (see PetalCompanion).
|
|
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate'
|
|
|
|
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 {
|
|
wordCount: number
|
|
saveStatus: SaveStatus
|
|
// True while the writing-assist LLM is unreachable (timeout / down). A
|
|
// false→true flip earns a gentle "haiya".
|
|
llmDown: boolean
|
|
// Monotonic counters bumped by the app on each editor change / accepted
|
|
// suggestion — lets the companion react without a full event bus.
|
|
editTick: number
|
|
acceptTick: number
|
|
// 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.
|
|
const IDLE_MS = 75_000 // no edits → kitten naps
|
|
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
|
|
// how much there is to read, since she reads both the Mandarin and the English
|
|
// (and tips now quote a slice of her own sentence, so they run longer).
|
|
const BUBBLE_MS = 14_000 // baseline for a tip / break
|
|
const CHEER_MS = 9_000 // a short cheer still needs a beat in two languages
|
|
const READ_MS_PER_CHAR = 55 // ~18 chars/sec, generous for a bilingual ESL read
|
|
const MAX_BUBBLE_MS = 32_000 // cap so a long quote can't pin the bubble forever
|
|
const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
|
|
const now = () => Date.now()
|
|
|
|
// Reading time for a bubble: a base floor by tone, stretched by the combined
|
|
// length of the native + English lines so denser advice stays up long enough
|
|
// to actually finish reading.
|
|
function readBubbleMs(b: Bubble): number {
|
|
// 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)
|
|
}
|
|
|
|
// 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,
|
|
blankPage,
|
|
}: Signals) {
|
|
const [mood, setMood] = useState<Mood>('idle')
|
|
const [bubble, setBubble] = useState<Bubble | null>(null)
|
|
|
|
const lastActivity = useRef(now())
|
|
const sessionStart = useRef(now())
|
|
const lastProactive = useRef(0)
|
|
const lastTip = useRef(0)
|
|
const lastBreak = useRef(0)
|
|
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).
|
|
const textRef = useRef(text)
|
|
textRef.current = text
|
|
// Recently-surfaced hint ids, so the same untouched sentence isn't re-flagged
|
|
// each cadence. A small ring — old ids age out and may resurface later.
|
|
const recentHints = useRef<string[]>([])
|
|
const lastRule = useRef<string>('')
|
|
|
|
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
const moodTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
|
|
// Show a bubble + talking mood, then settle back. `proactive` messages respect
|
|
// the spacing floor; user-triggered ones (cheers) always go through.
|
|
const say = useCallback(
|
|
(b: Bubble, opts?: { proactive?: boolean; celebrate?: boolean; sound?: SoundName }) => {
|
|
const t = now()
|
|
if (opts?.proactive) {
|
|
if (bubble) return
|
|
if (t - lastProactive.current < PROACTIVE_GAP) return
|
|
lastProactive.current = t
|
|
}
|
|
sleeping.current = false
|
|
// A sound to match the bubble: callers can name a specific one (the milestone
|
|
// fanfare, the "haiya" on errors); everything else gets a rotating pop so the
|
|
// same blip never repeats back to back.
|
|
if (opts?.sound) playSound(opts.sound)
|
|
else playPop()
|
|
setBubble(b)
|
|
setMood(opts?.celebrate ? 'celebrate' : 'talking')
|
|
clearTimeout(bubbleTimer.current)
|
|
clearTimeout(moodTimer.current)
|
|
const dur = readBubbleMs(b)
|
|
bubbleTimer.current = setTimeout(() => setBubble(null), dur)
|
|
moodTimer.current = setTimeout(() => setMood('idle'), dur)
|
|
}, [bubble])
|
|
|
|
// Choose the next spontaneous tip. Prefer a real, context-aware note from the
|
|
// rules-based prose checker (it's actionable and clearly about *her* writing);
|
|
// fall back to a generic encouragement-style tip when the text reads clean or
|
|
// every finding was shown recently. Avoids repeating a finding or the same
|
|
// rule family back-to-back so the companion never feels like a broken record.
|
|
const nextTip = useCallback((): Bubble => {
|
|
// Applyable hints (those with a `fix`) surface as one-click suggestion cards,
|
|
// so the companion skips them — a span shouldn't be both a bubble and a card.
|
|
// What's left is the awareness notes (run-ons, splices, …) the kitten alone gives.
|
|
const hints = analyzeProse(textRef.current).filter((h) => !h.fix)
|
|
const fresh = hints.find(
|
|
(h) => !recentHints.current.includes(h.id) && h.rule !== lastRule.current,
|
|
)
|
|
const hint = fresh ?? hints.find((h) => !recentHints.current.includes(h.id))
|
|
if (hint) {
|
|
lastRule.current = hint.rule
|
|
recentHints.current = [hint.id, ...recentHints.current].slice(0, 8)
|
|
return { native: hint.native, en: hint.en, tone: 'tip' }
|
|
}
|
|
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)
|
|
setBubble(null)
|
|
setMood('idle')
|
|
}, [])
|
|
|
|
// Pause the auto-dismiss while she hovers a bubble — so a longer note never
|
|
// vanishes mid-read. Releasing leaves it up for a short, fixed grace so it
|
|
// doesn't linger forever once she looks away.
|
|
const holdBubble = useCallback(() => {
|
|
clearTimeout(bubbleTimer.current)
|
|
clearTimeout(moodTimer.current)
|
|
}, [])
|
|
|
|
const releaseBubble = useCallback(() => {
|
|
clearTimeout(bubbleTimer.current)
|
|
clearTimeout(moodTimer.current)
|
|
bubbleTimer.current = setTimeout(() => setBubble(null), HOVER_GRACE_MS)
|
|
moodTimer.current = setTimeout(() => setMood('idle'), HOVER_GRACE_MS)
|
|
}, [])
|
|
|
|
// Opening hello (once), after a short beat so it doesn't race the first paint.
|
|
useEffect(() => {
|
|
const id = setTimeout(() => say({ ...greeting(), tone: 'tip' }), 1200)
|
|
return () => clearTimeout(id)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
// Edits: record activity and wake from a nap with a warm welcome-back.
|
|
const firstEdit = useRef(true)
|
|
useEffect(() => {
|
|
if (firstEdit.current) {
|
|
firstEdit.current = false
|
|
return
|
|
}
|
|
lastActivity.current = now()
|
|
if (sleeping.current) {
|
|
sleeping.current = false
|
|
sessionStart.current = now() // a fresh stretch starts on return
|
|
say({ ...welcomeBack(), tone: 'cheer' })
|
|
} else if (mood === 'sleeping') {
|
|
setMood('idle')
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [editTick])
|
|
|
|
// Accepts: always cheer (it's a direct response to her action).
|
|
const firstAccept = useRef(true)
|
|
useEffect(() => {
|
|
if (firstAccept.current) {
|
|
firstAccept.current = false
|
|
return
|
|
}
|
|
// Prefer something true of her own writing over a line that would fit
|
|
// anybody — but only sometimes, so the personal ones stay a small surprise
|
|
// rather than the new default. The journal is fetched on this first accept
|
|
// and never awaited: the cheer goes out now, personal or not.
|
|
warmPersonalCheers()
|
|
const personal = Math.random() < 0.5 ? personalCheer() : null
|
|
say({ ...(personal ?? pick(encouragements())), tone: 'cheer' }, { celebrate: true })
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [acceptTick])
|
|
|
|
// Word-count milestones: cheer the first time each threshold is crossed.
|
|
useEffect(() => {
|
|
while (
|
|
nextMilestone.current < MILESTONES.length &&
|
|
wordCount >= MILESTONES[nextMilestone.current]
|
|
) {
|
|
const n = MILESTONES[nextMilestone.current]
|
|
nextMilestone.current += 1
|
|
// Skip silently if we're just loading a long doc (no edits yet).
|
|
if (!firstEdit.current)
|
|
say({ ...milestoneLine(n), tone: 'cheer' }, { celebrate: true, sound: 'milestone' })
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [wordCount])
|
|
|
|
// Trouble — a save failed or the writing-assist LLM went unreachable. Play the
|
|
// playful "haiya" and reassure her, but never spam: at most one per gap, and
|
|
// only on the *transition* into trouble (not while it lingers).
|
|
const lastError = useRef(0)
|
|
const ERROR_GAP = 20_000
|
|
const wasLlmDown = useRef(false)
|
|
const haiya = useCallback(() => {
|
|
const t = now()
|
|
if (t - lastError.current < ERROR_GAP) {
|
|
playSound('error') // still acknowledge it, just without a fresh bubble
|
|
return
|
|
}
|
|
lastError.current = t
|
|
say({ ...pick(errors()), tone: 'error' }, { sound: 'error' })
|
|
}, [say])
|
|
|
|
useEffect(() => {
|
|
if (saveStatus === 'error') haiya()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [saveStatus])
|
|
|
|
useEffect(() => {
|
|
if (llmDown && !wasLlmDown.current) haiya()
|
|
wasLlmDown.current = llmDown
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [llmDown])
|
|
|
|
// Occasional gentle cheer on a successful save (kept rare so it isn't noise).
|
|
useEffect(() => {
|
|
if (saveStatus === 'saved' && Math.random() < 0.18) {
|
|
say({ ...pick(encouragements()), tone: 'cheer' }, { proactive: true })
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [saveStatus])
|
|
|
|
// Heartbeat: nap when idle, suggest breaks, and drop the odd writing tip.
|
|
useEffect(() => {
|
|
const id = setInterval(() => {
|
|
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')
|
|
return // resting — no nudges while she's away
|
|
}
|
|
|
|
// Active: time for a break?
|
|
if (t - sessionStart.current > BREAK_MS && t - lastBreak.current > BREAK_MS) {
|
|
lastBreak.current = t
|
|
sessionStart.current = t
|
|
say({ ...pick(breaks()), tone: 'break' }, { proactive: true })
|
|
return
|
|
}
|
|
|
|
// Burning the midnight oil? Gently suggest bed — caring, low-frequency,
|
|
// and only while she's actually still at it (the idle branch above already
|
|
// returned if she's away/napping).
|
|
if (isBedtime() && t - lastBedtime.current > BEDTIME_GAP) {
|
|
lastBedtime.current = t
|
|
say({ ...pick(bedtime()), tone: 'bedtime' }, { proactive: true })
|
|
return
|
|
}
|
|
|
|
// Otherwise an occasional tip — context-aware when her text gives us
|
|
// something concrete to gently point at, generic warmth otherwise.
|
|
if (t - lastTip.current > TIP_MIN_GAP) {
|
|
lastTip.current = t
|
|
say(nextTip(), { proactive: true })
|
|
}
|
|
}, 10_000)
|
|
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)
|
|
clearTimeout(moodTimer.current)
|
|
},
|
|
[],
|
|
)
|
|
|
|
return {
|
|
mood,
|
|
bubble,
|
|
dismiss,
|
|
holdBubble,
|
|
releaseBubble,
|
|
acceptInvite,
|
|
declineInvite,
|
|
setInviteHandler,
|
|
}
|
|
}
|