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:
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user