cc-haha/src/goals/goalState.ts
程序员阿江(Relakkes) 174c6757f2 Drop prelaunch goal transcript compatibility
The /goal feature has not shipped, so the desktop and CLI paths should only understand the current set/clear protocol instead of carrying transitional transcript formats. This removes Goal created/replaced and old status-block parsing from hydration, WebSocket forwarding, and desktop history mapping.

Constraint: Feature is still pre-release, so historical Goal created/replaced transcript compatibility is unnecessary.

Rejected: Keep old status-block hydration | it preserves a surface that users never received and makes the simplified /goal protocol harder to reason about.

Confidence: high

Scope-risk: narrow

Directive: Do not re-add legacy /goal status-block parsing unless a shipped transcript format requires it.

Tested: bun test src/commands/goal/goal.test.tsx src/goals/goalState.test.ts src/goals/goalEvaluator.test.ts src/server/__tests__/ws-memory-events.test.ts src/server/__tests__/sessions.test.ts src/server/__tests__/conversations.test.ts

Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts src/components/chat/MessageList.test.tsx src/pages/ActiveSession.test.tsx src/components/chat/composerUtils.test.ts src/__tests__/pages.test.tsx
2026-05-16 12:39:27 +08:00

281 lines
7.8 KiB
TypeScript

