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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user