Files
AgentHub/client/src/components/Sagi/brain.ts
T
nntrivi2001 0cb5e41faf feat(pet): cleared alerts stay cleared, contextual bubbles, i18n, moments, chat window
- Fix: an errored session alerted on every update, and Clear alerts only
  dropped it in memory, so its next update (or a reload) brought the alert
  straight back. An error now alerts on the transition into error only;
  cleared sessions are remembered (agenthub.pet.dismissedErrors) and stay
  quiet until they recover and fail anew.
- Quips, greetings, notifications and Ask answers move to i18n (EN/VI);
  Ask understands Vietnamese keywords and names the sessions it counts.
- The pet goes by the worn pet's name; click it in the panel to rename
  (agenthub.pet.names, synced to the account).
- Bubbles name the session they are about, with an Open button (never a
  resume); a burst of the same event coalesces into one count.
- Moments: a new session waves, a finish hops, a subagent peeks, a wait taps,
  an error flinches — on Sagi, Codex sprites and still images. Idle fidgets,
  a once-per-session greeting, a "Needs you" list, an amber waiting dot.
- Pat (heart button, Ask, or stroking the pet) reacts on the pet itself, so
  it shows while the panel or chat is open.
- Panel drops Run Claude / Activity / Sessions / Errored.
- Ask hands off to a chat window (PetChat) on the default AI tool
  (Claude/Codex): transcript as bubbles, follow-ups and CLI menus through an
  off-screen attached terminal; no new server route.

Verified on HEAD + this diff in a clean worktree: tsc -b, the full client
suite (1334/1335; the one failure, ConversationView.follow, is flaky under
load and passes 3/3 alone) and server me-prefs.test.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:14:29 +07:00

502 lines
20 KiB
TypeScript

