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)
62 lines
2.3 KiB
JavaScript
62 lines
2.3 KiB
JavaScript
// 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);
|
|
};
|