2026-07-05 23:19:10 +03:00
// 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' ,
);
2026-07-06 22:54:31 +03:00
// 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 = () => {
2026-07-05 23:19:10 +03:00
try {
const raw = fs . readFileSync ( OPENCHAMBER_SETTINGS_FILE , 'utf8' );
const settings = JSON . parse ( raw );
2026-07-06 22:54:31 +03:00
return {
recap : settings ? . sessionRecapEnabled !== false ,
suggestion : settings ? . sessionSuggestionEnabled !== false ,
};
2026-07-05 23:19:10 +03:00
} catch {
2026-07-06 22:54:31 +03:00
return { recap : true , suggestion : true };
2026-07-05 23:19:10 +03:00
}
};
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 ;
2026-07-06 22:54:31 +03:00
const buildAssistSystemPrompt = ({ recap , suggestion }) => [
2026-07-05 23:19:10 +03:00
'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.' ,
2026-07-06 22:54:31 +03:00
`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.' ,
2026-07-05 23:19:10 +03:00
'Use double quotes for JSON strings, no trailing commas.' ,
2026-07-06 22:54:31 +03:00
]. filter ( Boolean ). join ( '\n' );
2026-07-05 23:19:10 +03:00
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 ) => {
2026-07-06 22:54:31 +03:00
const targets = getSessionAssistTargets ();
if ( ! targets . recap && ! targets . suggestion ) return ;
2026-07-05 23:19:10 +03:00
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 ();
2026-07-06 22:54:31 +03:00
const requestedFields = [ targets . recap ? 'recap' : '' , targets . suggestion ? 'suggestion' : '' ]
. filter ( Boolean )
. join ( ' and ' );
2026-07-05 23:19:10 +03:00
// 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 ,
2026-07-06 22:54:31 +03:00
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 ),
2026-07-05 23:19:10 +03:00
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 );
2026-07-06 22:54:31 +03:00
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 ) : '' ;
2026-07-05 23:19:10 +03:00
// 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 };
};