feat: file-backed goal objectives + compaction accounting fixes

Compaction fixes (observed in a real long run):
- the summary message's zeroed tokens froze the goal counter at its
  pre-compaction value; segments now close with the previously displayed
  total as a continuity floor
- audits and continuations after a summary tail now take execution params
  (provider/model/agent/variant) from the newest non-summary assistant
  turn instead of inheriting agent 'compaction' and the summarize model

File-backed objectives:
- the objective text lives in <data-dir>/goals/<sessionId>.md, keyed by
  session id (one goal per session, a new goal overwrites the file);
  metadata carries only an objectiveFile flag so session.updated fanout
  stays light, and never a path — ids are pattern-validated before any
  filesystem access
- limit raised to 5000 chars, no snapshot field: the UI fetches content
  via PUT/GET/DELETE /api/goals/objective/:sessionId (behind the blanket
  /api auth gate), writes the file before stamping metadata, and falls
  back to an inline objective when the write fails
- the loop reads the file fresh on every tick, so objectives are
  live-editable mid-goal; a missing file falls back to the inline text
- scheduled goal tasks write the objective file server-side; VS Code
  degrades to the audit note (route unavailable there by design)
This commit is contained in:
Bohdan Triapitsyn
2026-07-12 02:49:56 +03:00
parent 2c4b40893c
commit c9ac8676e7
12 changed files with 288 additions and 19 deletions
+6 -2
View File
@@ -9,11 +9,13 @@ export type SessionGoalStatus = 'active' | 'paused' | 'blocked' | 'budgetLimited
const SESSION_GOAL_STATUSES: SessionGoalStatus[] = ['active', 'paused', 'blocked', 'budgetLimited', 'complete'];
export const SESSION_GOAL_OBJECTIVE_CHAR_LIMIT = 2000;
export const SESSION_GOAL_OBJECTIVE_CHAR_LIMIT = 5000;
export interface SessionGoalPayload {
id: string;
objective: string;
/** True when the objective text lives in a server-side file keyed by session id. */
objectiveFile: boolean;
status: SessionGoalStatus;
tokenBudget: number | null;
tokensUsed: number;
@@ -42,7 +44,8 @@ export function getSessionGoal(session: Session | null | undefined): SessionGoal
const id = typeof goal.id === 'string' ? goal.id : '';
const objective = typeof goal.objective === 'string' ? goal.objective.trim() : '';
if (!id || !objective || !isGoalStatus(goal.status)) return null;
const objectiveFile = goal.objectiveFile === true;
if (!id || (!objective && !objectiveFile) || !isGoalStatus(goal.status)) return null;
const tokenBudget = typeof goal.tokenBudget === 'number' && Number.isFinite(goal.tokenBudget) && goal.tokenBudget > 0
? Math.floor(goal.tokenBudget)
@@ -53,6 +56,7 @@ export function getSessionGoal(session: Session | null | undefined): SessionGoal
return {
id,
objective: objective.slice(0, SESSION_GOAL_OBJECTIVE_CHAR_LIMIT),
objectiveFile,
status: goal.status,
tokenBudget,
tokensUsed: asCount(goal.tokensUsed),