Files
openchamber/packages/ui/src/lib/sessionGoalActions.ts
T

125 lines
4.1 KiB
TypeScript
Raw Normal View History

import { abortCurrentOperation, patchSessionMetadata } from '@/sync/session-actions';
import {
SESSION_GOAL_OBJECTIVE_CHAR_LIMIT,
type SessionGoalPayload,
type SessionGoalStatus,
} from '@/lib/sessionGoalMetadata';
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const createGoalId = (): string =>
`${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
const writeGoal = (
sessionId: string,
directory: string | undefined,
update: (currentGoal: Record<string, unknown> | null) => Record<string, unknown> | null,
) =>
patchSessionMetadata(sessionId, directory, (metadata) => {
const namespace = isRecord(metadata.openchamber) ? metadata.openchamber : {};
const currentGoal = isRecord(namespace.goal) ? namespace.goal : null;
const nextGoal = update(currentGoal);
const nextNamespace = { ...namespace };
if (nextGoal) {
nextNamespace.goal = nextGoal;
} else {
delete nextNamespace.goal;
}
return { ...metadata, openchamber: nextNamespace };
});
export interface SetSessionGoalInput {
objective: string;
tokenBudget: number | null;
}
/**
* Create a new goal (fresh id resets accounting) or edit the existing one
* (id and usage counters preserved).
*/
export async function setSessionGoal(
sessionId: string,
directory: string | undefined,
input: SetSessionGoalInput,
existing: SessionGoalPayload | null,
): Promise<void> {
const objective = input.objective.trim().slice(0, SESSION_GOAL_OBJECTIVE_CHAR_LIMIT);
if (!objective) {
throw new Error('Goal objective must not be empty');
}
const tokenBudget = typeof input.tokenBudget === 'number' && Number.isFinite(input.tokenBudget) && input.tokenBudget > 0
? Math.floor(input.tokenBudget)
: null;
const now = Date.now();
await writeGoal(sessionId, directory, (currentGoal) => {
if (existing && currentGoal && currentGoal.id === existing.id && existing.status !== 'complete') {
// Edit in place: keep accounting, reactivate, clear stale audit state.
return {
...currentGoal,
objective,
tokenBudget,
status: 'active',
statusReason: 'resumed',
blockedStreak: 0,
updatedAt: now,
};
}
return {
id: createGoalId(),
objective,
status: 'active',
tokenBudget,
tokensUsed: 0,
turnsUsed: 0,
blockedStreak: 0,
note: '',
statusReason: '',
lastAccountedMessageID: '',
createdAt: now,
updatedAt: now,
};
});
}
export async function setSessionGoalStatus(
sessionId: string,
directory: string | undefined,
status: Extract<SessionGoalStatus, 'active' | 'paused' | 'complete'>,
): Promise<void> {
// Pausing a goal also stops the agent's current turn — same mental model
// as the stop button, expressed through goal control. A no-op when the
// session is already idle.
if (status === 'paused') {
void abortCurrentOperation(sessionId);
}
await writeGoal(sessionId, directory, (currentGoal) => {
if (!currentGoal) return null;
return {
...currentGoal,
status,
// 'resumed' is the server's kickoff signal for an already-idle session.
statusReason: status === 'active' ? 'resumed' : (status === 'complete' ? 'marked by user' : ''),
blockedStreak: 0,
// An explicit resume grants a fresh auto-continuation allowance —
// otherwise a goal blocked on the turn cap would re-block on the very
// next tick and Resume would be a dead end.
...(status === 'active' ? { turnsUsed: 0 } : {}),
updatedAt: Date.now(),
};
});
}
export async function clearSessionGoal(sessionId: string, directory: string | undefined): Promise<void> {
let wasActive = false;
await writeGoal(sessionId, directory, (currentGoal) => {
wasActive = currentGoal?.status === 'active';
return null;
});
// Removing a running goal is a "stop" too — abort the current turn like
// pause does. A no-op when the session is idle.
if (wasActive) {
void abortCurrentOperation(sessionId);
}
}