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
@@ -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) => {