Files
openchamber/packages/web/server/lib/context-obligatory/runtime.js
T

172 lines
7.5 KiB
JavaScript

const FETCH_TIMEOUT_MS = 15_000;
const MESSAGE_FETCH_LIMIT = 20;
const isRecord = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
const readContextState = (session) => {
const metadata = isRecord(session?.metadata) ? session.metadata : {};
const openchamber = isRecord(metadata.openchamber) ? metadata.openchamber : {};
const messages = Array.isArray(openchamber.context_obligatory_messages)
? openchamber.context_obligatory_messages.filter((item) =>
isRecord(item)
&& typeof item.id === 'string'
&& typeof item.createdAt === 'number'
&& (item.role === 'user' || item.role === 'assistant'))
: [];
return { metadata, openchamber, messages };
};
const buildContextPrompt = (entries) => {
const timeline = entries.map(({ pinned, text }) => {
const timestamp = new Date(pinned.createdAt).toISOString();
return `## ${pinned.role}${timestamp}\n\n${text}`;
}).join('\n\n---\n\n');
return [
'The following messages are from the compacted conversation. The user explicitly marked them as important and required in your context. Pay close attention to them; they may have been sent by either the user or you before compaction.',
'Use them while continuing the pre-compaction work. Do not treat this context restoration as a new standalone task.',
'If any tasks or next steps remain, do not acknowledge, summarize, or mention this restored context in a separate response. Simply continue the work and use it silently as background context. Do not append a recap of it after completing those tasks. Only if no tasks or next steps remain, give the user a very brief summary of the important restored context in no more than one short paragraph, without lists or a detailed recap.',
'',
timeline,
].join('\n');
};
export const createContextObligatoryRuntime = ({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
sessionKnowledgeRuntime = null,
}) => {
const inflight = new Set();
let stopped = false;
const openCodeFetch = async (fetchPath, { directory, method = 'GET', body, query } = {}) => {
const params = new URLSearchParams(query || {});
if (directory) params.set('directory', directory);
const search = params.toString();
const response = await fetch(`${buildOpenCodeUrl(fetchPath, '')}${search ? `?${search}` : ''}`, {
method,
headers: {
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
...getOpenCodeAuthHeaders(),
},
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`OpenCode ${method} ${fetchPath} failed with ${response.status}`);
return response.json().catch(() => null);
};
const tick = async (sessionId, directory) => {
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
if (session?.parentID) return;
const state = readContextState(session);
/**
* Project knowledge rides along with the pinned messages. Compaction takes
* both away, and both are restored for the same reason, so they travel as
* one message: two synthetic turns back to back would read as the agent
* being interrupted twice.
*/
const knowledge = sessionKnowledgeRuntime
? await sessionKnowledgeRuntime
.resolvePending(
directory,
// Compaction removed the previously delivered block, so its stored
// signature is no longer evidence that the session still carries it.
'',
sessionKnowledgeRuntime.readPins(session),
)
.catch(() => ({ text: '', signature: '' }))
: { text: '', signature: '' };
if (state.messages.length === 0 && !knowledge.text) return;
const recent = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/message`, {
directory,
query: { limit: String(MESSAGE_FETCH_LIMIT) },
});
if (!Array.isArray(recent) || recent.length === 0) return;
const summary = recent.toReversed().find((message) =>
message?.info?.role === 'assistant' && message.info.summary === true)?.info;
if (!summary?.id || !summary?.time?.completed) return;
if (state.openchamber.context_obligatory_last_compaction_message_id === summary.id) return;
const fetched = await Promise.allSettled(state.messages.map(async (pinned) => {
const message = await openCodeFetch(
`/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(pinned.id)}`,
{ directory },
);
const text = Array.isArray(message?.parts)
? message.parts.filter((part) => part?.type === 'text' && typeof part.text === 'string')
.map((part) => part.text.trim()).filter(Boolean).join('\n\n')
: '';
return { pinned, text };
}));
const entries = fetched
.filter((result) => result.status === 'fulfilled' && result.value.text)
.map((result) => result.value)
.sort((left, right) => left.pinned.createdAt - right.pinned.createdAt);
if (entries.length === 0 && !knowledge.text) return;
const executionInfo = recent.toReversed().find((message) =>
message?.info?.role === 'assistant' && message.info.summary !== true)?.info;
const providerID = typeof executionInfo?.providerID === 'string' ? executionInfo.providerID : '';
const modelID = typeof executionInfo?.modelID === 'string' ? executionInfo.modelID : '';
if (!providerID || !modelID) throw new Error('no pre-compaction assistant provider/model');
const agent = typeof executionInfo.agent === 'string' ? executionInfo.agent : executionInfo.mode;
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/prompt_async`, {
directory,
method: 'POST',
body: {
model: { providerID, modelID },
...(typeof agent === 'string' && agent ? { agent } : {}),
parts: [{
type: 'text',
text: [knowledge.text, entries.length > 0 ? buildContextPrompt(entries) : '']
.filter(Boolean)
.join('\n\n---\n\n'),
synthetic: true,
}],
},
});
const fresh = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
const freshState = readContextState(fresh);
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
directory,
method: 'PATCH',
body: {
metadata: {
...freshState.metadata,
openchamber: {
...freshState.openchamber,
context_obligatory_last_compaction_message_id: summary.id,
// Recorded together with the cursor: the session now carries this
// knowledge again, so the next send must not repeat it.
...(knowledge.signature
? { [sessionKnowledgeRuntime.metadataKey]: knowledge.signature }
: {}),
},
},
},
});
};
const processPayload = (payload, directoryHint = '') => {
if (stopped || payload?.type !== 'session.compacted') return;
const sessionId = payload?.properties?.sessionID;
if (typeof sessionId !== 'string' || inflight.has(sessionId)) return;
const directory = payload?.properties?.directory || directoryHint;
inflight.add(sessionId);
return tick(sessionId, directory)
.catch((error) => console.warn('[context-obligatory] injection failed:', error?.message || error))
.finally(() => inflight.delete(sessionId));
};
const stop = () => {
stopped = true;
};
return { processPayload, stop };
};