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:
@@ -10,7 +10,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useSessionGoal } from '@/hooks/useSessionGoal';
|
||||
import { useGoalObjectiveContent, useSessionGoal } from '@/hooks/useSessionGoal';
|
||||
import {
|
||||
formatGoalTokens,
|
||||
SESSION_GOAL_OBJECTIVE_CHAR_LIMIT,
|
||||
@@ -32,6 +32,7 @@ interface SessionGoalDialogProps {
|
||||
export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }: SessionGoalDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const { goal } = useSessionGoal(sessionId, directory);
|
||||
const objectiveContent = useGoalObjectiveContent(sessionId, goal);
|
||||
|
||||
const [objective, setObjective] = React.useState('');
|
||||
const [budgetEnabled, setBudgetEnabled] = React.useState(false);
|
||||
@@ -40,7 +41,7 @@ export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }:
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setObjective(goal?.objective ?? '');
|
||||
setObjective(goal?.objectiveFile ? (objectiveContent ?? '') : (goal?.objective ?? ''));
|
||||
setBudgetEnabled(Boolean(goal?.tokenBudget));
|
||||
setTokenBudget(goal?.tokenBudget ?? 200_000);
|
||||
// Seed the form only when the dialog opens; live goal updates while it is
|
||||
@@ -48,6 +49,14 @@ export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }:
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// File-backed objectives fetch async — the content usually lands right
|
||||
// after the dialog opens. Late-seed the textarea only while it is still
|
||||
// untouched so a slow fetch never clobbers the user's typing.
|
||||
React.useEffect(() => {
|
||||
if (!open || !goal?.objectiveFile || objectiveContent === null) return;
|
||||
setObjective((current) => (current === '' ? objectiveContent : current));
|
||||
}, [open, goal?.objectiveFile, objectiveContent]);
|
||||
|
||||
const run = React.useCallback(async (action: () => Promise<void>, closeAfter: boolean) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -62,7 +71,8 @@ export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }:
|
||||
}, [onOpenChange, t]);
|
||||
|
||||
const trimmedObjective = objective.trim();
|
||||
const objectiveChanged = trimmedObjective !== (goal?.objective ?? '');
|
||||
const savedObjective = goal?.objectiveFile ? (objectiveContent ?? '') : (goal?.objective ?? '');
|
||||
const objectiveChanged = trimmedObjective !== savedObjective;
|
||||
const budgetValue = budgetEnabled ? tokenBudget : null;
|
||||
const budgetChanged = budgetValue !== (goal?.tokenBudget ?? null);
|
||||
// A completed goal is read-only: remove it and arm a new one instead of
|
||||
@@ -112,7 +122,7 @@ export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }:
|
||||
)}
|
||||
|
||||
{isCompleted ? (
|
||||
<p className="max-h-48 overflow-y-auto whitespace-pre-wrap break-words typography-meta text-muted-foreground">{goal.objective}</p>
|
||||
<p className="max-h-48 overflow-y-auto whitespace-pre-wrap break-words typography-meta text-muted-foreground">{objectiveContent ?? goal.objective}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useSessionStatus } from '@/sync/sync-context';
|
||||
import { useSessionGoal } from '@/hooks/useSessionGoal';
|
||||
import { useGoalObjectiveContent, useSessionGoal } from '@/hooks/useSessionGoal';
|
||||
import { formatGoalTokens } from '@/lib/sessionGoalMetadata';
|
||||
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
|
||||
import { setSessionGoalStatus } from '@/lib/sessionGoalActions';
|
||||
@@ -22,6 +22,7 @@ interface SessionGoalRowProps {
|
||||
export const SessionGoalRow: React.FC<SessionGoalRowProps> = React.memo(({ sessionId, directory, className }) => {
|
||||
const { t } = useI18n();
|
||||
const { goal, enabled } = useSessionGoal(sessionId ?? '', directory);
|
||||
const objectiveContent = useGoalObjectiveContent(sessionId ?? '', goal);
|
||||
const sessionStatus = useSessionStatus(sessionId ?? '', directory);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
@@ -65,11 +66,11 @@ export const SessionGoalRow: React.FC<SessionGoalRowProps> = React.memo(({ sessi
|
||||
className,
|
||||
)}
|
||||
aria-label={t('chat.goal.row.aria')}
|
||||
title={goal.objective}
|
||||
title={objectiveContent ?? undefined}
|
||||
>
|
||||
<Icon name="target" className="h-3.5 w-3.5 flex-shrink-0" style={{ color: sessionGoalStatusColor[goal.status] }} aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate typography-meta text-foreground">
|
||||
{goal.note || goal.objective}
|
||||
{goal.note || objectiveContent || ''}
|
||||
</span>
|
||||
{goal.status === 'active' && (!sessionStatus || sessionStatus.type === 'idle') ? (
|
||||
// The agent stopped but the goal is still active: the server is
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useSession } from '@/sync/sync-context';
|
||||
import { getSessionGoal, type SessionGoalPayload } from '@/lib/sessionGoalMetadata';
|
||||
import { fetchGoalObjectiveContent } from '@/lib/sessionGoalActions';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
export interface SessionGoalState {
|
||||
@@ -19,3 +21,30 @@ export function useSessionGoal(sessionId: string, directory?: string): SessionGo
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
// Effective objective text for display. Inline goals return the metadata
|
||||
// text directly; file-backed goals fetch the server-side file once per
|
||||
// goal edit (keyed by id + updatedAt). Display-only: a failed fetch yields
|
||||
// null and callers degrade gracefully (e.g. VS Code, where the OpenChamber
|
||||
// route is unavailable — the strip then shows only the audit note).
|
||||
export function useGoalObjectiveContent(sessionId: string, goal: SessionGoalPayload | null): string | null {
|
||||
const [fetched, setFetched] = React.useState<string | null>(null);
|
||||
const fetchKey = goal?.objectiveFile ? `${sessionId}:${goal.id}:${goal.updatedAt}` : '';
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!fetchKey) {
|
||||
setFetched(null);
|
||||
return undefined;
|
||||
}
|
||||
let alive = true;
|
||||
void fetchGoalObjectiveContent(sessionId).then((content) => {
|
||||
if (alive) setFetched(content);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [fetchKey, sessionId]);
|
||||
|
||||
if (!goal) return null;
|
||||
return goal.objectiveFile ? fetched : goal.objective;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { abortCurrentOperation, patchSessionMetadata } from '@/sync/session-actions';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import {
|
||||
SESSION_GOAL_OBJECTIVE_CHAR_LIMIT,
|
||||
type SessionGoalPayload,
|
||||
@@ -29,6 +30,41 @@ const writeGoal = (
|
||||
return { ...metadata, openchamber: nextNamespace };
|
||||
});
|
||||
|
||||
// File-backed objectives: the text lives in a server-side file keyed by the
|
||||
// session id (one goal per session — a new goal overwrites the old file);
|
||||
// the metadata only carries an `objectiveFile: true` flag so it stays light
|
||||
// for session.updated fanout. If the file write fails (offline blip, VS
|
||||
// Code without the route), the objective falls back to inline metadata.
|
||||
const writeObjectiveFile = async (sessionId: string, content: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await runtimeFetch(`/api/goals/objective/${encodeURIComponent(sessionId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteObjectiveFile = (sessionId: string): void => {
|
||||
void runtimeFetch(`/api/goals/objective/${encodeURIComponent(sessionId)}`, { method: 'DELETE' })
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
/** Fetch the file-backed objective text for display; null when unavailable. */
|
||||
export async function fetchGoalObjectiveContent(sessionId: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await runtimeFetch(`/api/goals/objective/${encodeURIComponent(sessionId)}`);
|
||||
if (!response.ok) return null;
|
||||
const parsed = await response.json().catch(() => null) as { content?: unknown } | null;
|
||||
return typeof parsed?.content === 'string' ? parsed.content : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SetSessionGoalInput {
|
||||
objective: string;
|
||||
tokenBudget: number | null;
|
||||
@@ -51,13 +87,15 @@ export async function setSessionGoal(
|
||||
const tokenBudget = typeof input.tokenBudget === 'number' && Number.isFinite(input.tokenBudget) && input.tokenBudget > 0
|
||||
? Math.floor(input.tokenBudget)
|
||||
: null;
|
||||
const objectiveFile = await writeObjectiveFile(sessionId, objective);
|
||||
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,
|
||||
objective: objectiveFile ? '' : objective,
|
||||
objectiveFile,
|
||||
tokenBudget,
|
||||
status: 'active',
|
||||
statusReason: 'resumed',
|
||||
@@ -67,7 +105,8 @@ export async function setSessionGoal(
|
||||
}
|
||||
return {
|
||||
id: createGoalId(),
|
||||
objective,
|
||||
objective: objectiveFile ? '' : objective,
|
||||
objectiveFile,
|
||||
status: 'active',
|
||||
tokenBudget,
|
||||
tokensUsed: 0,
|
||||
@@ -116,6 +155,7 @@ export async function clearSessionGoal(sessionId: string, directory: string | un
|
||||
wasActive = currentGoal?.status === 'active';
|
||||
return null;
|
||||
});
|
||||
deleteObjectiveFile(sessionId);
|
||||
// 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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1000,6 +1000,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
|
||||
req.path.startsWith('/api/notifications') ||
|
||||
req.path.startsWith('/api/session-folders') ||
|
||||
req.path.startsWith('/api/small-model') ||
|
||||
req.path.startsWith('/api/goals') ||
|
||||
req.path.startsWith('/api/text') ||
|
||||
req.path.startsWith('/api/voice') ||
|
||||
req.path.startsWith('/api/tts') ||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { registerFsRoutes } from '../fs/routes.js';
|
||||
import { registerQuotaRoutes } from '../quota/routes.js';
|
||||
import { registerSmallModelRoutes } from '../small-model/routes.js';
|
||||
import { registerSessionGoalRoutes } from '../session-goal/routes.js';
|
||||
import { registerGitHubRoutes } from '../github/routes.js';
|
||||
import { registerGitRoutes } from '../git/routes.js';
|
||||
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
|
||||
@@ -236,6 +237,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
|
||||
registerQuotaRoutes(app, { getQuotaProviders });
|
||||
registerSmallModelRoutes(app, { getSmallModelService });
|
||||
registerSessionGoalRoutes(app);
|
||||
registerGitHubRoutes(app);
|
||||
registerGitRoutes(app);
|
||||
registerMagicPromptRoutes(app, {
|
||||
|
||||
@@ -444,9 +444,22 @@ export const createScheduledTasksRuntime = (deps) => {
|
||||
// from session events like any other goal.
|
||||
const createTaskGoal = async ({ baseUrl, authHeaders, sessionID, projectPath, task }) => {
|
||||
const now = Date.now();
|
||||
// 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);
|
||||
let objectiveFile = false;
|
||||
try {
|
||||
const { writeObjective } = await import('../session-goal/objectives.js');
|
||||
await writeObjective(sessionID, objectiveText);
|
||||
objectiveFile = true;
|
||||
} catch (error) {
|
||||
console.warn('[scheduled-tasks] goal objective file write failed, falling back to inline:', error?.message || error);
|
||||
}
|
||||
const goal = {
|
||||
id: `${now.toString(36)}${Math.random().toString(36).slice(2, 8)}`,
|
||||
objective: expandSnippets(task.execution.prompt, projectPath).slice(0, 2000),
|
||||
objective: objectiveFile ? '' : objectiveText.slice(0, 5000),
|
||||
objectiveFile,
|
||||
status: 'active',
|
||||
tokenBudget: task.execution.goalTokenBudget || null,
|
||||
tokensUsed: 0,
|
||||
|
||||
@@ -11,7 +11,8 @@ the web server and survives UI disconnects.
|
||||
```
|
||||
{
|
||||
id, // opaque per-logical-goal id; stale-write guard
|
||||
objective, // user text, <= 2000 chars
|
||||
objective, // inline user text (fallback), <= 5000 chars
|
||||
objectiveFile, // true: objective text lives in a server-side file
|
||||
status, // active | paused | blocked | budgetLimited | complete
|
||||
tokenBudget, // optional positive int
|
||||
tokensUsed, // tokensCommitted + current segment (snapshot - baseline)
|
||||
@@ -40,6 +41,32 @@ with a factual done/verified/remaining statement for the independent audit.
|
||||
Freshness/stale-write protection is by `id`: every runtime write re-reads the
|
||||
session and drops the write when the stored goal id no longer matches.
|
||||
|
||||
## File-backed objectives
|
||||
|
||||
The objective TEXT lives in `<data-dir>/goals/<sessionId>.md` (data dir =
|
||||
`OPENCHAMBER_DATA_DIR` or `~/.config/openchamber`), keyed by the SESSION ID:
|
||||
sessions are globally unique and carry one goal at a time, so the mapping is
|
||||
deterministic and a new goal simply overwrites the file. Metadata carries
|
||||
only `objectiveFile: true` — never a path — so user-writable metadata cannot
|
||||
become a file-read vector (`objectives.js` also validates the id shape
|
||||
before touching the filesystem). Rationale: metadata rides every
|
||||
`session.updated`, so multi-KB objectives must not live there.
|
||||
|
||||
- `objectives.js` — write/read/delete, 5000-char clamp.
|
||||
- `routes.js` — `PUT/GET/DELETE /api/goals/objective/:sessionId`
|
||||
(OpenChamber-owned, registered before the generic proxy; JSON parsing via
|
||||
the `/api/goals` family in core-routes). The UI writes the file BEFORE
|
||||
patching the goal metadata and falls back to an inline objective when the
|
||||
write fails; `clearSessionGoal` deletes the file best-effort.
|
||||
- The tick resolves the effective objective fresh on every cycle (the file
|
||||
is live-editable mid-goal) and falls back to the inline `objective` when
|
||||
the file is unreadable — a goal never dies because a file went away.
|
||||
- UI display fetches content via the GET route
|
||||
(`useGoalObjectiveContent`); in VS Code the route is unavailable, so the
|
||||
strip degrades to the audit note (display-only fallback by design).
|
||||
- Scheduled goal tasks write the file server-side directly via
|
||||
`objectives.js`.
|
||||
|
||||
## Flow
|
||||
|
||||
1. `createSessionGoalRuntime` subscribes to the global SSE hub (same pattern
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// File-backed goal objectives. Session metadata must stay light (it rides
|
||||
// every session.updated event), so the objective TEXT lives in a file under
|
||||
// the OpenChamber data dir, keyed by the SESSION ID: sessions are globally
|
||||
// unique and carry at most one goal at a time, so the mapping is fully
|
||||
// deterministic — the metadata only carries an `objectiveFile: true` flag,
|
||||
// never a path, and user-writable metadata cannot become a file-read vector.
|
||||
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
export const GOAL_OBJECTIVE_CHAR_LIMIT = 5_000;
|
||||
|
||||
// OpenCode session ids are URL-safe tokens; anything else is rejected before
|
||||
// touching the filesystem.
|
||||
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{4,128}$/;
|
||||
|
||||
const goalsDir = () => path.join(
|
||||
process.env.OPENCHAMBER_DATA_DIR
|
||||
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
||||
: path.join(os.homedir(), '.config', 'openchamber'),
|
||||
'goals',
|
||||
);
|
||||
|
||||
const objectiveFilePath = (sessionId) => path.join(goalsDir(), `${sessionId}.md`);
|
||||
|
||||
export const isValidObjectiveKey = (sessionId) =>
|
||||
typeof sessionId === 'string' && SESSION_ID_PATTERN.test(sessionId);
|
||||
|
||||
const clampContent = (content) => String(content ?? '').trim().slice(0, GOAL_OBJECTIVE_CHAR_LIMIT);
|
||||
|
||||
/** Write (or overwrite — a new goal replaces the old one) the session's objective. */
|
||||
export const writeObjective = async (sessionId, content) => {
|
||||
if (!isValidObjectiveKey(sessionId)) {
|
||||
throw Object.assign(new Error('invalid session id'), { statusCode: 400 });
|
||||
}
|
||||
const text = clampContent(content);
|
||||
if (!text) {
|
||||
throw Object.assign(new Error('objective content is required'), { statusCode: 400 });
|
||||
}
|
||||
await fs.promises.mkdir(goalsDir(), { recursive: true });
|
||||
await fs.promises.writeFile(objectiveFilePath(sessionId), text, 'utf8');
|
||||
return { content: text };
|
||||
};
|
||||
|
||||
/** Returns the objective text, or null when missing/invalid. */
|
||||
export const readObjective = async (sessionId) => {
|
||||
if (!isValidObjectiveKey(sessionId)) return null;
|
||||
try {
|
||||
const raw = await fs.promises.readFile(objectiveFilePath(sessionId), 'utf8');
|
||||
return clampContent(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Best-effort delete; missing files are fine. */
|
||||
export const deleteObjective = async (sessionId) => {
|
||||
if (!isValidObjectiveKey(sessionId)) return;
|
||||
await fs.promises.unlink(objectiveFilePath(sessionId)).catch(() => undefined);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { deleteObjective, readObjective, writeObjective } from './objectives.js';
|
||||
|
||||
// OpenChamber-owned routes for file-backed goal objectives, keyed by session
|
||||
// id (one goal per session; a new goal overwrites the old file). The UI
|
||||
// writes the objective file before stamping the goal metadata (which only
|
||||
// carries an `objectiveFile: true` flag), reads it back for display, and
|
||||
// deletes it when the goal is removed.
|
||||
export function registerSessionGoalRoutes(app) {
|
||||
app.put('/api/goals/objective/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const { content } = req.body || {};
|
||||
await writeObjective(req.params.sessionId, content);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
const statusCode = Number(error?.statusCode) || 500;
|
||||
if (statusCode >= 500) {
|
||||
console.error('Failed to write goal objective:', error);
|
||||
}
|
||||
res.status(statusCode).json({ error: error?.message || 'Failed to write goal objective' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/goals/objective/:sessionId', async (req, res) => {
|
||||
const content = await readObjective(req.params.sessionId);
|
||||
if (content === null) {
|
||||
res.status(404).json({ error: 'objective not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ content });
|
||||
});
|
||||
|
||||
app.delete('/api/goals/objective/:sessionId', async (req, res) => {
|
||||
await deleteObjective(req.params.sessionId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { GOAL_OBJECTIVE_CHAR_LIMIT, readObjective } from './objectives.js';
|
||||
|
||||
const OPENCHAMBER_SETTINGS_FILE = path.join(
|
||||
process.env.OPENCHAMBER_DATA_DIR
|
||||
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
||||
@@ -45,7 +47,6 @@ const RESUME_KICKOFF_MS = 250;
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const MESSAGE_FETCH_LIMIT = 40;
|
||||
const TRANSCRIPT_PART_CHAR_LIMIT = 6_000;
|
||||
const GOAL_OBJECTIVE_CHAR_LIMIT = 2_000;
|
||||
const NOTE_CHAR_LIMIT = 280;
|
||||
const REASON_CHAR_LIMIT = 200;
|
||||
// Hard safety cap on auto-continuations per goal id. The audit and markers are
|
||||
@@ -192,12 +193,16 @@ const parseGoalMetadata = (session) => {
|
||||
const goal = namespace.goal;
|
||||
if (!goal || typeof goal !== 'object') return null;
|
||||
const objective = typeof goal.objective === 'string' ? goal.objective.trim() : '';
|
||||
const objectiveFile = goal.objectiveFile === true;
|
||||
const id = typeof goal.id === 'string' ? goal.id : '';
|
||||
const status = GOAL_STATUSES.includes(goal.status) ? goal.status : '';
|
||||
if (!id || !objective || !status) return null;
|
||||
// File-backed goals carry only the flag (the file is keyed by session id);
|
||||
// inline goals carry the objective text directly.
|
||||
if (!id || !status || (!objective && !objectiveFile)) return null;
|
||||
return {
|
||||
id,
|
||||
objective: objective.slice(0, GOAL_OBJECTIVE_CHAR_LIMIT),
|
||||
objectiveFile,
|
||||
status,
|
||||
tokenBudget: Number.isFinite(goal.tokenBudget) && goal.tokenBudget > 0 ? Math.floor(goal.tokenBudget) : null,
|
||||
tokensUsed: Number.isFinite(goal.tokensUsed) && goal.tokensUsed > 0 ? Math.floor(goal.tokensUsed) : 0,
|
||||
@@ -414,6 +419,24 @@ export const createSessionGoalRuntime = ({
|
||||
const goal = parseGoalMetadata(session);
|
||||
if (!goal || goal.status !== 'active') return;
|
||||
|
||||
// File-backed objectives: the metadata carries only a flag; the objective
|
||||
// TEXT lives under the OpenChamber data dir keyed by session id and is
|
||||
// read fresh on every tick (live-editable). A missing file falls back to
|
||||
// whatever inline objective the metadata still has — the goal must never
|
||||
// die just because a file went away.
|
||||
let effectiveObjective = goal.objective;
|
||||
if (goal.objectiveFile) {
|
||||
const fileObjective = await readObjective(sessionId);
|
||||
if (fileObjective) {
|
||||
effectiveObjective = fileObjective;
|
||||
} else if (!effectiveObjective) {
|
||||
console.warn(`[session-goal] ${sessionId} objective file unreadable and no inline fallback`);
|
||||
return;
|
||||
} else {
|
||||
console.warn(`[session-goal] ${sessionId} objective file unreadable, using inline fallback`);
|
||||
}
|
||||
}
|
||||
|
||||
const messages = await fetchRecentMessages(sessionId, directory);
|
||||
if (!messages) return;
|
||||
|
||||
@@ -427,6 +450,19 @@ export const createSessionGoalRuntime = ({
|
||||
const lastAssistantInfo = lastAssistant?.info;
|
||||
const lastMessageInfo = messages.length > 0 ? messages[messages.length - 1]?.info : null;
|
||||
|
||||
// Execution source for audits and continuations: the newest NON-summary
|
||||
// assistant turn. The compaction summary message carries agent/mode
|
||||
// "compaction" and the summarize model — inheriting those would continue
|
||||
// the session with the wrong agent/model.
|
||||
let executionInfo = null;
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const info = messages[i]?.info;
|
||||
if (info?.role === 'assistant' && info.summary !== true) {
|
||||
executionInfo = info;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Quiescence check: the idle event may have raced a follow-up prompt, and
|
||||
// the kickoff path arms without knowing the live status at all. A trailing
|
||||
// user message or an unfinished assistant reply means the session is (or
|
||||
@@ -475,8 +511,17 @@ export const createSessionGoalRuntime = ({
|
||||
sawNewMessages = true;
|
||||
const total = messageTokenTotal(info);
|
||||
if (info.summary === true) {
|
||||
const closing = Math.max(segmentSnapshot ?? 0, total);
|
||||
tokensCommitted += Math.max(0, closing - tokensBaseline);
|
||||
// The summary message's own tokens are ZEROED by opencode — never
|
||||
// feed them into the closing value. Close the segment from what is
|
||||
// already known, with the previously displayed total as a continuity
|
||||
// floor (the latest pre-summary snapshot was already folded into
|
||||
// tokensUsed on earlier ticks); otherwise the counter freezes at the
|
||||
// pre-compaction value until the new context outgrows it. Known
|
||||
// undercount: the summarization call itself is reported as 0 tokens.
|
||||
tokensCommitted = Math.max(
|
||||
goal.tokensUsed,
|
||||
tokensCommitted + Math.max(0, (segmentSnapshot ?? 0) - tokensBaseline),
|
||||
);
|
||||
tokensBaseline = 0;
|
||||
segmentSnapshot = null;
|
||||
} else {
|
||||
@@ -557,7 +602,7 @@ export const createSessionGoalRuntime = ({
|
||||
if (lastAssistantInfo.summary === true || abortedTail) {
|
||||
blockedStreak = goal.blockedStreak;
|
||||
} else {
|
||||
audit = await runAudit({ goal, assistantText, directory, lastAssistantInfo });
|
||||
audit = await runAudit({ goal: { ...goal, objective: effectiveObjective }, assistantText, directory, lastAssistantInfo: executionInfo ?? lastAssistantInfo });
|
||||
|
||||
// Audit unavailable: tolerate one consecutive failure (transient
|
||||
// hiccup), then stop the goal instead of continuing blind. Blocked is
|
||||
@@ -622,7 +667,7 @@ export const createSessionGoalRuntime = ({
|
||||
}
|
||||
|
||||
console.log(`[session-goal] continuing ${sessionId} (turn ${written.turnsUsed}/${maxAutoTurns}, tokens ${written.tokensUsed}${written.tokenBudget ? `/${written.tokenBudget}` : ''})`);
|
||||
await sendContinuation({ sessionId, directory, goal: written, lastAssistantInfo });
|
||||
await sendContinuation({ sessionId, directory, goal: { ...written, objective: effectiveObjective }, lastAssistantInfo: executionInfo ?? lastAssistantInfo });
|
||||
};
|
||||
|
||||
const armTimer = (sessionId, directory, quietMs) => {
|
||||
|
||||
Reference in New Issue
Block a user