Add "Assistant Workspace" feature that lets users designate a directory (e.g. Obsidian vault) containing 4 Markdown files (soul.md, user.md, memory.md, claude.md) to define AI personality, user profile, long-term memory, and execution rules. These files are automatically injected into the system prompt for assistant project sessions. Core library (src/lib/assistant-workspace.ts): - Workspace validation, initialization with template files - File loading with 8000-char truncation, XML prompt assembly (40k limit) - State management via .assistant/state.json - Directory docs generation (README.ai.md + PATH.ai.md) - Case-insensitive file name fallback (soul.md → SOUL.md) System prompt injection (src/app/api/chat/route.ts): - Scoped to sessions where working_directory === workspace path - Workspace prompt prepended before session-level system prompt - Onboarding/check-in instructions injected as <assistant-project-task> - autoTrigger flag skips user message save and title update Chat-based onboarding (10 questions) & daily check-in (3 questions): - AI conducts conversation, outputs structured completion code blocks - Frontend detects onboarding-complete/checkin-complete blocks - Backend calls Claude API to generate file content from answers - Fallback to raw answers if API call fails Auto-trigger pipeline: - Mount trigger (500ms delay) + focus fallback in MessageInput - assistantTriggerFiredRef prevents duplicate triggers per mount - hookTriggeredSessionId in state.json prevents cross-reload duplicates - Daily check-in only triggers in the most recent workspace session - hookTriggeredSessionId cleared via __clear__ sentinel after completion API endpoints: - GET/PUT /api/settings/workspace — path CRUD + file status - POST /api/workspace/session — create (onboarding) or reuse (check-in) - POST /api/workspace/onboarding — AI-generate soul.md + user.md - POST /api/workspace/checkin — AI-update memory.md + user.md - POST /api/workspace/hook-triggered — set/clear trigger guard - GET /api/workspace/latest-session — latest session lookup - POST /api/workspace/docs — refresh directory documentation UI: - Settings "Assistant" tab with workspace path, file status, controls - Violet badge on workspace project group in ChatListPanel - useAssistantWorkspace hook for state queries Also includes: - 102 passing tests (16 new regression tests for workspace logic) - i18n keys for en.ts and zh.ts - Handover documentation and execution plan Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
523 lines
21 KiB
TypeScript
523 lines
21 KiB
TypeScript
import { NextRequest } from 'next/server';
|
|
import { streamClaude } from '@/lib/claude-client';
|
|
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId, acquireSessionLock, renewSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db';
|
|
import { notifySessionStart, notifySessionComplete, notifySessionError } from '@/lib/telegram-bot';
|
|
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types';
|
|
import crypto from 'crypto';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
export const runtime = 'nodejs';
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
export async function POST(request: NextRequest) {
|
|
let activeSessionId: string | undefined;
|
|
let activeLockId: string | undefined;
|
|
|
|
try {
|
|
const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean } = await request.json();
|
|
const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger } = body;
|
|
|
|
console.log('[chat API] content length:', content.length, 'first 200 chars:', content.slice(0, 200));
|
|
console.log('[chat API] systemPromptAppend:', systemPromptAppend ? `${systemPromptAppend.length} chars` : 'none');
|
|
|
|
if (!session_id || !content) {
|
|
return new Response(JSON.stringify({ error: 'session_id and content are required' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const session = getSession(session_id);
|
|
if (!session) {
|
|
return new Response(JSON.stringify({ error: 'Session not found' }), {
|
|
status: 404,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// Acquire exclusive lock for this session to prevent concurrent requests
|
|
const lockId = crypto.randomBytes(8).toString('hex');
|
|
const lockAcquired = acquireSessionLock(session_id, lockId, `chat-${process.pid}`, 600);
|
|
if (!lockAcquired) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Session is busy processing another request', code: 'SESSION_BUSY' }),
|
|
{ status: 409, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
}
|
|
activeSessionId = session_id;
|
|
activeLockId = lockId;
|
|
setSessionRuntimeStatus(session_id, 'running');
|
|
|
|
// Telegram notification: session started (fire-and-forget)
|
|
const telegramNotifyOpts = {
|
|
sessionId: session_id,
|
|
sessionTitle: session.title !== 'New Chat' ? session.title : content.slice(0, 50),
|
|
workingDirectory: session.working_directory,
|
|
};
|
|
notifySessionStart(telegramNotifyOpts).catch(() => {});
|
|
|
|
// Save user message — persist file metadata so attachments survive page reload
|
|
// Skip saving for autoTrigger messages (invisible system triggers for assistant hooks)
|
|
let savedContent = content;
|
|
let fileMeta: Array<{ id: string; name: string; type: string; size: number; filePath: string }> | undefined;
|
|
if (!autoTrigger) {
|
|
if (files && files.length > 0) {
|
|
const workDir = session.working_directory;
|
|
const uploadDir = path.join(workDir, '.codepilot-uploads');
|
|
if (!fs.existsSync(uploadDir)) {
|
|
fs.mkdirSync(uploadDir, { recursive: true });
|
|
}
|
|
fileMeta = files.map((f) => {
|
|
const safeName = path.basename(f.name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
const filePath = path.join(uploadDir, `${Date.now()}-${safeName}`);
|
|
const buffer = Buffer.from(f.data, 'base64');
|
|
fs.writeFileSync(filePath, buffer);
|
|
return { id: f.id, name: f.name, type: f.type, size: buffer.length, filePath };
|
|
});
|
|
savedContent = `<!--files:${JSON.stringify(fileMeta)}-->${content}`;
|
|
}
|
|
addMessage(session_id, 'user', savedContent);
|
|
|
|
// Auto-generate title from first message if still default
|
|
if (session.title === 'New Chat') {
|
|
const title = content.slice(0, 50) + (content.length > 50 ? '...' : '');
|
|
updateSessionTitle(session_id, title);
|
|
}
|
|
}
|
|
|
|
// Determine model: request override > session model > default setting
|
|
const effectiveModel = model || session.model || getSetting('default_model') || undefined;
|
|
|
|
// Persist model and provider to session so usage stats can group by model+provider.
|
|
// This runs on every message but the DB writes are cheap (single UPDATE by PK).
|
|
if (effectiveModel && effectiveModel !== session.model) {
|
|
updateSessionModel(session_id, effectiveModel);
|
|
}
|
|
|
|
// Resolve provider: explicit provider_id > default_provider_id > environment variables
|
|
let resolvedProvider: import('@/types').ApiProvider | undefined;
|
|
const effectiveProviderId = provider_id || session.provider_id || '';
|
|
if (effectiveProviderId && effectiveProviderId !== 'env') {
|
|
resolvedProvider = getProvider(effectiveProviderId);
|
|
if (!resolvedProvider) {
|
|
// Requested provider not found, try default
|
|
const defaultId = getDefaultProviderId();
|
|
if (defaultId) {
|
|
resolvedProvider = getProvider(defaultId);
|
|
}
|
|
}
|
|
} else if (!effectiveProviderId) {
|
|
// No provider specified, try default
|
|
const defaultId = getDefaultProviderId();
|
|
if (defaultId) {
|
|
resolvedProvider = getProvider(defaultId);
|
|
}
|
|
}
|
|
// effectiveProviderId === 'env' → resolvedProvider stays undefined → uses env vars
|
|
|
|
const providerName = resolvedProvider?.name || '';
|
|
if (providerName !== (session.provider_name || '')) {
|
|
updateSessionProvider(session_id, providerName);
|
|
}
|
|
const persistProviderId = effectiveProviderId || provider_id || '';
|
|
if (persistProviderId !== (session.provider_id || '')) {
|
|
updateSessionProviderId(session_id, persistProviderId);
|
|
}
|
|
|
|
// Determine permission mode from chat mode: code → acceptEdits, plan → plan, ask → default (no tools)
|
|
const effectiveMode = mode || session.mode || 'code';
|
|
let permissionMode: string;
|
|
let systemPromptOverride: string | undefined;
|
|
switch (effectiveMode) {
|
|
case 'plan':
|
|
permissionMode = 'plan';
|
|
break;
|
|
case 'ask':
|
|
permissionMode = 'default';
|
|
systemPromptOverride = (session.system_prompt || '') +
|
|
'\n\nYou are in Ask mode. Answer questions and provide information only. Do not use any tools, do not read or write files, do not execute commands. Only respond with text.';
|
|
break;
|
|
default: // 'code'
|
|
permissionMode = 'acceptEdits';
|
|
break;
|
|
}
|
|
|
|
const abortController = new AbortController();
|
|
|
|
// Handle client disconnect
|
|
request.signal.addEventListener('abort', () => {
|
|
abortController.abort();
|
|
});
|
|
|
|
// Convert file attachments to the format expected by streamClaude.
|
|
// Include filePath from the already-saved files so claude-client can
|
|
// reference the on-disk copies instead of writing them again.
|
|
const fileAttachments: FileAttachment[] | undefined = files && files.length > 0
|
|
? files.map((f, i) => {
|
|
const meta = fileMeta?.find((m: { id: string }) => m.id === f.id);
|
|
return {
|
|
id: f.id || `file-${Date.now()}-${i}`,
|
|
name: f.name,
|
|
type: f.type,
|
|
size: f.size,
|
|
data: (meta?.filePath && !f.type.startsWith('image/')) ? '' : f.data, // Keep base64 for images (needed for vision); clear for non-images (read from disk)
|
|
filePath: meta?.filePath,
|
|
};
|
|
})
|
|
: undefined;
|
|
|
|
// Load assistant workspace prompt if configured
|
|
let workspacePrompt = '';
|
|
let assistantProjectInstructions = '';
|
|
try {
|
|
const workspacePath = getSetting('assistant_workspace_path');
|
|
if (workspacePath) {
|
|
const { loadWorkspaceFiles, assembleWorkspacePrompt, loadState, needsDailyCheckIn } = await import('@/lib/assistant-workspace');
|
|
|
|
// Only inject workspace files for assistant project sessions
|
|
const sessionWd = session.working_directory || '';
|
|
const isAssistantProject = sessionWd === workspacePath;
|
|
|
|
if (isAssistantProject) {
|
|
const files = loadWorkspaceFiles(workspacePath);
|
|
workspacePrompt = assembleWorkspacePrompt(files);
|
|
|
|
const state = loadState(workspacePath);
|
|
|
|
if (!state.onboardingComplete) {
|
|
// First-time onboarding: instruct AI to ask onboarding questions
|
|
assistantProjectInstructions = `<assistant-project-task type="onboarding">
|
|
You are now in the assistant workspace onboarding session. Your task is to interview the user to build their profile.
|
|
|
|
Ask the following 10 questions ONE AT A TIME. Wait for the user's answer before asking the next question. Be conversational and friendly.
|
|
|
|
1. How should I address you?
|
|
2. What name should I use for myself?
|
|
3. Do you prefer "concise and direct" or "detailed explanations"?
|
|
4. Do you prefer "minimal interruptions" or "proactive suggestions"?
|
|
5. What are your three hard boundaries?
|
|
6. What are your three most important current goals?
|
|
7. Do you prefer output as "lists", "reports", or "conversation summaries"?
|
|
8. What information may be written to long-term memory?
|
|
9. What information must never be written to long-term memory?
|
|
10. What three things should I do first when entering a project?
|
|
|
|
After all questions are answered, output a summary of the collected answers in exactly this format — this is critical for the system to process your answers:
|
|
|
|
\`\`\`onboarding-complete
|
|
{"q1":"answer1","q2":"answer2","q3":"answer3","q4":"answer4","q5":"answer5","q6":"answer6","q7":"answer7","q8":"answer8","q9":"answer9","q10":"answer10"}
|
|
\`\`\`
|
|
|
|
Do NOT try to write files yourself. The system will automatically generate soul.md and user.md from your collected answers.
|
|
|
|
Start by greeting the user and asking the first question.
|
|
</assistant-project-task>`;
|
|
} else if (needsDailyCheckIn(state)) {
|
|
// Daily check-in: instruct AI to ask 3 quick questions
|
|
assistantProjectInstructions = `<assistant-project-task type="daily-checkin">
|
|
You are now in the assistant workspace daily check-in session. Ask the user these 3 questions ONE AT A TIME:
|
|
|
|
1. What did you work on or accomplish today?
|
|
2. Any changes to your current priorities or goals?
|
|
3. Anything you'd like me to remember going forward?
|
|
|
|
After collecting all 3 answers, output a summary in exactly this format:
|
|
|
|
\`\`\`checkin-complete
|
|
{"q1":"answer1","q2":"answer2","q3":"answer3"}
|
|
\`\`\`
|
|
|
|
Do NOT try to write files yourself. The system will automatically update memory.md and user.md from your collected answers.
|
|
|
|
Start by greeting the user and asking the first question.
|
|
</assistant-project-task>`;
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[chat API] Failed to load assistant workspace:', e);
|
|
}
|
|
|
|
// Append per-request system prompt (e.g. skill injection for image generation)
|
|
let finalSystemPrompt = systemPromptOverride || session.system_prompt || undefined;
|
|
if (systemPromptAppend) {
|
|
finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + systemPromptAppend;
|
|
}
|
|
|
|
// Workspace prompt goes first (base personality), session prompt after (task override)
|
|
if (workspacePrompt) {
|
|
finalSystemPrompt = workspacePrompt + '\n\n' + (finalSystemPrompt || '');
|
|
}
|
|
|
|
// Assistant project instructions go after workspace prompt
|
|
if (assistantProjectInstructions) {
|
|
finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + assistantProjectInstructions;
|
|
}
|
|
|
|
// Load recent conversation history from DB as fallback context.
|
|
// This is used when SDK session resume is unavailable or fails,
|
|
// so the model still has conversation context.
|
|
const { messages: recentMsgs } = getMessages(session_id, { limit: 50 });
|
|
// Exclude the user message we just saved (last in the list) — it's already the prompt
|
|
const historyMsgs = recentMsgs.slice(0, -1).map(m => ({
|
|
role: m.role as 'user' | 'assistant',
|
|
content: m.content,
|
|
}));
|
|
|
|
// Stream Claude response, using SDK session ID for resume if available
|
|
console.log('[chat API] streamClaude params:', {
|
|
promptLength: content.length,
|
|
promptFirst200: content.slice(0, 200),
|
|
sdkSessionId: session.sdk_session_id || 'none',
|
|
systemPromptLength: finalSystemPrompt?.length || 0,
|
|
systemPromptFirst200: finalSystemPrompt?.slice(0, 200) || 'none',
|
|
});
|
|
const stream = streamClaude({
|
|
prompt: content,
|
|
sessionId: session_id,
|
|
sdkSessionId: session.sdk_session_id || undefined,
|
|
model: effectiveModel,
|
|
systemPrompt: finalSystemPrompt,
|
|
workingDirectory: session.sdk_cwd || session.working_directory || undefined,
|
|
abortController,
|
|
permissionMode,
|
|
files: fileAttachments,
|
|
imageAgentMode: !!systemPromptAppend,
|
|
toolTimeoutSeconds: toolTimeout || 300,
|
|
provider: resolvedProvider,
|
|
conversationHistory: historyMsgs,
|
|
onRuntimeStatusChange: (status: string) => {
|
|
try { setSessionRuntimeStatus(session_id, status); } catch { /* best effort */ }
|
|
},
|
|
});
|
|
|
|
// Tee the stream: one for client, one for collecting the response
|
|
const [streamForClient, streamForCollect] = stream.tee();
|
|
|
|
// Periodically renew the session lock so long-running tasks don't expire
|
|
const lockRenewalInterval = setInterval(() => {
|
|
try { renewSessionLock(session_id, lockId, 600); } catch { /* best effort */ }
|
|
}, 60_000);
|
|
|
|
// Save assistant message in background, with cleanup callback to release lock
|
|
collectStreamResponse(streamForCollect, session_id, telegramNotifyOpts, () => {
|
|
clearInterval(lockRenewalInterval);
|
|
releaseSessionLock(session_id, lockId);
|
|
setSessionRuntimeStatus(session_id, 'idle');
|
|
});
|
|
|
|
return new Response(streamForClient, {
|
|
headers: {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
// Release lock and reset status on error (only if lock was acquired)
|
|
if (activeSessionId && activeLockId) {
|
|
try {
|
|
releaseSessionLock(activeSessionId, activeLockId);
|
|
setSessionRuntimeStatus(activeSessionId, 'idle', error instanceof Error ? error.message : 'Unknown error');
|
|
} catch { /* best effort */ }
|
|
}
|
|
|
|
const message = error instanceof Error ? error.message : 'Internal server error';
|
|
return new Response(JSON.stringify({ error: message }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function collectStreamResponse(
|
|
stream: ReadableStream<string>,
|
|
sessionId: string,
|
|
telegramOpts: { sessionId?: string; sessionTitle?: string; workingDirectory?: string },
|
|
onComplete?: () => void,
|
|
) {
|
|
const reader = stream.getReader();
|
|
const contentBlocks: MessageContentBlock[] = [];
|
|
let currentText = '';
|
|
let tokenUsage: TokenUsage | null = null;
|
|
let hasError = false;
|
|
let errorMessage = '';
|
|
// Dedup layer: skip duplicate tool_result events by tool_use_id
|
|
const seenToolResultIds = new Set<string>();
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
const lines = value.split('\n');
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
try {
|
|
const event: SSEEvent = JSON.parse(line.slice(6));
|
|
if (event.type === 'permission_request' || event.type === 'tool_output') {
|
|
// Skip permission_request and tool_output events - not saved as message content
|
|
} else if (event.type === 'text') {
|
|
currentText += event.data;
|
|
} else if (event.type === 'tool_use') {
|
|
// Flush any accumulated text before the tool use block
|
|
if (currentText.trim()) {
|
|
contentBlocks.push({ type: 'text', text: currentText });
|
|
currentText = '';
|
|
}
|
|
try {
|
|
const toolData = JSON.parse(event.data);
|
|
contentBlocks.push({
|
|
type: 'tool_use',
|
|
id: toolData.id,
|
|
name: toolData.name,
|
|
input: toolData.input,
|
|
});
|
|
} catch {
|
|
// skip malformed tool_use data
|
|
}
|
|
} else if (event.type === 'tool_result') {
|
|
try {
|
|
const resultData = JSON.parse(event.data);
|
|
const newBlock = {
|
|
type: 'tool_result' as const,
|
|
tool_use_id: resultData.tool_use_id,
|
|
content: resultData.content,
|
|
is_error: resultData.is_error || false,
|
|
};
|
|
// Last-wins: if same tool_use_id already exists, replace it
|
|
// (user handler's result may be more complete than PostToolUse's)
|
|
if (seenToolResultIds.has(resultData.tool_use_id)) {
|
|
const idx = contentBlocks.findIndex(
|
|
(b) => b.type === 'tool_result' && 'tool_use_id' in b && b.tool_use_id === resultData.tool_use_id
|
|
);
|
|
if (idx >= 0) {
|
|
contentBlocks[idx] = newBlock;
|
|
}
|
|
} else {
|
|
seenToolResultIds.add(resultData.tool_use_id);
|
|
contentBlocks.push(newBlock);
|
|
}
|
|
} catch {
|
|
// skip malformed tool_result data
|
|
}
|
|
} else if (event.type === 'status') {
|
|
// Capture SDK session_id and model from init event and persist them
|
|
try {
|
|
const statusData = JSON.parse(event.data);
|
|
if (statusData.session_id) {
|
|
updateSdkSessionId(sessionId, statusData.session_id);
|
|
}
|
|
if (statusData.model) {
|
|
updateSessionModel(sessionId, statusData.model);
|
|
}
|
|
} catch {
|
|
// skip malformed status data
|
|
}
|
|
} else if (event.type === 'task_update') {
|
|
// Sync SDK TodoWrite tasks to local DB
|
|
try {
|
|
const taskData = JSON.parse(event.data);
|
|
if (taskData.session_id && taskData.todos) {
|
|
syncSdkTasks(taskData.session_id, taskData.todos);
|
|
}
|
|
} catch {
|
|
// skip malformed task_update data
|
|
}
|
|
} else if (event.type === 'error') {
|
|
hasError = true;
|
|
errorMessage = event.data || 'Unknown error';
|
|
} else if (event.type === 'result') {
|
|
try {
|
|
const resultData = JSON.parse(event.data);
|
|
if (resultData.usage) {
|
|
tokenUsage = resultData.usage;
|
|
}
|
|
if (resultData.is_error) {
|
|
hasError = true;
|
|
}
|
|
// Also capture session_id from result if we missed it from init
|
|
if (resultData.session_id) {
|
|
updateSdkSessionId(sessionId, resultData.session_id);
|
|
}
|
|
} catch {
|
|
// skip malformed result data
|
|
}
|
|
}
|
|
} catch {
|
|
// skip malformed lines
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flush any remaining text
|
|
if (currentText.trim()) {
|
|
contentBlocks.push({ type: 'text', text: currentText });
|
|
}
|
|
|
|
if (contentBlocks.length > 0) {
|
|
// If the message is text-only (no tool calls), store as plain text
|
|
// for backward compatibility with existing message rendering.
|
|
// If it contains tool calls, store as structured JSON.
|
|
const hasToolBlocks = contentBlocks.some(
|
|
(b) => b.type === 'tool_use' || b.type === 'tool_result'
|
|
);
|
|
|
|
const content = hasToolBlocks
|
|
? JSON.stringify(contentBlocks)
|
|
: contentBlocks
|
|
.filter((b): b is Extract<MessageContentBlock, { type: 'text' }> => b.type === 'text')
|
|
.map((b) => b.text)
|
|
.join('')
|
|
.trim();
|
|
|
|
if (content) {
|
|
addMessage(
|
|
sessionId,
|
|
'assistant',
|
|
content,
|
|
tokenUsage ? JSON.stringify(tokenUsage) : null,
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
hasError = true;
|
|
errorMessage = e instanceof Error ? e.message : 'Stream reading error';
|
|
// Stream reading error - best effort save
|
|
if (currentText.trim()) {
|
|
contentBlocks.push({ type: 'text', text: currentText });
|
|
}
|
|
if (contentBlocks.length > 0) {
|
|
const hasToolBlocks = contentBlocks.some(
|
|
(b) => b.type === 'tool_use' || b.type === 'tool_result'
|
|
);
|
|
const content = hasToolBlocks
|
|
? JSON.stringify(contentBlocks)
|
|
: contentBlocks
|
|
.filter((b): b is Extract<MessageContentBlock, { type: 'text' }> => b.type === 'text')
|
|
.map((b) => b.text)
|
|
.join('')
|
|
.trim();
|
|
if (content) {
|
|
addMessage(sessionId, 'assistant', content);
|
|
}
|
|
}
|
|
} finally {
|
|
// Telegram notifications: completion or error (fire-and-forget)
|
|
if (hasError) {
|
|
notifySessionError(errorMessage, telegramOpts).catch(() => {});
|
|
} else {
|
|
// Extract text summary for the completion notification
|
|
const textSummary = contentBlocks
|
|
.filter((b): b is Extract<MessageContentBlock, { type: 'text' }> => b.type === 'text')
|
|
.map((b) => b.text)
|
|
.join('')
|
|
.trim();
|
|
notifySessionComplete(textSummary || undefined, telegramOpts).catch(() => {});
|
|
}
|
|
onComplete?.();
|
|
}
|
|
}
|