/**
* @file brain.ts
* @description Pure, framework-free core of the Sagi companion. Reduces the
* dashboard's live WebSocket stream into a small mood model and derives the
* current mood from that model plus the wall clock. Kept side-effect free
* so it can be unit-tested without React, timers, or the DOM. The React hook
* (`useSagiBrain`) wires this to the event bus and to real timers.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `../../lib/types`
*
* ## Public surface
* - `Mood` — exported API; see TSDoc on the symbol for behavior.
* - `SagiPulse` — exported API; see TSDoc on the symbol for behavior.
* - `SagiStatus` — exported API; see TSDoc on the symbol for behavior.
* - `SagiState` — exported API; see TSDoc on the symbol for behavior.
* - `HAPPY_MS` — exported API; see TSDoc on the symbol for behavior.
* - `WORRIED_MS` — exported API; see TSDoc on the symbol for behavior.
* - `STUCK_MS` — exported API; see TSDoc on the symbol for behavior.
* - `SLEEP_MS` — exported API; see TSDoc on the symbol for behavior.
* - `FAILURE_EVENT_TYPES` — exported API; see TSDoc on the symbol for behavior.
* - `initialSagiState` — exported API; see TSDoc on the symbol for behavior.
* - `statusOf` — exported API; see TSDoc on the symbol for behavior.
* - `deriveMood` — exported API; see TSDoc on the symbol for behavior.
* - `reduceSagi` — exported API; see TSDoc on the symbol for behavior.
* - `seedSessions` — exported API; see TSDoc on the symbol for behavior.
* - `clearErrors` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **Mood**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **SagiPulse**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **SagiStatus**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **SagiState**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **HAPPY_MS**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **WORRIED_MS**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **STUCK_MS**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **SLEEP_MS**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **FAILURE_EVENT_TYPES**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **initialSagiState**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **statusOf**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **deriveMood**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **reduceSagi**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **seedSessions**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **clearErrors**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import type { WSMessage, Session, Agent, RunStatusPayload, DashboardEvent } from "../../lib/types";
/** All moods Sagi can express, highest priority first (see `deriveMood`). */
export type Mood =
"disconnected" | "worried" | "stuck" | "happy" | "thinking" | "watching" | "sleeping" | "idle";
/**
* A one-shot signal describing what just happened, emitted by `reduceSagi`.
* The hook turns pulses into transient speech bubbles. `null` means the message
* was irrelevant or non-notable.
*/
export type SagiPulse =
"session_done" | "session_start" | "subagent_spawn" | "waiting" | "error" | "run_done" | null;
/** A short animation the pet plays on an event or a pat, over its mood. */
export type PetMoment = "wave" | "jump" | "fail" | "wait" | "review";
/** Which moment each pulse plays: greet a new session, hop at a finish,
* inspect a new subagent, tap a foot while you are awaited, flinch at an error. */
export const MOMENT_OF_PULSE: Record<NonNullable<SagiPulse>, PetMoment> = {
session_start: "wave",
session_done: "jump",
run_done: "jump",
subagent_spawn: "review",
waiting: "wait",
error: "fail",
};
export interface SagiStatus {
/** Active + waiting sessions (everything not finished/errored). */
liveCount: number;
/** Subset of liveCount currently blocked on user input. */
waitingCount: number;
errorCount: number;
connected: boolean;
}
export interface SagiState {
connected: boolean;
/** Latest status per session id we still care about. "waiting" = active but
* blocked on user input; counts as live for the status line. */
sessions: Record<string, "active" | "error" | "waiting">;
/** Epoch ms of the last meaningful activity; drives stuck/sleeping. */
lastActivityAt: number;
/** While `now < happyUntil`, mood can be `happy`. */
happyUntil: number;
/** While `now < worriedUntil`, mood can be `worried`. */
worriedUntil: number;
/** True while an Ask request is in flight (panel). */
thinking: boolean;
/** Errored sessions the user cleared ("clear alerts"). Their further
* updates, agent errors and failure events stay silent until the session
* goes active again or ends — otherwise every update of a still-errored
* session re-raised the alert the user had just dismissed. */
dismissed: Record<string, true>;
/** Name and owning workspace of each tracked session, so a bubble or the
* panel can say WHICH session it means and open it. */
info: Record<string, SessionInfo>;
}
/** What Sagi knows about a session beyond its status. */
export interface SessionInfo {
name: string | null;
cwd: string | null;
lane_id: number | null;
}
/** The session a pulse is about, when it is about one. */
export interface SagiSubject extends SessionInfo {
id: string;
}
/** A session that needs the user: errored, or waiting on their input. */
export interface AttentionItem extends SagiSubject {
kind: "error" | "waiting";
}
const infoOf = (s: {
name?: string | null;
cwd?: string | null;
lane_id?: number | null;
}): SessionInfo => ({ name: s.name ?? null, cwd: s.cwd ?? null, lane_id: s.lane_id ?? null });
/** A session's label for a bubble: its name, else its folder, else a short id. */
export function subjectLabel(s: SagiSubject): string {
if (s.name && s.name.trim()) return s.name.trim();
const base = s.cwd
?.replace(/[\\/]+$/, "")
.split(/[\\/]/)
.pop();
return base || s.id.slice(0, 8);
}
// Tunable timing constants (ms).
export const HAPPY_MS = 4000;
export const WORRIED_MS = 4500;
export const STUCK_MS = 10 * 60_000;
export const SLEEP_MS = 3 * 60_000;
/** Event types from the hook ingestion that represent a genuine failure. */
export const FAILURE_EVENT_TYPES: ReadonlySet<string> = new Set([
"error",
"toolError",
"agentError",
"subagentError",
"errorEvent",
"errorReport",
"errorBoundary",
"crashReport",
"diagnosticError",
]);
export function initialSagiState(now: number): SagiState {
return {
connected: true,
sessions: {},
lastActivityAt: now,
happyUntil: 0,
worriedUntil: 0,
thinking: false,
dismissed: {},
info: {},
};
}
export function statusOf(state: SagiState): SagiStatus {
let liveCount = 0;
let waitingCount = 0;
let errorCount = 0;
for (const s of Object.values(state.sessions)) {
if (s === "active" || s === "waiting") {
liveCount++;
if (s === "waiting") waitingCount++;
} else if (s === "error") errorCount++;
}
return { liveCount, waitingCount, errorCount, connected: state.connected };
}
/**
* Pure mood resolver. Highest-priority matching state wins. `now` is injected
* so callers (and tests) control the clock; transient windows (happy/worried)
* and inactivity windows (stuck/sleeping) are evaluated against it.
*/
export function deriveMood(state: SagiState, now: number): Mood {
if (!state.connected) return "disconnected";
if (now < state.worriedUntil) return "worried";
const { liveCount } = statusOf(state);
const silent = now - state.lastActivityAt;
if (liveCount > 0 && silent > STUCK_MS) return "stuck";
if (now < state.happyUntil) return "happy";
if (state.thinking) return "thinking";
if (liveCount > 0) return "watching";
if (silent > SLEEP_MS) return "sleeping";
return "idle";
}
/**
* Fold a single WebSocket message into the Sagi state. Returns the next state
* (new object) and a one-shot pulse describing what happened. Unknown or
* irrelevant message types pass through unchanged with a `null` pulse.
*/
export function reduceSagi(
state: SagiState,
msg: WSMessage,
now: number
): { state: SagiState; pulse: SagiPulse; subject?: SagiSubject } {
// The tracked session an agent/event message belongs to, when known.
const known = (id: string | undefined): SagiSubject | undefined =>
id ? { id, ...(state.info[id] ?? { name: null, cwd: null, lane_id: null }) } : undefined;
switch (msg.type) {
case "session_created":
case "session_updated": {
const s = msg.data as Session;
if (!s || !s.id) return { state, pulse: null };
const sessions = { ...state.sessions };
const info = { ...state.info, [s.id]: infoOf(s) };
const subject: SagiSubject = { id: s.id, ...info[s.id]! };
let dismissed = state.dismissed;
let pulse: SagiPulse = null;
let happyUntil = state.happyUntil;
let worriedUntil = state.worriedUntil;
// Anything but a (still) errored status ends a dismissal: a later error
// of that session is a new one and must alert again.
if (s.status !== "error" && s.id in dismissed) {
dismissed = { ...dismissed };
delete dismissed[s.id];
}
if (s.status === "active") {
// "waiting" = active session blocked on user input (permission prompt
// or sitting at a fresh prompt). Announce the transition once each way.
const isWaiting = !!s.awaiting_input_since;
const prev = sessions[s.id];
if (isWaiting) {
sessions[s.id] = "waiting";
if (prev !== "waiting") pulse = "waiting";
} else {
sessions[s.id] = "active";
if (prev === undefined) pulse = "session_start";
}
} else if (s.status === "error") {
if (s.id in dismissed) {
return { state: { ...state, lastActivityAt: now }, pulse: null };
}
// Alert on the transition INTO error only; a session that stays
// errored keeps getting updates and must not re-alert on each one.
if (sessions[s.id] !== "error") {
worriedUntil = now + WORRIED_MS;
pulse = "error";
}
sessions[s.id] = "error";
} else if (s.status === "completed" || s.status === "abandoned") {
const wasTracked = s.id in sessions;
delete sessions[s.id];
delete info[s.id];
if (s.status === "completed") {
happyUntil = now + HAPPY_MS;
if (wasTracked) pulse = "session_done";
}
}
return {
state: {
...state,
sessions,
dismissed,
info,
happyUntil,
worriedUntil,
lastActivityAt: now,
},
pulse,
subject,
};
}
case "agent_created": {
const a = msg.data as Agent;
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
return {
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
pulse: "error",
subject: known(a.session_id),
};
}
// A freshly spawned subagent is worth announcing; the main agent landing
// is already covered by session_start.
const pulse: SagiPulse = a && a.type === "subagent" ? "subagent_spawn" : null;
return {
state: { ...state, lastActivityAt: now },
pulse,
subject: pulse ? known(a.session_id) : undefined,
};
}
case "agent_updated": {
const a = msg.data as Agent;
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
return {
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
pulse: "error",
subject: known(a.session_id),
};
}
return { state: { ...state, lastActivityAt: now }, pulse: null };
}
case "new_event": {
const e = msg.data as DashboardEvent;
const isFailure =
!!e && FAILURE_EVENT_TYPES.has(e.event_type) && !(e.session_id in state.dismissed);
return {
state: {
...state,
lastActivityAt: now,
worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil,
},
pulse: isFailure ? "error" : null,
subject: isFailure ? known(e.session_id) : undefined,
};
}
case "run_status": {
const r = msg.data as RunStatusPayload;
if (!r) return { state, pulse: null };
// running / gone → activity only (no exit code to distinguish success/failure).
return { state: { ...state, lastActivityAt: now }, pulse: null };
}
case "run_stream":
// Streaming output counts as activity but is not itself notable.
return { state: { ...state, lastActivityAt: now }, pulse: null };
default:
return { state, pulse: null };
}
}
/**
* Hydrate session tracking from a REST snapshot (the same data the dashboard
* fetches on load). Without this, the brain only learns about sessions from
* live WS deltas that arrive *after* it mounts, so a freshly-loaded page shows
* "0 live" even when sessions already exist. Merges in non-finished sessions;
* never clears the error window. Live WS deltas continue to refine this.
*/
export function seedSessions(
state: SagiState,
rows: ReadonlyArray<{
id: string;
status: string;
awaiting_input_since?: string | null;
name?: string | null;
cwd?: string | null;
lane_id?: number | null;
}>,
now: number
): SagiState {
const sessions = { ...state.sessions };
const info = { ...state.info };
for (const r of rows) {
if (!r || !r.id) continue;
if (r.status === "error") {
if (r.id in state.dismissed) continue;
sessions[r.id] = "error";
} else if (r.status === "active") {
sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
} else continue; // completed / abandoned: leave untracked.
info[r.id] = infoOf(r);
}
return { ...state, sessions, info, lastActivityAt: now };
}
/** Errored sessions first, then the ones waiting on the user — the panel's list. */
export function attentionOf(state: SagiState): AttentionItem[] {
const out: AttentionItem[] = [];
for (const kind of ["error", "waiting"] as const) {
for (const [id, s] of Object.entries(state.sessions)) {
if (s !== kind) continue;
out.push({
id,
kind,
...(state.info[id] ?? { name: null, cwd: null, lane_id: null }),
});
}
}
return out;
}
/** Drop all errored sessions from tracking (used by "clear alerts") and
* remember them as dismissed, so their next update does not bring the alert
* straight back. */
export function clearErrors(state: SagiState): SagiState {
const sessions: SagiState["sessions"] = {};
const dismissed = { ...state.dismissed };
const info = { ...state.info };
for (const [id, s] of Object.entries(state.sessions)) {
if (s !== "error") sessions[id] = s;
else {
dismissed[id] = true;
delete info[id];
}
}
return { ...state, sessions, dismissed, info, worriedUntil: 0 };
}