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