Companion: rules-based, context-aware writing advice

Replace the kitten's random generic tips with a deterministic, LLM-free
prose checker that reads the actual document and surfaces one gentle,
bilingual note at a time — quoting a slice of her own sentence so the
advice is clearly about *this* draft.

Rules (all conservative, tuned for a Mandarin-native ESL writer):
run-ons, comma splices, unclear antecedents, Oxford comma, a/an,
uncountable plurals, capitalize languages/days/months, subject-verb
agreement, plural-after-number, double determiner, "there is" + plural,
its/it's, than/then, comma-after-transition, doubled words, lowercase
"i", sentence capitals, and spacing around punctuation.

Each rule is paired with false-positive guards (e.g. "a university",
"After dinner, I went home and read", existing Oxford lists) and pinned
by a 45-case Vitest suite (npm test).

Also: bubbles now linger proportional to how much there is to read
(zh+en) and pause-on-hover, so denser advice no longer vanishes mid-read.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 07:16:23 -07:00
parent f3c4fe2c22
commit 69ecb79da1
8 changed files with 1280 additions and 14 deletions

View File

@@ -11,6 +11,7 @@ import {
pick,
type Line,
} from './tips'
import { analyzeProse } from './prose'
// The kitten's expression. Maps to a Lottie animation when assets are present,
// otherwise to an emoji placeholder (see PetalCompanion).
@@ -28,6 +29,9 @@ interface Signals {
// 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
}
// Timing knobs (ms). Tuned to feel present but never naggy.
@@ -35,14 +39,29 @@ 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 BUBBLE_MS = 6_500 // how long a bubble lingers
const CHEER_MS = 4_500 // shorter for quick cheers
// 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 = 9_000 // baseline for a tip / break
const CHEER_MS = 6_000 // a short cheer still needs a beat in two languages
const READ_MS_PER_CHAR = 45 // ~22 chars/sec, generous for a bilingual read
const MAX_BUBBLE_MS = 20_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 Mandarin + 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 : BUBBLE_MS
const chars = b.zh.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, editTick, acceptTick }: Signals) {
export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text }: Signals) {
const [mood, setMood] = useState<Mood>('idle')
const [bubble, setBubble] = useState<Bubble | null>(null)
@@ -54,6 +73,15 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
const nextMilestone = useRef(0) // index into MILESTONES
const sleeping = useRef(false)
// 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)
@@ -71,11 +99,30 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
setMood(opts?.celebrate ? 'celebrate' : 'talking')
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
const dur = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS
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 => {
const hints = analyzeProse(textRef.current)
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 { zh: hint.zh, en: hint.en, tone: 'tip' }
}
return { ...pick(TIPS), tone: 'tip' }
}, [])
const dismiss = useCallback(() => {
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
@@ -83,6 +130,21 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
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)
@@ -161,14 +223,15 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
return
}
// Otherwise an occasional tip.
// 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({ ...pick(TIPS), tone: 'tip' }, { proactive: true })
say(nextTip(), { proactive: true })
}
}, 10_000)
return () => clearInterval(id)
}, [bubble, say])
}, [bubble, say, nextTip])
useEffect(
() => () => {
@@ -178,5 +241,5 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
[],
)
return { mood, bubble, dismiss }
return { mood, bubble, dismiss, holdBubble, releaseBubble }
}