Files
openchamber/packages/web/server/lib/session-assist/runtime.js
T

395 lines
18 KiB
JavaScript

// Session assist: after a session goes idle and stays quiet, generate a short
// recap of the agent's last reply plus one suggested user follow-up with the
// small model, and store both on the session's metadata
// (metadata.openchamber.assist). Clients decide visibility from
// assist.forMessageID — a new message makes the payload stale everywhere
// without any extra writes.
//
// Purely event-driven: only sessions that transition busy→idle while the
// server is running ever generate anything. No backfill, no session scans.
import fs from 'fs';
import os from 'os';
import path from 'path';
const OPENCHAMBER_SETTINGS_FILE = path.join(
process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber'),
'settings.json',
);
// The Chat settings are hard generation switches (default on): when both are
// off, no small-model calls and no metadata writes happen at all. Existing
// payloads stay untouched — clients keep showing them and dismissal still works.
const getSessionAssistTargets = () => {
try {
const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
const settings = JSON.parse(raw);
return {
recap: settings?.sessionRecapEnabled !== false,
suggestion: settings?.sessionSuggestionEnabled !== false,
};
} catch {
return { recap: true, suggestion: true };
}
};
const IDLE_QUIET_MS = 60_000;
const TRANSCRIPT_MESSAGE_LIMIT = 12;
const TRANSCRIPT_PART_CHAR_LIMIT = 6_000;
const RECAP_CHAR_LIMIT = 320;
const SUGGESTION_CHAR_LIMIT = 500;
const FETCH_TIMEOUT_MS = 5_000;
const buildAssistSystemPrompt = ({ recap, suggestion }) => [
'You assist a user who chats with a coding agent. Based on the conversation transcript, return exactly one JSON object and nothing else — no prose, no markdown, no code fences.',
`Shape: {${[recap ? '"recap": string' : '', suggestion ? '"suggestion": string' : ''].filter(Boolean).join(', ')}}`,
recap
? 'recap: at most 20 words. State the substance directly — the facts, result, or conclusion, plus the next move if there is one. NEVER narrate ("The assistant explained…", "The agent did…") — write the content itself, like a note the user jotted down.'
: '',
suggestion ? 'suggestion: write ONE immediately sendable next user message addressed TO the coding agent.' : '',
suggestion ? 'The suggestion should be the most useful next step after the assistant\'s latest reply. It should help the user continue productively, not inspect already-known details.' : '',
suggestion ? 'Prefer suggestions that ask the agent to make a concrete improvement, implement something specific, validate the latest change, explain tradeoffs, improve the current approach, or continue from the current result.' : '',
suggestion ? 'Rules for suggestion:' : '',
suggestion ? '- Output exactly one message the user could click and send without editing.' : '',
suggestion ? '- Pick one best next action yourself.' : '',
suggestion ? '- Do not include alternatives, choices, slash-separated options, or "or".' : '',
suggestion ? '- Do not write "Do X or Y", "Ask whether...", "Maybe...", or "You could...".' : '',
suggestion ? '- Do not ask for information the assistant already provided.' : '',
suggestion ? '- Do not ask to see exact code, file paths, prompt locations, or implementation internals unless the assistant did not provide them and they are necessary for the next step.' : '',
suggestion ? '- Do not produce generic workflow commands like "Run tests" unless testing is clearly the next unresolved step.' : '',
suggestion ? '- Do not produce meta/debug requests that merely inspect the implementation.' : '',
suggestion ? '- Use imperative or question form.' : '',
suggestion ? '- Keep it concise.' : '',
suggestion ? 'Use these examples to understand how to choose the suggestion. Do not copy their topic or wording unless the current conversation is about the same thing.' : '',
suggestion ? 'Example 1:' : '',
suggestion ? 'Assistant reply summary:' : '',
suggestion ? 'The assistant already identified the file where the feature is implemented, explained what context is sent to the small model, and summarized the current prompt.' : '',
suggestion ? 'Bad suggestion:' : '',
suggestion ? '"Show me the exact runtime.js code and where the prompt is built."' : '',
suggestion ? 'Why bad:' : '',
suggestion ? 'It asks for information the assistant already provided. It repeats inspection instead of moving to an improvement or decision.' : '',
suggestion ? 'Good suggestion:' : '',
suggestion ? '"Suggest how to improve the prompt and context so the generated suggestion is more useful."' : '',
suggestion ? 'Why good:' : '',
suggestion ? 'It naturally continues from the analysis and asks for a concrete improvement.' : '',
suggestion ? 'Example 2:' : '',
suggestion ? 'Assistant reply summary:' : '',
suggestion ? 'The assistant implemented a timeline dialog redesign, listed concrete UI changes, and reported that type-check and lint passed.' : '',
suggestion ? 'Bad suggestion:' : '',
suggestion ? '"Check whether scrolling or loading older messages works without jumps."' : '',
suggestion ? 'Why bad:' : '',
suggestion ? 'It contains an alternative. A suggestion chip must be one sendable message, not a choice the user has to edit.' : '',
suggestion ? 'Good suggestion:' : '',
suggestion ? '"Check whether scrolling and loading older messages work without jumps."' : '',
suggestion ? 'Why good:' : '',
suggestion ? 'It picks a single validation request that the user can send immediately.' : '',
'All requested values MUST be written in the same language as the conversation text itself. Ignore any other language preferences or personalization you may have — only the conversation text decides the language.',
'Use double quotes for JSON strings, no trailing commas.',
].filter(Boolean).join('\n');
const extractJsonObject = (value) => {
const text = String(value ?? '').trim();
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
const candidate = (fenced?.[1] ?? text).trim();
const start = candidate.indexOf('{');
if (start < 0) return null;
for (let end = candidate.length; end > start; end -= 1) {
if (candidate[end - 1] !== '}') continue;
try {
const parsed = JSON.parse(candidate.slice(start, end));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
} catch {
// keep scanning — models wrap JSON in prose sometimes
}
}
return null;
};
const extractSessionStatus = (payload) => {
if (!payload || payload.type !== 'session.status') return null;
const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
const status = properties.status && typeof properties.status === 'object' ? properties.status : {};
const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : '';
const type = typeof status.type === 'string'
? status.type.trim()
: (typeof info.type === 'string' ? info.type.trim() : '');
if (!sessionId || !type) return null;
const directory = typeof properties.directory === 'string' && properties.directory
? properties.directory
: (typeof info.directory === 'string' ? info.directory : '');
return { sessionId, type, directory };
};
const extractUserMessage = (payload) => {
if (!payload || payload.type !== 'message.updated') return null;
const info = payload.properties?.info;
if (!info || typeof info !== 'object' || info.role !== 'user') return null;
if (typeof info.sessionID !== 'string' || !info.sessionID) return null;
return {
sessionId: info.sessionID,
createdAt: typeof info.time?.created === 'number' ? info.time.created : 0,
};
};
const messagePartsToText = (message) => {
const parts = Array.isArray(message?.parts) ? message.parts : [];
return parts
.map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
.filter(Boolean)
.join('\n')
.slice(0, TRANSCRIPT_PART_CHAR_LIMIT);
};
export const createSessionAssistRuntime = ({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
getSmallModelService,
quietMs = IDLE_QUIET_MS,
}) => {
const timers = new Map();
const inflight = new Set();
let stopped = false;
const clearTimer = (sessionId) => {
const existing = timers.get(sessionId);
if (existing) {
clearTimeout(existing.timer);
timers.delete(sessionId);
}
};
const openCodeFetch = async (path, { directory, method = 'GET', body } = {}) => {
const base = buildOpenCodeUrl(path, '');
const url = directory ? `${base}?directory=${encodeURIComponent(directory)}` : base;
const response = await fetch(url, {
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} ${path} failed with ${response.status}`);
}
return response.json().catch(() => null);
};
const fetchRecentMessages = async (sessionId, directory) => {
const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, '');
const params = new URLSearchParams({ limit: String(TRANSCRIPT_MESSAGE_LIMIT) });
if (directory) params.set('directory', directory);
const response = await fetch(`${base}?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) return null;
const messages = await response.json().catch(() => null);
return Array.isArray(messages) ? messages : null;
};
const generateAssist = async (sessionId, directory) => {
const targets = getSessionAssistTargets();
if (!targets.recap && !targets.suggestion) return;
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
.catch((error) => {
console.warn(`[session-assist] session fetch failed: ${error?.message || error}`);
return null;
});
if (!session || typeof session !== 'object') return;
// Sub-agent/task sessions never surface in chat — skip them.
if (typeof session.parentID === 'string' && session.parentID) return;
const messages = await fetchRecentMessages(sessionId, directory);
if (!messages || messages.length === 0) {
console.warn('[session-assist] no messages fetched');
return;
}
let lastAssistant = null;
for (let i = messages.length - 1; i >= 0; i -= 1) {
const info = messages[i]?.info;
if (info?.role === 'assistant') {
lastAssistant = messages[i];
break;
}
}
const lastAssistantInfo = lastAssistant?.info;
if (!lastAssistantInfo?.id) return;
// Only the last exchange: the assistant reply plus the user message it
// answered (assistant info.parentID → user info.id). Everything else is
// token waste for a one-line recap and a single suggestion.
const parentUserMessage = typeof lastAssistantInfo.parentID === 'string' && lastAssistantInfo.parentID
? messages.find((message) => message?.info?.id === lastAssistantInfo.parentID && message?.info?.role === 'user')
: null;
const userText = parentUserMessage ? messagePartsToText(parentUserMessage) : '';
const assistantText = messagePartsToText(lastAssistant);
const transcript = [
userText ? `User:\n${userText}` : '',
assistantText ? `Assistant:\n${assistantText}` : '',
].filter(Boolean).join('\n\n');
if (!transcript) return;
const { generateSmallModelText } = await getSmallModelService();
const requestedFields = [targets.recap ? 'recap' : '', targets.suggestion ? 'suggestion' : '']
.filter(Boolean)
.join(' and ');
// Instruct the language by example, not by description — account-side
// personalization (e.g. the ChatGPT backend knowing the user's locale)
// otherwise leaks a different language into the output.
const languageSample = (userText || assistantText).slice(0, 200).replace(/\s+/g, ' ').trim();
let generated;
try {
generated = await generateSmallModelText({
// Background feature: conversation content must never leave the
// session's own provider unless the user explicitly picked a small
// model (settings override / opencode config).
restrictToPreferredProvider: true,
prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite ${requestedFields} in the SAME language as this sample from the conversation: "${languageSample}"`,
system: buildAssistSystemPrompt(targets),
directory,
preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
});
} catch (error) {
// No authenticated provider (404) or a transient model failure — this is
// background sugar, never retry loops or logs spam.
if (Number(error?.statusCode) !== 404) {
console.warn('[session-assist] generation failed:', error?.message || error);
}
return;
}
const structured = extractJsonObject(generated?.text);
let recap = targets.recap && typeof structured?.recap === 'string' ? structured.recap.trim().slice(0, RECAP_CHAR_LIMIT) : '';
let suggestion = targets.suggestion && typeof structured?.suggestion === 'string' ? structured.suggestion.trim().slice(0, SUGGESTION_CHAR_LIMIT) : '';
// Hard guard against language hallucination: if the conversation contains
// no Cyrillic/CJK at all, the output must not either (and drop per-field,
// so one hallucinated field doesn't kill the other).
const hasCyrillic = (text) => /[\u0400-\u04FF]/.test(text);
const hasCjk = (text) => /[\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/.test(text);
const inputText = `${userText}\n${assistantText}`;
const scriptMismatch = (text) => (hasCyrillic(text) && !hasCyrillic(inputText))
|| (hasCjk(text) && !hasCjk(inputText));
if (recap && scriptMismatch(recap)) {
console.warn('[session-assist] dropped recap: language mismatch with conversation');
recap = '';
}
if (suggestion && scriptMismatch(suggestion)) {
console.warn('[session-assist] dropped suggestion: language mismatch with conversation');
suggestion = '';
}
if (!recap && !suggestion) return;
// The session may have moved on while we generated — a stale patch would
// flash outdated content, so re-check the tail before writing.
const latest = await fetchRecentMessages(sessionId, directory);
const latestAssistantId = (() => {
if (!latest) return null;
for (let i = latest.length - 1; i >= 0; i -= 1) {
const info = latest[i]?.info;
if (info?.role === 'assistant') return info.id;
if (info?.role === 'user') return null;
}
return null;
})();
if (latestAssistantId !== lastAssistantInfo.id) {
console.log('[session-assist] tail moved on, dropping result');
return;
}
// Merge from a FRESH read: generation takes tens of seconds, and merging
// from the session snapshot fetched before it would clobber any metadata
// written meanwhile (suggestion dismissals, review links, …).
const freshSession = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
.catch(() => null);
const currentMetadata = freshSession?.metadata && typeof freshSession.metadata === 'object'
? freshSession.metadata
: (session.metadata && typeof session.metadata === 'object' ? session.metadata : {});
const currentNamespace = currentMetadata.openchamber && typeof currentMetadata.openchamber === 'object'
? currentMetadata.openchamber
: {};
console.log(`[session-assist] generated for ${sessionId} via ${generated.providerID}/${generated.modelID}`);
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
directory,
method: 'PATCH',
body: {
metadata: {
...currentMetadata,
openchamber: {
...currentNamespace,
assist: {
recap,
suggestion,
forMessageID: lastAssistantInfo.id,
generatedAt: Date.now(),
},
},
},
},
});
};
const armTimer = (sessionId, directory) => {
clearTimer(sessionId);
const timer = setTimeout(() => {
timers.delete(sessionId);
if (stopped || inflight.has(sessionId)) return;
inflight.add(sessionId);
generateAssist(sessionId, directory)
.catch((error) => {
console.warn('[session-assist] failed:', error?.message || error);
})
.finally(() => {
inflight.delete(sessionId);
});
}, quietMs);
if (typeof timer?.unref === 'function') timer.unref();
timers.set(sessionId, { timer, armedAt: Date.now() });
};
const processPayload = (payload, directoryHint = '') => {
if (stopped) return;
const status = extractSessionStatus(payload);
if (status) {
if (status.type === 'idle') {
armTimer(status.sessionId, status.directory || directoryHint);
} else {
clearTimer(status.sessionId);
}
return;
}
const userMessage = extractUserMessage(payload);
if (userMessage) {
// OpenCode re-emits message.updated for OLD user messages after the
// session settles (post-completion metadata patches). Only a message
// created after the timer was armed means the user actually moved on.
const armed = timers.get(userMessage.sessionId);
if (armed && userMessage.createdAt >= armed.armedAt) {
clearTimer(userMessage.sessionId);
}
}
};
const stop = () => {
stopped = true;
for (const { timer } of timers.values()) {
clearTimeout(timer);
}
timers.clear();
};
return { processPayload, stop };
};