Files
openchamber/packages/web/server/lib/notifications/template-runtime.js
T

453 lines
15 KiB
JavaScript

import { summarizeText as summarizeSharedText } from '../text/summarization.js';
export const createNotificationTemplateRuntime = (deps) => {
const {
readSettingsFromDisk,
persistSettings,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
resolveGitBinaryForSpawn,
} = deps;
const NOTIFICATION_BODY_MAX_CHARS = 1000;
const ZEN_DEFAULT_MODEL = 'gpt-5-nano';
const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000;
const SESSION_INFO_CACHE_TTL_MS = 60 * 1000;
let validatedZenFallback = null;
let cachedZenModels = null;
let cachedZenModelsTimestamp = 0;
const sessionTitleCache = new Map();
const sessionInfoCache = new Map();
const createTimeoutSignal = (timeoutMs) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return {
signal: controller.signal,
cleanup: () => clearTimeout(timer),
};
};
const formatProjectLabel = (label) => {
if (!label || typeof label !== 'string') return '';
return label
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
};
const resolveNotificationTemplate = (template, variables) => {
if (!template || typeof template !== 'string') return '';
return template.replace(/\{(\w+)\}/g, (_match, key) => {
const value = variables[key];
if (value === undefined || value === null) return '';
return String(value);
});
};
const shouldApplyResolvedTemplateMessage = (template, resolved, variables) => {
if (!resolved) {
return false;
}
if (typeof template !== 'string') {
return true;
}
if (template.includes('{last_message}')) {
return typeof variables?.last_message === 'string' && variables.last_message.trim().length > 0;
}
return true;
};
const fetchFreeZenModels = async () => {
const now = Date.now();
if (cachedZenModels && now - cachedZenModelsTimestamp < ZEN_MODELS_CACHE_TTL) {
return cachedZenModels.models;
}
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
try {
const [zenResponse, metadataResponse] = await Promise.all([
fetch('https://opencode.ai/zen/v1/models', {
signal: controller?.signal,
headers: { Accept: 'application/json' },
}),
fetch('https://models.dev/api.json', {
signal: controller?.signal,
headers: { Accept: 'application/json' },
}),
]);
if (!zenResponse.ok) {
throw new Error(`zen/v1/models responded with status ${zenResponse.status}`);
}
if (!metadataResponse.ok) {
throw new Error(`models.dev responded with status ${metadataResponse.status}`);
}
const data = await zenResponse.json();
const metadata = await metadataResponse.json();
const metadataModels = metadata?.opencode?.models && typeof metadata.opencode.models === 'object'
? metadata.opencode.models
: {};
const allModels = Array.isArray(data?.data) ? data.data : [];
const freeModels = allModels
.filter((model) => {
const id = typeof model?.id === 'string' ? model.id.trim() : '';
const cost = id ? metadataModels[id]?.cost : null;
return id && cost?.input === 0 && cost?.output === 0;
})
.map((model) => ({ id: model.id.trim(), owned_by: model.owned_by }));
cachedZenModels = { models: freeModels };
cachedZenModelsTimestamp = Date.now();
return freeModels;
} finally {
if (timeout) clearTimeout(timeout);
}
};
const resolveZenModel = async (override) => {
const overrideModel = typeof override === 'string' ? override.trim() : '';
let settingsModel = '';
try {
const settings = await readSettingsFromDisk();
if (typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0) {
settingsModel = settings.zenModel.trim();
}
} catch {
}
const candidate = overrideModel || settingsModel;
try {
const models = await fetchFreeZenModels();
const modelIds = models.map((model) => model.id);
if (candidate && modelIds.includes(candidate)) {
return candidate;
}
if (modelIds.includes(ZEN_DEFAULT_MODEL)) {
return ZEN_DEFAULT_MODEL;
}
if (modelIds.length > 0) {
return modelIds[0];
}
} catch {
if (candidate) {
return candidate;
}
}
return validatedZenFallback || ZEN_DEFAULT_MODEL;
};
const validateZenModelAtStartup = async () => {
try {
const freeModels = await fetchFreeZenModels();
const freeModelIds = freeModels.map((model) => model.id);
if (freeModelIds.length > 0) {
validatedZenFallback = freeModelIds[0];
const settings = await readSettingsFromDisk();
const storedModel = typeof settings?.zenModel === 'string' ? settings.zenModel.trim() : '';
if (!storedModel || !freeModelIds.includes(storedModel)) {
const fallback = freeModelIds[0];
console.log(
storedModel
? `[zen] Stored model "${storedModel}" not found in free models, falling back to "${fallback}"`
: `[zen] No model configured, setting default to "${fallback}"`
);
await persistSettings({ zenModel: fallback });
} else {
console.log(`[zen] Stored model "${storedModel}" verified as available`);
}
} else {
console.warn('[zen] No free models returned from API, skipping validation');
}
} catch (error) {
console.warn('[zen] Startup model validation failed (non-blocking):', error?.message || error);
}
};
const summarizeText = async (text, targetLength, zenModel) => {
if (!text || typeof text !== 'string' || text.trim().length === 0) return text;
const result = await summarizeSharedText({
text,
threshold: 0,
maxLength: targetLength,
zenModel: zenModel || ZEN_DEFAULT_MODEL,
mode: 'notification',
});
return typeof result?.summary === 'string' && result.summary.trim().length > 0
? result.summary
: text;
};
const extractTextFromParts = (parts, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
if (!Array.isArray(parts) || parts.length === 0) return '';
const textParts = parts
.filter((part) => part && (part.type === 'text' || typeof part.text === 'string' || typeof part.content === 'string'))
.map((part) => part.text || part.content || '')
.filter(Boolean);
let text = textParts.length > 0 ? textParts.join('\n').trim() : '';
if (maxLength > 0 && text.length > maxLength) {
text = text.slice(0, maxLength);
}
return text;
};
const extractLastMessageText = (payload, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
const info = payload?.properties?.info;
if (!info) return '';
const parts = info.parts || payload?.properties?.parts;
const text = extractTextFromParts(parts, maxLength);
if (text) return text;
const content = info.content;
if (Array.isArray(content)) {
const textContent = content
.filter((entry) => entry && (entry.type === 'text' || typeof entry.text === 'string'))
.map((entry) => entry.text || '')
.filter(Boolean);
if (textContent.length > 0) {
let result = textContent.join('\n').trim();
if (maxLength > 0 && result.length > maxLength) {
result = result.slice(0, maxLength);
}
return result;
}
}
return '';
};
const fetchLastAssistantMessageText = async (sessionId, messageId, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
if (!sessionId) return '';
try {
const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, '');
const response = await fetch(`${url}?limit=5`, {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: AbortSignal.timeout(3000),
});
if (!response.ok) return '';
const messages = await response.json().catch(() => null);
if (!Array.isArray(messages)) return '';
let target = null;
if (messageId) {
target = messages.find((message) => message?.info?.id === messageId && message?.info?.role === 'assistant');
}
if (!target) {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message?.info?.role === 'assistant' && message?.info?.finish === 'stop') {
target = message;
break;
}
}
}
if (!target || !Array.isArray(target.parts)) return '';
return extractTextFromParts(target.parts, maxLength);
} catch {
return '';
}
};
const cacheSessionTitle = (sessionId, title) => {
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof title === 'string' && title.length > 0) {
sessionTitleCache.set(sessionId, title);
}
};
const getCachedSessionTitle = (sessionId) => {
return sessionTitleCache.get(sessionId) ?? null;
};
const maybeCacheSessionInfoFromEvent = (payload) => {
if (!payload || typeof payload !== 'object') return;
const type = payload.type;
if (type !== 'session.updated' && type !== 'session.created') return;
const info = payload.properties?.info;
if (!info || typeof info !== 'object') return;
cacheSessionTitle(info.id, info.title);
};
const fetchSessionInfo = async (sessionId) => {
if (!sessionId) return null;
const cached = sessionInfoCache.get(sessionId);
if (cached && Date.now() - cached.at < SESSION_INFO_CACHE_TTL_MS) {
return cached.data;
}
try {
const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(2000),
});
if (!response.ok) {
console.warn(`[Notification] fetchSessionInfo: ${response.status} for session ${sessionId}`);
return null;
}
const data = await response.json().catch(() => null);
if (data && typeof data === 'object') {
sessionInfoCache.set(sessionId, { data, at: Date.now() });
return data;
}
return null;
} catch (error) {
console.warn(`[Notification] fetchSessionInfo failed for ${sessionId}:`, error?.message || error);
return null;
}
};
const buildTemplateVariables = async (payload, sessionId) => {
const info = payload?.properties?.info || {};
let sessionTitle = payload?.properties?.sessionTitle || payload?.properties?.session?.title || (typeof info.sessionTitle === 'string' ? info.sessionTitle : '') || '';
if (!sessionTitle && sessionId) {
const cached = getCachedSessionTitle(sessionId);
if (cached) {
sessionTitle = cached;
}
}
let sessionInfo = null;
if (!sessionTitle && sessionId) {
sessionInfo = await fetchSessionInfo(sessionId);
if (sessionInfo && typeof sessionInfo.title === 'string') {
sessionTitle = sessionInfo.title;
cacheSessionTitle(sessionId, sessionTitle);
}
}
const agentName = (() => {
const mode = typeof info.agent === 'string' && info.agent.trim().length > 0
? info.agent.trim()
: (typeof info.mode === 'string' ? info.mode.trim() : '');
if (!mode) return 'Agent';
return mode.split(/[-_\s]+/).filter(Boolean)
.map((token) => token.charAt(0).toUpperCase() + token.slice(1)).join(' ');
})();
const modelName = (() => {
const raw = typeof info.modelID === 'string' ? info.modelID.trim()
: (typeof info.model?.modelID === 'string' ? info.model.modelID.trim() : '');
if (!raw) return 'Assistant';
return raw.split(/[-_]+/).filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ');
})();
let projectName = '';
let branch = '';
let worktreeDir = '';
const infoPath = info.path;
if (typeof infoPath?.root === 'string' && infoPath.root.length > 0) {
worktreeDir = infoPath.root;
} else if (typeof infoPath?.cwd === 'string' && infoPath.cwd.length > 0) {
worktreeDir = infoPath.cwd;
}
try {
const settings = await readSettingsFromDisk();
const projects = Array.isArray(settings.projects) ? settings.projects : [];
if (worktreeDir) {
const normalizedDir = worktreeDir.replace(/\/+$/, '');
const matchedProject = projects.find((project) => {
if (!project || typeof project.path !== 'string') return false;
return project.path.replace(/\/+$/, '') === normalizedDir;
});
if (matchedProject && typeof matchedProject.label === 'string' && matchedProject.label.trim().length > 0) {
projectName = matchedProject.label.trim();
} else {
projectName = normalizedDir.split('/').filter(Boolean).pop() || '';
}
} else {
const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : '';
const activeProject = activeId ? projects.find((project) => project && project.id === activeId) : projects[0];
if (activeProject) {
projectName = typeof activeProject.label === 'string' && activeProject.label.trim().length > 0
? activeProject.label.trim()
: typeof activeProject.path === 'string'
? activeProject.path.split('/').pop() || ''
: '';
worktreeDir = typeof activeProject.path === 'string' ? activeProject.path : '';
}
}
} catch {
if (worktreeDir && !projectName) {
projectName = worktreeDir.split('/').filter(Boolean).pop() || '';
}
}
if (worktreeDir) {
try {
const { simpleGit } = await import('simple-git');
const git = simpleGit({
baseDir: worktreeDir,
spawnOptions: { windowsHide: true },
binary: resolveGitBinaryForSpawn(),
});
branch = await Promise.race([
git.revparse(['--abbrev-ref', 'HEAD']),
new Promise((_, reject) => setTimeout(() => reject(new Error('git timeout')), 3000)),
]).catch(() => '');
} catch {
}
}
return {
project_name: formatProjectLabel(projectName),
worktree: worktreeDir,
branch: typeof branch === 'string' ? branch.trim() : '',
session_name: sessionTitle,
agent_name: agentName,
model_name: modelName,
last_message: '',
session_id: sessionId || '',
};
};
const getCachedZenModels = () => cachedZenModels;
return {
createTimeoutSignal,
formatProjectLabel,
resolveNotificationTemplate,
shouldApplyResolvedTemplateMessage,
fetchFreeZenModels,
resolveZenModel,
validateZenModelAtStartup,
summarizeText,
extractTextFromParts,
extractLastMessageText,
fetchLastAssistantMessageText,
maybeCacheSessionInfoFromEvent,
buildTemplateVariables,
getCachedZenModels,
};
};