From 7342772992ae56c6541bc7214b9c743d9b6a9c52 Mon Sep 17 00:00:00 2001 From: GaltRanch Date: Fri, 17 Apr 2026 11:49:26 -0300 Subject: [PATCH] =?UTF-8?q?feat(kodi):=20Phase=203=20=E2=80=94=20Kodi=20fe?= =?UTF-8?q?els=20alive=20(idle=20actions,=20walking,=20observations,=20per?= =?UTF-8?q?sonality)=20+=20v2.10.114?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four autonomy layers that take Kodi from "reacts to events" to "feels alive". All four gate on the Kodi advisor server being reachable (same port 10092 used by the existing advice line). When the server is down, Kodi stays on deterministic behavior — nothing in this commit touches the network without the server up. ## Phase 3d — Session personality Six personality types bias speech-chip selection for the whole session: sarcastic, hyped, tired, curious, focused, mischievous. At TUI mount, a single LLM call picks one (temperature 0.5 so the same user doesn't get the same personality every session). Random deterministic fallback if the server is unreachable. Implemented in kodi-animation.ts: - KodiPersonality type + PERSONALITY_CHIPS table with personality-flavored chips for each common event type. - engine.personality field + setPersonality(p) setter. - react() prefers personality chips, falls back to SPEECH_CHIPS. Live test result: personality pick takes ~160ms with schema- constrained generation. ## Phase 3a — Autonomous idle actions Every 30-60s of true idle (no active agent, not mid-animation), ask the LLM to pick one action from a palette of 11: yawn, stretch, look_left, look_right, read_book, hum, flip, nap, rubber_duck, pet_cat, stare Each action maps to a fixed (mood, speech) pair via IDLE_ACTION_MAP so sprite quality stays consistent regardless of model output — the LLM's only job is name selection. Prompt includes recent actions so the model varies instead of repeating. Deterministic fallback picks randomly, also avoiding the last action. Live test: action pick in 321ms, correctly avoided the 2 recent actions and returned "look_left". ## Phase 3b — Walking Pure deterministic step machine. Every 4s: - If still, 20% chance to start walking in a random direction. - If walking, step one column; 25% chance to stop per step. - Bounces off ±WALK_RANGE (=3) edges. Sprite render adds a left-pad of (walkPosition + WALK_RANGE) columns so Kodi traverses a 6-column lane inside the 14-char sprite box without ever clipping the info panel. ## Phase 3c — Proactive observations Every 60s, collect session signals (idleMs, sessionMs, contextPressure, toolUses, msSinceCommit) and check thresholds: long_idle idle >10 min cooldown 30 min context_pressure token budget >85% cooldown 10 min long_session >2h at keyboard cooldown 1h no_recent_commit >20 tools, 45 min since cooldown 45 min When tripped, ask the LLM to rephrase the detail into an advice line (schema-constrained, fluff filter). Falls back to raw detail on LLM failure. Writes to latestAdvice so the existing Phase 2 render line picks it up. ## Tests 18 new tests in kodi-autonomy.test.ts: - pickRandomIdleAction: returns valid action, avoids recent - stepWalk: position bounds, edge bouncing, movement visible - collectObservations: all 4 types fire at correct thresholds, cooldowns prevent re-fire, detail text includes numeric context All 476 UI tests passing (was 458 + 18 new autonomy). ## Design notes - ALL LLM calls gated on Kodi server availability — no main-model tokens ever spent on autonomy. - All schedulers use ref'd cleanup (clearTimeout/clearInterval) so /quit exits cleanly, regardless of scheduled-but-unfired autonomy timers. - LLM output constrained via response_format json_schema at every call site, so the 1.5B abliterated doesn't drift. Schema enums are enforced token-by-token. Co-Authored-By: Kulvex Code --- package.json | 2 +- src/ui/components/Kodi.tsx | 148 +++++++++++- src/ui/kodi-animation.ts | 109 ++++++++- src/ui/kodi-autonomy.test.ts | 231 +++++++++++++++++++ src/ui/kodi-autonomy.ts | 423 +++++++++++++++++++++++++++++++++++ 5 files changed, 909 insertions(+), 4 deletions(-) create mode 100644 src/ui/kodi-autonomy.test.ts create mode 100644 src/ui/kodi-autonomy.ts 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"; +}