190 lines
5.0 KiB
TypeScript
190 lines
5.0 KiB
TypeScript
import { atom } from 'nanostores'
|
|
|
|
import { keyedTimeouts } from '@/lib/keyed-timeouts'
|
|
|
|
import { $gateway } from './gateway'
|
|
import { isSessionGone, isSessionGoneForBackgroundPolling, markSessionGone } from './runtime-gone'
|
|
import { ambientRequestFor } from './session-gone-latch'
|
|
import { requestForOwnedSession } from './session-states'
|
|
|
|
export type GoalStatus = 'active' | 'done' | 'paused' | 'waiting'
|
|
|
|
export interface SessionGoal {
|
|
detail?: string
|
|
status: GoalStatus
|
|
title: string
|
|
updatedAt: number
|
|
}
|
|
|
|
export const $goalsBySession = atom<Record<string, SessionGoal>>({})
|
|
|
|
const DONE_LINGER_MS = 8_000
|
|
const clearTimers = keyedTimeouts()
|
|
|
|
export function setSessionGoal(sid: string, goal: SessionGoal) {
|
|
if (!sid) {
|
|
return
|
|
}
|
|
|
|
clearTimers.cancel(sid)
|
|
$goalsBySession.set({ ...$goalsBySession.get(), [sid]: goal })
|
|
|
|
if (goal.status === 'done') {
|
|
clearTimers.schedule(sid, DONE_LINGER_MS, () => clearSessionGoal(sid))
|
|
}
|
|
}
|
|
|
|
export function clearSessionGoal(sid: string) {
|
|
clearTimers.cancel(sid)
|
|
|
|
const map = $goalsBySession.get()
|
|
|
|
if (!(sid in map)) {
|
|
return
|
|
}
|
|
|
|
const { [sid]: _drop, ...rest } = map
|
|
$goalsBySession.set(rest)
|
|
}
|
|
|
|
const clean = (value: string): string => value.replace(/\r/g, '').trim()
|
|
|
|
const firstLine = (value: string): string => clean(value).split('\n')[0]?.trim() ?? ''
|
|
|
|
function goalTitleFromLine(line: string, pattern: RegExp): string {
|
|
return (line.match(pattern)?.[1] ?? '').trim()
|
|
}
|
|
|
|
function nextGoalFromText(text: string, previous?: SessionGoal): SessionGoal | null | undefined {
|
|
const body = clean(text)
|
|
const line = firstLine(body)
|
|
|
|
if (!line) {
|
|
return undefined
|
|
}
|
|
|
|
if (
|
|
/^No active goal\b/i.test(line) ||
|
|
/^No goal (?:set|to resume)\b/i.test(line) ||
|
|
/^✓ Goal cleared\b/i.test(line)
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const now = Date.now()
|
|
const fromSet = goalTitleFromLine(line, /^⊙ Goal set(?:\s*\([^)]*\))?:\s*(.+)$/)
|
|
const fromActive = goalTitleFromLine(line, /^⊙ Goal\s*\([^)]*active[^)]*\):\s*(.+)$/)
|
|
const fromResume = goalTitleFromLine(line, /^▶ Goal resumed:\s*(.+)$/)
|
|
|
|
if (fromSet || fromActive || fromResume) {
|
|
return { status: 'active', title: fromSet || fromActive || fromResume, updatedAt: now }
|
|
}
|
|
|
|
const fromWaiting = goalTitleFromLine(line, /^⏳ Goal\s*\([^)]*(?:parked|active)[^)]*\):\s*(.+)$/)
|
|
|
|
if (fromWaiting) {
|
|
return { status: 'waiting', title: fromWaiting, updatedAt: now }
|
|
}
|
|
|
|
const fromPaused = goalTitleFromLine(line, /^⏸ Goal(?:\s*\([^)]*\)| paused)?:\s*(.+)$/)
|
|
|
|
if (fromPaused) {
|
|
return { status: 'paused', title: fromPaused, updatedAt: now }
|
|
}
|
|
|
|
const fromDone = goalTitleFromLine(line, /^✓ Goal done\s*\([^)]*\):\s*(.+)$/)
|
|
|
|
if (fromDone) {
|
|
return { status: 'done', title: fromDone, updatedAt: now }
|
|
}
|
|
|
|
if (/^↻ Continuing toward goal\b/i.test(line)) {
|
|
return {
|
|
detail: line.replace(/^↻\s*/, ''),
|
|
status: 'active',
|
|
title: previous?.title || 'Standing goal',
|
|
updatedAt: now
|
|
}
|
|
}
|
|
|
|
if (/^⏳ Goal parked\b/i.test(line)) {
|
|
return {
|
|
detail: line.replace(/^⏳\s*/, ''),
|
|
status: 'waiting',
|
|
title: previous?.title || 'Standing goal',
|
|
updatedAt: now
|
|
}
|
|
}
|
|
|
|
if (/^⏸ Goal paused\b/i.test(line)) {
|
|
return {
|
|
detail: line.replace(/^⏸\s*/, ''),
|
|
status: 'paused',
|
|
title: previous?.title || 'Standing goal',
|
|
updatedAt: now
|
|
}
|
|
}
|
|
|
|
if (/^✓ Goal achieved\b/i.test(line)) {
|
|
return {
|
|
detail: line.replace(/^✓\s*/, ''),
|
|
status: 'done',
|
|
title: previous?.title || 'Standing goal',
|
|
updatedAt: now
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
export function applyGoalStatusText(sid: string, text: string, opts?: { hydrate?: boolean }) {
|
|
if (!sid) {
|
|
return
|
|
}
|
|
|
|
const next = nextGoalFromText(text, $goalsBySession.get()[sid])
|
|
|
|
if (next === null) {
|
|
clearSessionGoal(sid)
|
|
} else if (next) {
|
|
// A done goal is terminal state in the backend DB — it stays "done"
|
|
// forever (only /goal clear or a new goal replaces it). The 8s linger is
|
|
// for the LIVE completion moment; re-hydrating "✓ Goal done" on every
|
|
// mount would resurrect the chip indefinitely. Bot Mode is the worst
|
|
// case: one endless session means the completed layover would never go
|
|
// away. On hydration, a terminal goal is the same as no goal.
|
|
if (opts?.hydrate && next.status === 'done') {
|
|
clearSessionGoal(sid)
|
|
|
|
return
|
|
}
|
|
|
|
setSessionGoal(sid, next)
|
|
}
|
|
}
|
|
|
|
export async function refreshSessionGoal(sid: string): Promise<void> {
|
|
const gateway = $gateway.get()
|
|
|
|
if (!sid || !gateway || isSessionGone(sid)) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const result = await requestForOwnedSession<{ output?: string }>(sid, ambientRequestFor(gateway), 'slash.exec', {
|
|
command: 'goal status',
|
|
session_id: sid
|
|
})
|
|
|
|
applyGoalStatusText(sid, result?.output ?? '', { hydrate: true })
|
|
} catch (error) {
|
|
if (isSessionGoneForBackgroundPolling(error)) {
|
|
markSessionGone(sid)
|
|
|
|
return
|
|
}
|
|
|
|
// Best-effort: older gateways or a transport blip simply won't hydrate it.
|
|
}
|
|
}
|