2026-04-18 13:46:51 +03:00
|
|
|
import { summarizeText as summarizeSharedText } from '../text/summarization.js';
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
export const createNotificationTemplateRuntime = (deps) => {
|
|
|
|
|
const {
|
|
|
|
|
readSettingsFromDisk,
|
|
|
|
|
buildOpenCodeUrl,
|
|
|
|
|
getOpenCodeAuthHeaders,
|
|
|
|
|
resolveGitBinaryForSpawn,
|
|
|
|
|
} = deps;
|
|
|
|
|
|
|
|
|
|
const NOTIFICATION_BODY_MAX_CHARS = 1000;
|
|
|
|
|
const SESSION_INFO_CACHE_TTL_MS = 60 * 1000;
|
|
|
|
|
|
2026-05-19 02:06:52 +03:00
|
|
|
const cachedZenModels = { models: [] };
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
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 '';
|
2026-08-15 10:16:24 +03:00
|
|
|
// Folder names are shown exactly as they are on disk — no title-casing.
|
|
|
|
|
return label.trim();
|
2026-03-31 18:47:00 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-19 02:06:52 +03:00
|
|
|
const fetchFreeZenModels = async () => [];
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
const resolveZenModel = async (override) => {
|
2026-04-30 13:07:37 +03:00
|
|
|
const overrideModel = typeof override === 'string' ? override.trim() : '';
|
2026-05-19 02:06:52 +03:00
|
|
|
if (overrideModel) return overrideModel;
|
|
|
|
|
const settings = await readSettingsFromDisk().catch(() => ({}));
|
|
|
|
|
return typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0
|
|
|
|
|
? settings.zenModel.trim()
|
|
|
|
|
: '';
|
2026-03-31 18:47:00 +03:00
|
|
|
};
|
|
|
|
|
|
2026-05-19 02:06:52 +03:00
|
|
|
const validateZenModelAtStartup = async () => {};
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
const summarizeText = async (text, targetLength, zenModel) => {
|
|
|
|
|
if (!text || typeof text !== 'string' || text.trim().length === 0) return text;
|
2026-04-18 13:46:51 +03:00
|
|
|
const result = await summarizeSharedText({
|
|
|
|
|
text,
|
|
|
|
|
threshold: 0,
|
|
|
|
|
maxLength: targetLength,
|
2026-05-19 02:06:52 +03:00
|
|
|
zenModel,
|
2026-04-18 13:46:51 +03:00
|
|
|
mode: 'notification',
|
|
|
|
|
});
|
|
|
|
|
return typeof result?.summary === 'string' && result.summary.trim().length > 0
|
|
|
|
|
? result.summary
|
|
|
|
|
: text;
|
2026-03-31 18:47:00 +03:00
|
|
|
};
|
|
|
|
|
|
2026-06-16 15:21:27 +03:00
|
|
|
const isNotificationTextPart = (part) => {
|
|
|
|
|
if (!part || typeof part !== 'object') return false;
|
|
|
|
|
if (part.type !== 'text') return false;
|
|
|
|
|
return typeof part.text === 'string' || typeof part.content === 'string';
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
const extractTextFromParts = (parts, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
|
|
|
|
|
if (!Array.isArray(parts) || parts.length === 0) return '';
|
|
|
|
|
|
|
|
|
|
const textParts = parts
|
2026-06-16 15:21:27 +03:00
|
|
|
.filter(isNotificationTextPart)
|
2026-03-31 18:47:00 +03:00
|
|
|
.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
|
2026-06-16 15:21:27 +03:00
|
|
|
.filter(isNotificationTextPart)
|
2026-03-31 18:47:00 +03:00
|
|
|
.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,
|
|
|
|
|
};
|
|
|
|
|
};
|