Files
Bohdan Triapitsyn c9ac8676e7 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)
2026-07-12 02:49:56 +03:00

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);
};