import { randomUUID } from 'crypto'
import {
COMMAND_NAME_TAG,
LOCAL_COMMAND_STDOUT_TAG,
} from '../constants/xml.js'
import type { Message } from '../types/message.js'
export type ThreadGoalStatus = 'active' | 'paused' | 'complete' | 'budget_limited'
export type ThreadGoal = {
goalId: string
threadId: string
objective: string
status: ThreadGoalStatus
tokenBudget: number | null
tokensUsed: number
continuationCount: number
lastReason: string | null
createdAt: number
updatedAt: number
}
export type ParsedGoalCommand =
| { type: 'clear' }
| { type: 'set'; objective: string }
const goalsByThread = new Map<string, ThreadGoal>()
export function parseGoalCommand(args: string): ParsedGoalCommand {
const trimmed = args.trim()
if (!trimmed) throw new Error('Usage: /goal <condition> | clear')
if (trimmed === 'clear') return { type: 'clear' }
return { type: 'set', objective: trimmed }
}
export function setThreadGoal(
threadId: string,
input: {
objective: string
tokenBudget?: number | null
now?: number
},
): ThreadGoal {
const now = input.now ?? Date.now()
const goal: ThreadGoal = {
goalId: randomUUID(),
threadId,
objective: input.objective.trim(),
status: 'active',
tokenBudget: input.tokenBudget ?? null,
tokensUsed: 0,
continuationCount: 0,
lastReason: null,
createdAt: now,
updatedAt: now,
}
goalsByThread.set(threadId, goal)
return goal
}
export function getThreadGoal(threadId: string): ThreadGoal | null {
return goalsByThread.get(threadId) ?? null
}
export function hydrateThreadGoalFromMessages(
threadId: string,
messages: Message[],
now = Date.now(),
): ThreadGoal | null {
if (goalsByThread.has(threadId)) return goalsByThread.get(threadId) ?? null
let pendingGoalCommand = false
let restored: ThreadGoal | null = null
for (const message of messages) {
const text = messageToText(message)
if (!text) continue
const commandName = readXmlTag(text, COMMAND_NAME_TAG)
if (commandName) {
pendingGoalCommand = commandName.replace(/^\//, '') === 'goal'
continue
}
const output = readXmlTag(text, LOCAL_COMMAND_STDOUT_TAG)
if (!output) continue
if (!pendingGoalCommand && !looksLikeGoalStatusOutput(output)) continue
restored = goalFromLocalCommandOutput(threadId, output, restored, now)
pendingGoalCommand = false
}
if (restored) goalsByThread.set(threadId, restored)
return restored
}
export function clearThreadGoal(threadId: string): boolean {
return goalsByThread.delete(threadId)
}
export function updateThreadGoalStatus(
threadId: string,
status: ThreadGoalStatus,
now = Date.now(),
): ThreadGoal | null {
const goal = goalsByThread.get(threadId)
if (!goal) return null
const updated = { ...goal, status, updatedAt: now }
goalsByThread.set(threadId, updated)
return updated
}
export function markThreadGoalComplete(
threadId: string,
input: { reason?: string; now?: number } = {},
): ThreadGoal | null {
const goal = goalsByThread.get(threadId)
if (!goal) return null
const updated = {
...goal,
status: 'complete' as const,
lastReason: input.reason ?? goal.lastReason,
updatedAt: input.now ?? Date.now(),
}
goalsByThread.set(threadId, updated)
return updated
}
export function accountThreadGoalUsage(
threadId: string,
tokens: number,
now = Date.now(),
): ThreadGoal | null {
const goal = goalsByThread.get(threadId)
if (!goal || tokens <= 0) return goal ?? null
const updated = {
...goal,
tokensUsed: goal.tokensUsed + tokens,
updatedAt: now,
}
goalsByThread.set(threadId, updated)
return updated
}
export function incrementThreadGoalContinuation(
threadId: string,
input: { reason?: string; now?: number } = {},
): ThreadGoal | null {
const goal = goalsByThread.get(threadId)
if (!goal) return null
const updated = {
...goal,
continuationCount: goal.continuationCount + 1,
lastReason: input.reason ?? goal.lastReason,
updatedAt: input.now ?? Date.now(),
}
goalsByThread.set(threadId, updated)
return updated
}
export function formatGoalStatus(goal: ThreadGoal | null, now = Date.now()): string {
if (!goal) return 'No active goal.'
return [
`Goal: ${goal.status}`,
`Objective: ${goal.objective}`,
`Budget: ${goal.tokensUsed.toLocaleString()} / ${
goal.tokenBudget === null ? 'unlimited' : goal.tokenBudget.toLocaleString()
} tokens`,
`Elapsed: ${formatElapsed(Math.max(0, now - goal.createdAt))}`,
`Continuations: ${goal.continuationCount.toLocaleString()}`,
goal.lastReason ? `Latest reason: ${goal.lastReason}` : null,
]
.filter((line): line is string => line !== null)
.join('\n')
}
export function buildGoalStartPrompt(goal: ThreadGoal): string {
return [
'You are now pursuing this /goal until the completion condition is met.',
'',
`<objective>${goal.objective}</objective>`,
'',
'Work autonomously. Research, implement, test, and review as needed.',
'Before claiming completion, perform a concrete completion audit against the objective.',
].join('\n')
}
export function buildGoalContinuationPrompt(
goal: ThreadGoal,
reason: string,
): string {
return [
'Continue working toward the active /goal.',
'',
`<objective>${goal.objective}</objective>`,
'',
'The goal evaluator says the objective is not complete yet.',
`Reason: ${reason || 'No reason provided.'}`,
'',
'Resume directly from the current state. Do not ask the user to continue. Do the next concrete step, then test or review before stopping.',
].join('\n')
}
function formatElapsed(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) return `${hours}h ${minutes % 60}m`
if (minutes > 0) return `${minutes}m`
return `${seconds}s`
}
function goalFromLocalCommandOutput(
threadId: string,
output: string,
current: ThreadGoal | null,
now: number,
): ThreadGoal | null {
const trimmed = output.trim()
if (trimmed === 'Goal cleared.' || trimmed.startsWith('Goal cleared:')) {
return null
}
if (trimmed === 'No active goal.') return current
if (trimmed === 'Goal marked complete.') {
return current ? { ...current, status: 'complete', updatedAt: now } : null
}
if (trimmed.startsWith('Goal set:')) {
const objective = trimmed.slice('Goal set:'.length).trim()
if (!objective) return current
return {
goalId: randomUUID(),
threadId,
objective,
status: 'active',
tokenBudget: null,
tokensUsed: 0,
continuationCount: 0,
lastReason: null,
createdAt: now,
updatedAt: now,
}
}
return current
}
function messageToText(message: Message): string {
if (message.type === 'system') {
return typeof message.content === 'string' ? message.content : ''
}
if (!('message' in message)) return ''
const content = message.message?.content
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
return content
.map((block) => {
if (!block || typeof block !== 'object') return ''
const text = (block as { text?: unknown }).text
return typeof text === 'string' ? text : ''
})
.filter(Boolean)
.join('\n')
}
function readXmlTag(text: string, tag: string): string | null {
const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = text.match(new RegExp(`<${escaped}>([\\s\\S]*?)</${escaped}>`, 'i'))
return match?.[1]?.trim() ?? null
}
function looksLikeGoalStatusOutput(output: string): boolean {
const trimmed = output.trim()
return (
trimmed.startsWith('Goal set:') ||
trimmed === 'Goal cleared.' ||
trimmed.startsWith('Goal cleared:') ||
trimmed === 'Goal marked complete.' ||
trimmed === 'No active goal.'
)
}