395 lines
18 KiB
JavaScript
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 };
|
|
};
|