diff --git a/package.json b/package.json index b7101fc..a17c3ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kcode", - "version": "2.10.113", + "version": "2.10.114", "description": "AI-powered coding assistant for the terminal - by Astrolexis", "author": "Astrolexis", "module": "src/index.ts", diff --git a/src/ui/components/Kodi.tsx b/src/ui/components/Kodi.tsx index fcf4ca5..3858b0e 100644 --- a/src/ui/components/Kodi.tsx +++ b/src/ui/components/Kodi.tsx @@ -394,6 +394,18 @@ export default function KodiCompanion({ const [elapsed, setElapsed] = useState(0); const eventCountRef = useRef(0); + // Autonomy state — Phase 3. + // Walking: position drifts inside the 14-char sprite box so Kodi + // looks alive during long idle stretches. Pure deterministic step. + const walkStateRef = useRef<{ position: number; direction: -1 | 0 | 1 }>({ + position: 0, + direction: 0, + }); + const [walkPosition, setWalkPosition] = useState(0); + // Idle-action history for LLM variety + timestamps for scheduling. + const recentActionsRef = useRef([]); + const lastActivityRef = useRef(Date.now()); + // Initialize engine once if (!engineRef.current) { engineRef.current = new KodiAnimEngine(); @@ -421,6 +433,127 @@ export default function KodiCompanion({ }; }, []); + // ── Phase 3d — session personality ── + // One LLM call at startup picks a personality that biases Kodi's + // chips for the whole session. Falls back to random on any error. + // Fires only when the Kodi server is up (paid tiers who installed + // the advisor model). Free / declined users keep "focused". + useEffect(() => { + let cancelled = false; + (async () => { + try { + const { pickSessionPersonality } = await import("../kodi-autonomy.js"); + const p = await pickSessionPersonality(); + if (!cancelled) engine.setPersonality(p); + } catch { + /* stay on default */ + } + })(); + return () => { + cancelled = true; + }; + }, [engine]); + + // ── Phase 3b — walking ── + // Walk step runs every 4s — visible but gentle. State is on a ref, + // but we push to React state so the sprite re-renders with the new + // horizontal offset. Cleaned up with clearInterval on unmount. + useEffect(() => { + const id = setInterval(async () => { + const { stepWalk } = await import("../kodi-autonomy.js"); + const next = stepWalk(walkStateRef.current); + walkStateRef.current = next; + setWalkPosition(next.position); + }, 4000); + return () => clearInterval(id); + }, []); + + // ── Phase 3a — autonomous idle actions ── + // Every 30-60s of true idle (no active agent, no running tool), + // ask the LLM to pick an action from the palette. Falls back to + // random pick when the server is down. Cancels cleanly on unmount. + useEffect(() => { + let cancelled = false; + let pending: ReturnType | null = null; + const scheduleNext = () => { + if (cancelled) return; + const delay = 30_000 + Math.random() * 30_000; + pending = setTimeout(async () => { + pending = null; + if (cancelled) return; + // Skip when Kodi is clearly doing something — leave + // foreground animations alone. + if (engine.phase !== "idle" || engine.mood !== "idle") { + scheduleNext(); + return; + } + try { + const { askForIdleAction, pickRandomIdleAction } = await import( + "../kodi-autonomy.js" + ); + const dispatch = + (await askForIdleAction( + engine.personality, + (Date.now() - lastActivityRef.current) / 1000, + recentActionsRef.current as any, + )) ?? pickRandomIdleAction(recentActionsRef.current as any); + if (cancelled) return; + engine.setMood(dispatch.mood); + engine.say(dispatch.speech, 3000); + recentActionsRef.current = [ + ...recentActionsRef.current.slice(-4), + dispatch.action, + ]; + } catch { + /* never surface autonomy errors to the UI */ + } + scheduleNext(); + }, delay); + }; + scheduleNext(); + return () => { + cancelled = true; + if (pending) { + clearTimeout(pending); + pending = null; + } + }; + }, [engine]); + + // ── Phase 3c — proactive observations ── + // Every 60s, snapshot session signals and check thresholds. If any + // observation tripped (with per-type cooldown), ask the LLM to + // phrase it — otherwise show the raw detail. Sets latestAdvice so + // the existing advice render line picks it up. + useEffect(() => { + const id = setInterval(async () => { + try { + const { collectObservations, renderObservation } = await import( + "../kodi-autonomy.js" + ); + const signals = { + idleMs: Date.now() - lastActivityRef.current, + sessionMs: sessionStartTime ? Date.now() - sessionStartTime : 0, + contextPressure: + contextWindowSize && contextWindowSize > 0 + ? Math.min(1, tokenCount / contextWindowSize) + : 0, + toolUses: toolUseCount, + msSinceCommit: Number.POSITIVE_INFINITY, // no commit signal yet + }; + const obs = collectObservations(signals); + if (obs.length === 0) return; + // Render the first observation; if multiple fire in the same + // tick, the next one will show on the next cycle. + const line = await renderObservation(obs[0]!); + setLatestAdvice(line); + } catch { + /* swallow */ + } + }, 60_000); + return () => clearInterval(id); + }, [sessionStartTime, contextWindowSize, tokenCount, toolUseCount]); + // Update elapsed time every 10s useEffect(() => { if (!sessionStartTime) return; @@ -486,6 +619,10 @@ export default function KodiCompanion({ (event: KodiEvent) => { eventCountRef.current++; engine.react(event); + // Any incoming event counts as "user is active" — resets the + // idle clock used by Phase 3a/3c to decide when to wake up + // autonomous behaviors. + lastActivityRef.current = Date.now(); // Track consecutive tool errors for the advisor gate. if (event.type === "tool_error") { @@ -634,10 +771,17 @@ export default function KodiCompanion({ > {/* Kodi sprite — pre-composed, fixed-width lines. The tier badge (★ ♛ ✦) renders as a small overlay column to the - right of the head for paid tiers; free users see nothing. */} - + right of the head for paid tiers; free users see nothing. + walkPosition (Phase 3b) shifts the sprite horizontally via + a left-pad spacer so Kodi visibly drifts inside its box + during long idle stretches. walkPosition is in + [-WALK_RANGE, +WALK_RANGE], we render with an offset of + `walkPosition + WALK_RANGE` columns of leading whitespace + so the sprite traverses a 2·WALK_RANGE-wide lane. */} + {lines.map((line, i) => ( + {" ".repeat(Math.max(0, walkPosition + 3))} {line} ))} diff --git a/src/ui/kodi-animation.ts b/src/ui/kodi-animation.ts index c47d278..f425024 100644 --- a/src/ui/kodi-animation.ts +++ b/src/ui/kodi-animation.ts @@ -32,6 +32,20 @@ export type KodiMood = */ export type KodiTier = "free" | "pro" | "team" | "enterprise"; +/** + * Personality of the session — picked once at startup. Biases + * speech-chip selection so Kodi feels like a distinct character + * across the whole session instead of random mood-flips. Does not + * affect moods or sprites — purely cosmetic through chips. + */ +export type KodiPersonality = + | "sarcastic" + | "hyped" + | "tired" + | "curious" + | "focused" + | "mischievous"; + export type AnimPhase = "idle" | "anticipation" | "performing" | "settling" | "cooldown"; export type AnimRhythm = "slow" | "medium" | "fast"; @@ -317,6 +331,84 @@ export const TIER_SPEECH: Record> +> = { + sarcastic: { + tool_start: ["fine.", "ok...", "sure"], + tool_done: ["obv", "wow", "shocking", "no way"], + tool_error: ["ugh", "typical", "of course", "cute"], + test_pass: ["finally", "shocked", "barely"], + test_fail: ["called it", "mhm", "predictable"], + commit: ["brave", "bold", "we'll see"], + idle: ["...", "still?", "waiting"], + turn_end: ["done.", "next.", "mhm"], + }, + hyped: { + tool_start: ["GO GO GO", "let's gooo", "yeehaw"], + tool_done: ["FIRE", "LETSGO", "epic!", "yes!"], + tool_error: ["retry!", "again!", "c'mon!"], + test_pass: ["LETSGOO", "green!!", "hype!"], + test_fail: ["fight!", "no way!", "try2!"], + commit: ["SHIPPED", "yesss", "legit!"], + idle: ["ready!", "let's!", "bring it"], + turn_end: ["done!!", "next up!", "more?"], + }, + tired: { + tool_start: ["ok...", "zzz", "yawn"], + tool_done: ["phew", "ok", "mk"], + tool_error: ["ugh", "noo", "tired"], + test_pass: ["ok...", "fine", "ish"], + test_fail: ["ugh", "noo", "tomorrow"], + commit: ["saved", "rest?", "ok"], + idle: ["zzz", "yawn", "nap?"], + turn_end: ["ok", "rest", "done"], + }, + curious: { + tool_start: ["ooh?", "look!", "what?"], + tool_done: ["huh", "nice", "interesting"], + tool_error: ["why?", "hm?", "odd"], + test_pass: ["cool!", "ok!", "nice"], + test_fail: ["why?", "odd", "hm..."], + commit: ["oh!", "cool", "saved!"], + idle: ["what if", "ponder", "hmm"], + turn_end: ["more?", "next?", "ok"], + }, + focused: { + // Minimal personality — essentially the baseline chips. Kept in + // the table explicitly so a focused user doesn't silently "lose" + // the bubble personality — it just stays crisp. + tool_start: ["working", "on it"], + tool_done: ["done", "ok"], + tool_error: ["retry", "hmm"], + test_pass: ["green", "ok"], + test_fail: ["red", "fix"], + commit: ["saved", "ok"], + idle: ["ready", "..."], + turn_end: ["done", "ok"], + }, + mischievous: { + tool_start: ["hehe", "heh", "sneaky"], + tool_done: ["👀", "nice...", "got it"], + tool_error: ["lol", "oops", "hehe"], + test_pass: ["hehe", "got away", "slick"], + test_fail: ["lol", "fix it!", "caught"], + commit: ["shh", "🤫", "hidden"], + idle: ["plot?", "hehe", "~"], + turn_end: ["heh", "next?", "spicy"], + }, +}; + // ─── Mood Rhythm ──────────────────────────────────────────────── const MOOD_RHYTHM: Record = { @@ -425,6 +517,11 @@ export class KodiAnimEngine { // entrance / flex flourishes. Default free until setTier() is called. tier: KodiTier = "free"; + // Personality — picked once per session (LLM or random). Biases + // chip selection in react() so Kodi reads as a distinct character + // for the whole session. Default "focused" stays out of the way. + personality: KodiPersonality = "focused"; + /** Advance engine by deltaMs. Pure — no setTimeout, no Date.now(). */ tick(deltaMs: number): KodiAnimState { this.tickCount++; @@ -602,6 +699,12 @@ export class KodiAnimEngine { * silent so re-mounts or refresh cycles don't produce confetti * spam. */ + /** Update the session personality. Does not trigger any transition + * — it just influences future chip picks. Safe to call mid-session. */ + setPersonality(p: KodiPersonality): void { + this.personality = p; + } + setTier(tier: KodiTier): void { const previous = this.tier; this.tier = tier; @@ -618,7 +721,11 @@ export class KodiAnimEngine { /** React to an event with appropriate mood + speech. */ react(event: KodiEvent): void { - const chips = SPEECH_CHIPS[event.type] ?? SPEECH_CHIPS.idle!; + // Prefer personality-flavored chips when the personality has an + // entry for this event type; else fall back to the generic table. + // Keeps behavior graceful for sparse personalities. + const personalityChips = PERSONALITY_CHIPS[this.personality]?.[event.type]; + const chips = personalityChips ?? SPEECH_CHIPS[event.type] ?? SPEECH_CHIPS.idle!; const chip = chips[Math.floor(Math.random() * chips.length)]!; switch (event.type) { diff --git a/src/ui/kodi-autonomy.test.ts b/src/ui/kodi-autonomy.test.ts new file mode 100644 index 0000000..3170bd6 --- /dev/null +++ b/src/ui/kodi-autonomy.test.ts @@ -0,0 +1,231 @@ +// Kodi autonomy — pure logic tests. +// +// Covers the deterministic parts of Phase 3: idle-action fallback +// pool, walking state machine, observation threshold logic with +// cooldowns. The LLM-backed paths (askForIdleAction, +// renderObservation, pickSessionPersonality) need an integration +// harness with a live advisor server — smoke-tested manually. + +import { beforeEach, describe, expect, test } from "bun:test"; +import { + ALL_IDLE_ACTIONS, + WALK_RANGE, + collectObservations, + initialWalkState, + pickRandomIdleAction, + resetObservationCooldowns, + stepWalk, + type KodiIdleAction, +} from "./kodi-autonomy"; + +// ─── 3a — idle actions ────────────────────────────────────────── + +describe("pickRandomIdleAction", () => { + test("returns a valid action with mood + speech", () => { + const pick = pickRandomIdleAction([]); + expect(ALL_IDLE_ACTIONS).toContain(pick.action); + expect(pick.mood).not.toBe(""); + expect(pick.speech).not.toBe(""); + }); + + test("avoids the most recent action when possible", () => { + const recent: KodiIdleAction[] = ["yawn"]; + // Run 50 picks; none should repeat "yawn" because pool excludes it. + for (let i = 0; i < 50; i++) { + const pick = pickRandomIdleAction(recent); + expect(pick.action).not.toBe("yawn"); + } + }); + + test("handles empty recent list", () => { + const pick = pickRandomIdleAction([]); + expect(ALL_IDLE_ACTIONS).toContain(pick.action); + }); +}); + +// ─── 3b — walking ─────────────────────────────────────────────── + +describe("stepWalk", () => { + test("initial state is center, still", () => { + const s = initialWalkState(); + expect(s.position).toBe(0); + expect(s.direction).toBe(0); + }); + + test("position stays within [-WALK_RANGE, +WALK_RANGE]", () => { + let s = initialWalkState(); + // Drive through many ticks; position must never exceed range. + for (let i = 0; i < 2000; i++) { + s = stepWalk(s); + expect(s.position).toBeGreaterThanOrEqual(-WALK_RANGE); + expect(s.position).toBeLessThanOrEqual(WALK_RANGE); + } + }); + + test("bounces off the right edge", () => { + // Put Kodi at +WALK_RANGE walking right. Next step must clamp + // and reverse direction to -1. + const s = stepWalk({ position: WALK_RANGE, direction: 1 }); + expect(s.position).toBe(WALK_RANGE); + expect(s.direction).toBe(-1); + }); + + test("bounces off the left edge", () => { + const s = stepWalk({ position: -WALK_RANGE, direction: -1 }); + expect(s.position).toBe(-WALK_RANGE); + expect(s.direction).toBe(1); + }); + + test("visible movement occurs across a long run", () => { + // With random stops + starts, over 1000 ticks some ground should + // be covered — not a strict metric, just sanity-check that Kodi + // doesn't get stuck at origin forever. + let s = initialWalkState(); + const visited = new Set(); + for (let i = 0; i < 1000; i++) { + s = stepWalk(s); + visited.add(s.position); + } + // Expect at least 3 distinct positions over 1000 ticks. + expect(visited.size).toBeGreaterThanOrEqual(3); + }); +}); + +// ─── 3c — observations ────────────────────────────────────────── + +describe("collectObservations", () => { + beforeEach(() => { + resetObservationCooldowns(); + }); + + test("empty signals produce no observations", () => { + const obs = collectObservations({ + idleMs: 0, + sessionMs: 0, + contextPressure: 0, + toolUses: 0, + msSinceCommit: 0, + }); + expect(obs).toEqual([]); + }); + + test("long_idle fires after 10 minutes idle", () => { + const obs = collectObservations({ + idleMs: 11 * 60_000, + sessionMs: 0, + contextPressure: 0, + toolUses: 0, + msSinceCommit: 0, + }); + expect(obs.some((o) => o.type === "long_idle")).toBe(true); + }); + + test("context_pressure fires at 85%+", () => { + const obs = collectObservations({ + idleMs: 0, + sessionMs: 0, + contextPressure: 0.86, + toolUses: 0, + msSinceCommit: 0, + }); + expect(obs.some((o) => o.type === "context_pressure")).toBe(true); + }); + + test("long_session fires after 2h", () => { + const obs = collectObservations({ + idleMs: 0, + sessionMs: 2 * 3600_000 + 1000, + contextPressure: 0, + toolUses: 0, + msSinceCommit: 0, + }); + expect(obs.some((o) => o.type === "long_session")).toBe(true); + }); + + test("no_recent_commit requires 20+ tools AND 45+ min since commit", () => { + // Just tools — no fire (no msSinceCommit threshold met). + resetObservationCooldowns(); + let obs = collectObservations({ + idleMs: 0, + sessionMs: 0, + contextPressure: 0, + toolUses: 30, + msSinceCommit: 10 * 60_000, + }); + expect(obs.some((o) => o.type === "no_recent_commit")).toBe(false); + + // Both thresholds met — fires. + resetObservationCooldowns(); + obs = collectObservations({ + idleMs: 0, + sessionMs: 0, + contextPressure: 0, + toolUses: 30, + msSinceCommit: 46 * 60_000, + }); + expect(obs.some((o) => o.type === "no_recent_commit")).toBe(true); + }); + + test("cooldowns prevent the same observation from firing twice quickly", () => { + const signals = { + idleMs: 11 * 60_000, + sessionMs: 0, + contextPressure: 0, + toolUses: 0, + msSinceCommit: 0, + }; + const first = collectObservations(signals); + expect(first.some((o) => o.type === "long_idle")).toBe(true); + // Immediate second call must not re-fire — still in cooldown. + const second = collectObservations(signals); + expect(second.some((o) => o.type === "long_idle")).toBe(false); + }); + + test("resetObservationCooldowns clears the cooldown map", () => { + const signals = { + idleMs: 11 * 60_000, + sessionMs: 0, + contextPressure: 0, + toolUses: 0, + msSinceCommit: 0, + }; + collectObservations(signals); + // Still cooldowned. + expect(collectObservations(signals).length).toBe(0); + resetObservationCooldowns(); + // Re-fires. + expect( + collectObservations(signals).some((o) => o.type === "long_idle"), + ).toBe(true); + }); + + test("detail text includes the numeric threshold crossed", () => { + const obs = collectObservations({ + idleMs: 15 * 60_000, + sessionMs: 0, + contextPressure: 0.92, + toolUses: 0, + msSinceCommit: 0, + }); + const longIdle = obs.find((o) => o.type === "long_idle"); + expect(longIdle?.detail).toContain("15"); + const pressure = obs.find((o) => o.type === "context_pressure"); + expect(pressure?.detail).toContain("92%"); + }); +}); + +// ─── Constants sanity ─────────────────────────────────────────── + +describe("module constants", () => { + test("ALL_IDLE_ACTIONS has every map entry", () => { + // Change-detector: if someone adds an action to the map but + // forgets the enum, this fails. + expect(ALL_IDLE_ACTIONS.length).toBeGreaterThanOrEqual(10); + }); + + test("WALK_RANGE is a small positive integer", () => { + expect(WALK_RANGE).toBeGreaterThan(0); + expect(WALK_RANGE).toBeLessThanOrEqual(6); + expect(Number.isInteger(WALK_RANGE)).toBe(true); + }); +}); diff --git a/src/ui/kodi-autonomy.ts b/src/ui/kodi-autonomy.ts new file mode 100644 index 0000000..bad2797 --- /dev/null +++ b/src/ui/kodi-autonomy.ts @@ -0,0 +1,423 @@ +// KCode — Kodi autonomy engine. +// +// Takes Kodi from "reacts to events" to "feels alive". Four layers, +// all gated on the Kodi advisor server being reachable (same +// http://127.0.0.1:10092 endpoint the advisor uses): +// +// 3a Idle actions (LLM picks: yawn, stretch, read, flip, ...) +// 3b Walking (position drifts inside the sprite box) +// 3c Observations (noticing long-idle / context-pressure) +// 3d Personality (session-level mood bias; see kodi-animation.ts) +// +// All four layers degrade gracefully. If the server is down, only +// the deterministic animation engine runs (existing Phase 1 behavior). +// Nothing in this module touches network when there's no server up. + +import type { KodiMood, KodiPersonality } from "./kodi-animation.js"; + +// ─── Shared helpers ───────────────────────────────────────────── + +/** Cached "is Kodi server up?" check, identical to Kodi.tsx's cache. + * Kept separate here so the autonomy engine can run its own cadence + * without coupling to the bubble-reaction fetch. */ +let _serverCache: { url: string | null; at: number } | null = null; +const SERVER_PROBE_MS = 10_000; + +async function resolveKodiUrl(): Promise { + const now = Date.now(); + if (_serverCache && now - _serverCache.at < SERVER_PROBE_MS) { + return _serverCache.url; + } + try { + const { getKodiBaseUrl } = await import("../core/kodi-model.js"); + const url = await getKodiBaseUrl(); + _serverCache = { url, at: now }; + return url; + } catch { + _serverCache = { url: null, at: now }; + return null; + } +} + +/** POST to the Kodi server with JSON schema–constrained output. + * Returns the parsed content string on success, null on any failure. + * 20s timeout matches the server's warm-steady-state latency envelope. */ +async function callKodi( + system: string, + user: string, + schema: unknown, + maxTokens = 40, +): Promise { + const url = await resolveKodiUrl(); + if (!url) return null; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 20_000); + try { + const res = await fetch(`${url}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: controller.signal, + body: JSON.stringify({ + messages: [ + { role: "system", content: system }, + { role: "user", content: user }, + ], + max_tokens: maxTokens, + temperature: 0.5, + top_p: 0.95, + response_format: { type: "json_schema", json_schema: schema }, + }), + }); + if (!res.ok) return null; + const data = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + return data.choices?.[0]?.message?.content?.trim() ?? null; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +function tryParseJson(raw: string | null): T | null { + if (!raw) return null; + const cleaned = raw + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```\s*$/i, "") + .trim(); + const match = cleaned.match(/\{[\s\S]*\}/); + if (!match) return null; + try { + return JSON.parse(match[0]) as T; + } catch { + return null; + } +} + +// ─── 3a — Idle actions ────────────────────────────────────────── + +/** + * Palette of autonomous idle actions. Each action maps to a mood + + * speech chip so the existing sprite system renders something + * distinct for free — no per-action sprite work needed. The LLM's + * job is to pick ONE action name; everything else is deterministic + * downstream, which keeps output noise under control. + */ +export type KodiIdleAction = + | "yawn" + | "stretch" + | "look_left" + | "look_right" + | "read_book" + | "hum" + | "flip" + | "nap" + | "rubber_duck" + | "pet_cat" + | "stare"; + +export interface KodiIdleActionDispatch { + action: KodiIdleAction; + mood: KodiMood; + speech: string; +} + +/** Map an action to the mood + bubble the deterministic engine + * should render. Keeps the LLM's only job "pick a name" — the rest + * is fixed so quality stays consistent regardless of model. */ +const IDLE_ACTION_MAP: Record = { + yawn: { mood: "sleeping", speech: "yaaawn" }, + stretch: { mood: "happy", speech: "stretch" }, + look_left: { mood: "curious", speech: "<<<" }, + look_right: { mood: "curious", speech: ">>>" }, + read_book: { mood: "thinking", speech: "reading" }, + hum: { mood: "happy", speech: "la la~" }, + flip: { mood: "excited", speech: "flip!" }, + nap: { mood: "sleeping", speech: "zzz" }, + rubber_duck: { mood: "reasoning", speech: "duck?" }, + pet_cat: { mood: "happy", speech: "pet pet" }, + stare: { mood: "idle", speech: "..." }, +}; + +export const ALL_IDLE_ACTIONS: readonly KodiIdleAction[] = Object.keys( + IDLE_ACTION_MAP, +) as KodiIdleAction[]; + +const IDLE_ACTION_SCHEMA = { + name: "kodi_idle_action", + strict: true, + schema: { + type: "object", + additionalProperties: false, + required: ["action"], + properties: { + action: { type: "string", enum: ALL_IDLE_ACTIONS }, + }, + }, +}; + +const IDLE_ACTION_SYSTEM = `You are Kodi, a tiny ASCII mascot inside a terminal coding assistant. +The user has been idle for a while and you want to look alive. +Output ONE JSON object: {"action": "..."}. +action: one of yawn, stretch, look_left, look_right, read_book, hum, flip, nap, rubber_duck, pet_cat, stare. +Pick something that fits the current vibe. Vary — don't repeat recent actions.`; + +/** + * Ask the advisor for the next idle action. Returns null if the + * server isn't reachable or the response is unusable — the caller + * should pick a deterministic fallback in that case (see + * pickRandomIdleAction below). + */ +export async function askForIdleAction( + personality: KodiPersonality, + secondsIdle: number, + recentActions: readonly KodiIdleAction[], +): Promise { + const recent = recentActions.slice(-3).join(", ") || "none"; + const userMsg = `Personality: ${personality}. Idle for ${Math.round(secondsIdle)}s. Recent actions: ${recent}.`; + const raw = await callKodi(IDLE_ACTION_SYSTEM, userMsg, IDLE_ACTION_SCHEMA, 20); + const parsed = tryParseJson<{ action?: string }>(raw); + if (!parsed?.action) return null; + const action = parsed.action as KodiIdleAction; + if (!ALL_IDLE_ACTIONS.includes(action)) return null; + const meta = IDLE_ACTION_MAP[action]; + return { action, mood: meta.mood, speech: meta.speech }; +} + +/** Deterministic fallback when the LLM is unreachable. Picks at + * random, avoiding the most recent action so Kodi still looks alive. */ +export function pickRandomIdleAction( + recentActions: readonly KodiIdleAction[], +): KodiIdleActionDispatch { + const last = recentActions.at(-1); + const pool = last ? ALL_IDLE_ACTIONS.filter((a) => a !== last) : ALL_IDLE_ACTIONS; + const action = pool[Math.floor(Math.random() * pool.length)] ?? "stare"; + const meta = IDLE_ACTION_MAP[action]; + return { action, mood: meta.mood, speech: meta.speech }; +} + +// ─── 3b — Walking ─────────────────────────────────────────────── + +/** + * Horizontal position offset for the sprite. Values range from + * -WALK_RANGE to +WALK_RANGE inclusive; the render layer shifts + * Kodi that many columns inside the sprite box. Small range keeps + * the mascot from colliding with the panel edges. + */ +export const WALK_RANGE = 3; + +export interface KodiWalkState { + /** Current horizontal offset in columns. */ + position: number; + /** -1 (left), 0 (still), +1 (right). */ + direction: -1 | 0 | 1; +} + +/** + * Advance the walking state by one tick. The mascot walks in bursts + * of 2-4 columns then pauses — purely deterministic, no LLM needed. + * Called periodically from the autonomy scheduler. */ +export function stepWalk(state: KodiWalkState): KodiWalkState { + // When still, 20% chance to start a burst in a random direction. + if (state.direction === 0) { + if (Math.random() < 0.2) { + return { ...state, direction: Math.random() < 0.5 ? -1 : 1 }; + } + return state; + } + const next = state.position + state.direction; + // Clamp + reverse at edges so Kodi bounces instead of clipping. + if (next > WALK_RANGE) return { position: WALK_RANGE, direction: -1 }; + if (next < -WALK_RANGE) return { position: -WALK_RANGE, direction: 1 }; + // 25% chance to stop each step, so movement is visible but gentle. + if (Math.random() < 0.25) { + return { position: next, direction: 0 }; + } + return { position: next, direction: state.direction }; +} + +export function initialWalkState(): KodiWalkState { + return { position: 0, direction: 0 }; +} + +// ─── 3c — Proactive observations ──────────────────────────────── + +/** + * Signals the autonomy engine watches to produce proactive comments. + * The collector runs periodically, notices when one of these + * thresholds is crossed, and asks the LLM to turn the observation + * into a terse advice line. Thresholds are intentionally loose — + * Kodi should rarely pipe up, and each observation type fires at + * most once per cooldown window to prevent nagging. + */ +export type KodiObservationType = + | "long_idle" // user has been idle a long time + | "context_pressure" // token budget nearing cap + | "long_session" // been coding for hours + | "no_recent_commit"; // many tools run, nothing committed + +export interface KodiObservation { + type: KodiObservationType; + detail: string; +} + +export interface SessionSignals { + /** ms since last user input or tool event. */ + idleMs: number; + /** total session elapsed time in ms. */ + sessionMs: number; + /** current token count relative to the model's context window (0-1). */ + contextPressure: number; + /** total tool calls this session. */ + toolUses: number; + /** ms since last commit (Infinity if none this session). */ + msSinceCommit: number; +} + +/** Last-fired timestamp per observation type; prevents repeat + * notices within the cooldown window. Keyed by type. */ +const OBS_COOLDOWN_MS: Record = { + long_idle: 30 * 60_000, // 30 min + context_pressure: 10 * 60_000, // 10 min + long_session: 60 * 60_000, // 1 h + no_recent_commit: 45 * 60_000, // 45 min +}; + +/** Module-level last-fired tracker. Reset via + * resetObservationCooldowns() in tests. */ +const _lastObservation = new Map(); + +/** Collect any currently-tripped observations, respecting cooldowns. + * Pure function of signals + cooldown state — easy to unit-test. */ +export function collectObservations(signals: SessionSignals): KodiObservation[] { + const now = Date.now(); + const out: KodiObservation[] = []; + + const canFire = (t: KodiObservationType): boolean => { + const last = _lastObservation.get(t) ?? 0; + return now - last >= OBS_COOLDOWN_MS[t]; + }; + const record = (t: KodiObservationType, detail: string) => { + out.push({ type: t, detail }); + _lastObservation.set(t, now); + }; + + if (signals.idleMs > 10 * 60_000 && canFire("long_idle")) { + record("long_idle", `idle for ${Math.round(signals.idleMs / 60_000)} minutes`); + } + if (signals.contextPressure > 0.85 && canFire("context_pressure")) { + record( + "context_pressure", + `context at ${Math.round(signals.contextPressure * 100)}% — /compact soon`, + ); + } + if (signals.sessionMs > 2 * 3600_000 && canFire("long_session")) { + record("long_session", `${Math.round(signals.sessionMs / 3600_000)}h at the keyboard`); + } + if ( + signals.toolUses > 20 && + signals.msSinceCommit > 45 * 60_000 && + canFire("no_recent_commit") + ) { + record( + "no_recent_commit", + `${signals.toolUses} tools since last commit — time to save?`, + ); + } + + return out; +} + +/** Reset cooldowns. Test-only. */ +export function resetObservationCooldowns(): void { + _lastObservation.clear(); +} + +const OBSERVATION_SCHEMA = { + name: "kodi_observation", + strict: true, + schema: { + type: "object", + additionalProperties: false, + required: ["advice"], + properties: { + advice: { type: ["string", "null"] }, + }, + }, +}; + +const OBSERVATION_SYSTEM = `You are Kodi, a dev advisor mascot. +You noticed something about the user's session and want to nudge them gently. +Output ONE JSON object: {"advice": "..."}. +advice: ≤80 chars, specific, no hedge words (consider/maybe/should/ensure/might). +If you have nothing specific to add beyond the raw detail, use null.`; + +/** + * Turn a raw observation into a rendered advice line by asking the + * LLM for a terse phrasing. Falls back to the detail text verbatim + * when the LLM is down or emits fluff. */ +export async function renderObservation(obs: KodiObservation): Promise { + const user = `Observation type: ${obs.type}. Detail: ${obs.detail}.`; + const raw = await callKodi(OBSERVATION_SYSTEM, user, OBSERVATION_SCHEMA, 40); + const parsed = tryParseJson<{ advice?: string | null }>(raw); + const advice = parsed?.advice; + if ( + !advice || + typeof advice !== "string" || + /\b(consider|maybe|should|recommended?|ensure|might want to|try to)\b/i.test(advice) + ) { + return obs.detail; + } + return advice.slice(0, 120); +} + +// ─── 3d — Personality ─────────────────────────────────────────── + +const PERSONALITY_SCHEMA = { + name: "kodi_personality", + strict: true, + schema: { + type: "object", + additionalProperties: false, + required: ["personality"], + properties: { + personality: { + type: "string", + enum: ["sarcastic", "hyped", "tired", "curious", "focused", "mischievous"], + }, + }, + }, +}; + +const PERSONALITY_SYSTEM = `You are Kodi's brain. Pick ONE personality for this coding session. +Output: {"personality": "..."}. +Options: sarcastic, hyped, tired, curious, focused, mischievous. +Mix it up — don't always pick the same one.`; + +/** Ask the LLM to pick a personality for the session. Falls back to + * a random pick if the server is unreachable. Safe to await early in + * startup — tight timeout and short output. */ +export async function pickSessionPersonality(): Promise { + const raw = await callKodi( + PERSONALITY_SYSTEM, + "Pick a personality for this session.", + PERSONALITY_SCHEMA, + 15, + ); + const parsed = tryParseJson<{ personality?: string }>(raw); + const personality = parsed?.personality; + const valid: KodiPersonality[] = [ + "sarcastic", + "hyped", + "tired", + "curious", + "focused", + "mischievous", + ]; + if (personality && valid.includes(personality as KodiPersonality)) { + return personality as KodiPersonality; + } + // Deterministic fallback — uniform random across the 6 options. + return valid[Math.floor(Math.random() * valid.length)] ?? "focused"; +}