141 lines
6.2 KiB
JavaScript
141 lines
6.2 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,
|
||
|
|
}) => {
|
||
|
|
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);
|
||
|
|
if (state.messages.length === 0) 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) 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: buildContextPrompt(entries), 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,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
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 };
|
||
|
|
};
|