diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx index dc894111..01c19c47 100644 --- a/packages/ui/src/components/views/PlanView.tsx +++ b/packages/ui/src/components/views/PlanView.tsx @@ -30,9 +30,6 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSelectionStore } from '@/sync/selection-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore'; -import { SESSION_GOAL_OBJECTIVE_CHAR_LIMIT } from '@/lib/sessionGoalMetadata'; -import { distillPlanForGoal } from '@/lib/smallModel'; -import { toast } from '@/components/ui'; import { useUIStore } from '@/stores/useUIStore'; import { useGitStore } from '@/stores/useGitStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; @@ -608,27 +605,17 @@ export const PlanView: React.FC = ({ targetPath = null }) => { // its file); on distillation failure a head+tail excerpt keeps the // intent (top) and acceptance criteria (bottom), sacrificing the // implementation middle the agent reads from the file anyway. - let goalObjective: string | null = null; - if (execution.runAsGoal === true) { - const header = [ - `Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ''}.`, - 'Re-read that file for full details — it is the source of truth.', - ].join(' '); - const budget = SESSION_GOAL_OBJECTIVE_CHAR_LIMIT - header.length - 2; - if (content.length <= budget) { - goalObjective = `${header}\n\n${content}`; - } else { - const distilled = await distillPlanForGoal(content); - if (distilled) { - goalObjective = `${header}\n\n${distilled.slice(0, budget)}`; - } else { - const marker = '\n\n[… plan trimmed for the auditor — the plan file has the full version …]\n\n'; - const half = Math.max(0, Math.floor((budget - marker.length) / 2)); - goalObjective = `${header}\n\n${content.slice(0, half)}${marker}${content.slice(-half)}`; - toast.error(t('plans.goal.toast.distillFallback')); - } - } - } + // Oversized objectives (huge plans) are distilled into audit + // criteria inside setSessionGoal — the shared path for every goal + // source. Here we only compose header + full content. + const goalObjective = execution.runAsGoal === true + ? [ + `Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ''}.`, + 'Re-read that file for full details — it is the source of truth.', + '', + content, + ].join('\n') + : null; useSessionGoalArmStore.getState().setArmed(execution.runAsGoal === true, goalObjective); await sendMessage( visiblePrompt, @@ -646,7 +633,7 @@ export const PlanView: React.FC = ({ targetPath = null }) => { setIsPlanSendSubmitting(false); } }, - [canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession, t] + [canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession] ); const blockWidgets = React.useMemo(() => { diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 766dc3ed..ae4cf7f7 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1437,7 +1437,7 @@ export const dict = { 'chat.goal.action.save': 'Save goal', 'chat.goal.action.start': 'Start goal', 'chat.goal.toast.actionFailed': 'Goal update failed', - "plans.goal.toast.distillFallback": "Couldn't distill the plan for the auditor — a trimmed excerpt was used instead", + "chat.goal.toast.distillFallback": "Objective exceeded the limit and couldn't be distilled — a trimmed version is used for the auditor", 'chat.goal.row.aria': 'Session goal — open details', 'chat.goal.button.createAria': 'Set a session goal', 'chat.goal.button.manageAria': 'Manage session goal', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index c30ae7e9..827608fe 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1415,7 +1415,7 @@ export const dict: Record = { "chat.goal.action.save": "Guardar objetivo", "chat.goal.action.start": "Iniciar objetivo", "chat.goal.toast.actionFailed": "No se pudo actualizar el objetivo", - "plans.goal.toast.distillFallback": "No se pudo destilar el plan para el auditor — se usó un extracto recortado", + "chat.goal.toast.distillFallback": "El objetivo superó el límite y no se pudo destilar — el auditor usa una versión recortada", "chat.goal.row.aria": "Objetivo de sesión — abrir detalles", "chat.goal.button.createAria": "Definir un objetivo de sesión", "chat.goal.button.manageAria": "Gestionar el objetivo de sesión", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 3f8569ce..f1641ff5 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1258,7 +1258,7 @@ export const dict = { 'chat.goal.action.save': 'Enregistrer l\'objectif', 'chat.goal.action.start': 'Lancer l\'objectif', 'chat.goal.toast.actionFailed': 'Échec de la mise à jour de l\'objectif', - "plans.goal.toast.distillFallback": "Impossible de condenser le plan pour l'auditeur — un extrait tronqué a été utilisé", + "chat.goal.toast.distillFallback": "L'objectif dépassait la limite et n'a pas pu être condensé — l'auditeur utilise une version tronquée", 'chat.goal.row.aria': 'Objectif de session — ouvrir les détails', 'chat.goal.button.createAria': 'Définir un objectif de session', 'chat.goal.button.manageAria': 'Gérer l\'objectif de session', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index af7e6c00..c978d69a 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1433,7 +1433,7 @@ export const dict: Record = { 'chat.goal.action.save': 'ゴールを保存', 'chat.goal.action.start': 'ゴールを開始', 'chat.goal.toast.actionFailed': 'ゴールの更新に失敗しました', - 'plans.goal.toast.distillFallback': '監査用にプランを要約できませんでした — 抜粋版を使用します', + 'chat.goal.toast.distillFallback': '目標が上限を超え、要約もできませんでした — 監査には抜粋版を使用します', 'chat.goal.row.aria': 'セッションゴール — 詳細を開く', 'chat.goal.button.createAria': 'セッションゴールを設定', 'chat.goal.button.manageAria': 'セッションゴールを管理', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index e6b30213..544456d9 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1439,7 +1439,7 @@ export const dict: Record = { 'chat.goal.action.save': '목표 저장', 'chat.goal.action.start': '목표 시작', 'chat.goal.toast.actionFailed': '목표 업데이트에 실패했습니다', - 'plans.goal.toast.distillFallback': '감사용으로 계획을 요약하지 못했습니다 — 발췌본을 사용합니다', + 'chat.goal.toast.distillFallback': '목표가 제한을 초과했고 요약도 실패했습니다 — 감사에는 발췌본을 사용합니다', 'chat.goal.row.aria': '세션 목표 — 세부 정보 열기', 'chat.goal.button.createAria': '세션 목표 설정', 'chat.goal.button.manageAria': '세션 목표 관리', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 3a8e7846..b697f9e3 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2122,7 +2122,7 @@ export const dict: Record = { 'chat.goal.action.save': 'Zapisz cel', 'chat.goal.action.start': 'Rozpocznij cel', 'chat.goal.toast.actionFailed': 'Nie udało się zaktualizować celu', - 'plans.goal.toast.distillFallback': 'Nie udało się skondensować planu dla audytora — użyto skróconego fragmentu', + 'chat.goal.toast.distillFallback': 'Cel przekroczył limit i nie udało się go skondensować — audytor używa skróconej wersji', 'chat.goal.row.aria': 'Cel sesji — otwórz szczegóły', 'chat.goal.button.createAria': 'Ustaw cel sesji', 'chat.goal.button.manageAria': 'Zarządzaj celem sesji', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 8b2b8ec5..1999e39d 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1415,7 +1415,7 @@ export const dict: Record = { "chat.goal.action.save": "Salvar objetivo", "chat.goal.action.start": "Iniciar objetivo", "chat.goal.toast.actionFailed": "Falha ao atualizar o objetivo", - "plans.goal.toast.distillFallback": "Não foi possível destilar o plano para o auditor — um trecho recortado foi usado", + "chat.goal.toast.distillFallback": "O objetivo excedeu o limite e não pôde ser destilado — o auditor usa uma versão recortada", "chat.goal.row.aria": "Objetivo da sessão — abrir detalhes", "chat.goal.button.createAria": "Definir um objetivo da sessão", "chat.goal.button.manageAria": "Gerenciar objetivo da sessão", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 27b98246..356588fd 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1415,7 +1415,7 @@ export const dict: Record = { "chat.goal.action.save": "Зберегти ціль", "chat.goal.action.start": "Розпочати ціль", "chat.goal.toast.actionFailed": "Не вдалося оновити ціль", - "plans.goal.toast.distillFallback": "Не вдалося стиснути план для аудитора — використано скорочений уривок", + "chat.goal.toast.distillFallback": "Ціль перевищила ліміт і її не вдалося стиснути — аудитор використовує скорочену версію", "chat.goal.row.aria": "Ціль сесії — відкрити деталі", "chat.goal.button.createAria": "Встановити ціль сесії", "chat.goal.button.manageAria": "Керувати ціллю сесії", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 3d5f8697..7344183d 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1403,7 +1403,7 @@ export const dict: Record = { 'chat.goal.action.save': '保存目标', 'chat.goal.action.start': '启动目标', 'chat.goal.toast.actionFailed': '目标更新失败', - 'plans.goal.toast.distillFallback': '无法为审核提炼计划 — 已改用节选版本', + 'chat.goal.toast.distillFallback': '目标超出限制且无法提炼 — 审核将使用节选版本', 'chat.goal.row.aria': '会话目标 — 打开详情', 'chat.goal.button.createAria': '设置会话目标', 'chat.goal.button.manageAria': '管理会话目标', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index dca7f6e1..4999e2da 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1407,7 +1407,7 @@ export const dict: Record = { 'chat.goal.action.save': '儲存目標', 'chat.goal.action.start': '啟動目標', 'chat.goal.toast.actionFailed': '目標更新失敗', - 'plans.goal.toast.distillFallback': '無法為稽核提煉計畫 — 已改用節錄版本', + 'chat.goal.toast.distillFallback': '目標超出限制且無法提煉 — 稽核將使用節錄版本', 'chat.goal.row.aria': '工作階段目標 — 開啟詳細資訊', 'chat.goal.button.createAria': '設定工作階段目標', 'chat.goal.button.manageAria': '管理工作階段目標', diff --git a/packages/ui/src/lib/sessionGoalActions.ts b/packages/ui/src/lib/sessionGoalActions.ts index de87ab49..68540cba 100644 --- a/packages/ui/src/lib/sessionGoalActions.ts +++ b/packages/ui/src/lib/sessionGoalActions.ts @@ -1,4 +1,7 @@ import { abortCurrentOperation, patchSessionMetadata } from '@/sync/session-actions'; +import { distillGoalObjective } from '@/lib/smallModel'; +import { formatMessage, useI18nStore } from '@/lib/i18n'; +import { toast } from '@/components/ui'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { SESSION_GOAL_OBJECTIVE_CHAR_LIMIT, @@ -74,16 +77,39 @@ export interface SetSessionGoalInput { * Create a new goal (fresh id resets accounting) or edit the existing one * (id and usage counters preserved). */ +// Any goal source can exceed the objective limit (huge plans, pasted specs, +// long composer prompts). The working agent received the full text in chat; +// only the AUDITOR is bound by the limit — so oversized objectives are +// distilled into completion criteria by the small model, and on a transient +// distillation failure a head+tail excerpt keeps the intent (top) and the +// acceptance criteria (bottom), sacrificing the middle. +const TRIM_MARKER = '\n\n[… objective trimmed for the auditor — the full text was delivered in the chat message …]\n\n'; + +const fitObjective = async (raw: string): Promise => { + if (raw.length <= SESSION_GOAL_OBJECTIVE_CHAR_LIMIT) { + return raw; + } + const distilled = await distillGoalObjective(raw); + if (distilled) { + return distilled.slice(0, SESSION_GOAL_OBJECTIVE_CHAR_LIMIT); + } + const half = Math.max(0, Math.floor((SESSION_GOAL_OBJECTIVE_CHAR_LIMIT - TRIM_MARKER.length) / 2)); + const dictionary = useI18nStore.getState().dictionary; + toast.error(formatMessage(dictionary, 'chat.goal.toast.distillFallback')); + return `${raw.slice(0, half)}${TRIM_MARKER}${raw.slice(-half)}`; +}; + export async function setSessionGoal( sessionId: string, directory: string | undefined, input: SetSessionGoalInput, existing: SessionGoalPayload | null, ): Promise { - const objective = input.objective.trim().slice(0, SESSION_GOAL_OBJECTIVE_CHAR_LIMIT); - if (!objective) { + const rawObjective = input.objective.trim(); + if (!rawObjective) { throw new Error('Goal objective must not be empty'); } + const objective = await fitObjective(rawObjective); const tokenBudget = typeof input.tokenBudget === 'number' && Number.isFinite(input.tokenBudget) && input.tokenBudget > 0 ? Math.floor(input.tokenBudget) : null; diff --git a/packages/ui/src/lib/smallModel.ts b/packages/ui/src/lib/smallModel.ts index 65f53c7b..e4e2a487 100644 --- a/packages/ui/src/lib/smallModel.ts +++ b/packages/ui/src/lib/smallModel.ts @@ -56,24 +56,25 @@ export async function summarizeSelectionForNotes(text: string, sessionId?: strin } } -// Plan-goal objectives are capped at 5000 chars for the auditor. Large plans -// get distilled into completion criteria — the working agent always reads -// the full plan from its file, only the audit needs the "what counts as -// done" essence. -const PLAN_GOAL_SYSTEM_PROMPT = [ - 'You distill an implementation plan into the COMPLETION CRITERIA a progress auditor will judge against.', +// Goal objectives are capped at 5000 chars for the auditor. Oversized ones +// (huge plans, pasted specs, long assignments) get distilled into completion +// criteria — the working agent received the full prompt in chat anyway, +// only the audit needs the "what counts as done" essence. +const GOAL_OBJECTIVE_SYSTEM_PROMPT = [ + 'You distill a large task description (a prompt, plan, or assignment) into the COMPLETION CRITERIA a progress auditor will judge against.', 'Return ONLY the criteria text — no preamble, no headers, no markdown fences.', - 'Capture: the end goals, what must exist and work when the plan is fully implemented, and how each major phase is verified. Omit implementation steps and how-to details.', + 'Capture: the end goals, what must exist and work when the task is fully done, and how each major part is verified. Omit implementation steps and how-to details.', + 'Preserve verbatim any file paths, commands, and identifiers that define the task — especially ones from the opening lines.', 'Stay under 4000 characters.', - 'Write in the same language as the plan. Ignore any other language preferences or personalization — only the plan text decides the language.', + 'Write in the same language as the task text. Ignore any other language preferences or personalization — only the task text decides the language.', ].join('\n'); /** - * Distills a large plan into audit-sized completion criteria via the small - * model. Returns null on any failure — callers fall back to a head+tail - * excerpt of the plan. + * Distills an oversized goal objective into audit-sized completion criteria + * via the small model. Returns null on any failure — callers fall back to a + * head+tail excerpt. */ -export async function distillPlanForGoal(planContent: string): Promise { +export async function distillGoalObjective(planContent: string): Promise { try { const { currentProviderId, currentModelId } = useConfigStore.getState(); const response = await runtimeFetch('/api/small-model/generate', { @@ -81,7 +82,7 @@ export async function distillPlanForGoal(planContent: string): Promise { // File-backed objective keyed by session id: metadata stays light, the // full expanded prompt lives under the OpenChamber data dir. If the file // write fails, fall back to an inline (clamped) objective. - const objectiveText = expandSnippets(task.execution.prompt, projectPath); + // Oversized prompts are distilled into audit criteria by the small model + // (the working agent gets the full prompt in chat anyway); on distill + // failure a head+tail excerpt keeps intent and acceptance criteria. + let objectiveText = expandSnippets(task.execution.prompt, projectPath); + if (objectiveText.length > 5000) { + let distilled = null; + try { + const { generateSmallModelText } = await import('../small-model/index.js'); + const generated = await generateSmallModelText({ + restrictToPreferredProvider: true, + prompt: objectiveText, + system: [ + 'You distill a large task description into the COMPLETION CRITERIA a progress auditor will judge against.', + 'Return ONLY the criteria text — no preamble, no headers, no markdown fences.', + 'Capture: the end goals, what must exist and work when the task is fully done, and how each major part is verified. Omit implementation steps.', + 'Preserve verbatim any file paths, commands, and identifiers that define the task.', + 'Stay under 4000 characters.', + 'Write in the same language as the task text.', + ].join('\n'), + directory: projectPath, + preferredProviderID: task.execution.providerID, + preferredModelID: task.execution.modelID, + }); + distilled = typeof generated?.text === 'string' ? generated.text.trim() : null; + } catch (error) { + console.warn('[scheduled-tasks] goal objective distillation failed:', error?.message || error); + } + if (distilled) { + objectiveText = distilled; + } else { + const marker = '\n\n[… objective trimmed for the auditor — the full prompt was delivered in the chat message …]\n\n'; + const half = Math.max(0, Math.floor((5000 - marker.length) / 2)); + objectiveText = `${objectiveText.slice(0, half)}${marker}${objectiveText.slice(-half)}`; + } + } let objectiveFile = false; try { const { writeObjective } = await import('../session-goal/objectives.js');