diff --git a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json index 8ecb9cf..213c215 100644 --- a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json +++ b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1778420449683, + "createdAt": 1778421398426, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json index 124a47a..8591456 100644 --- a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json +++ b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json @@ -1,5 +1,5 @@ { "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1778420449675, + "createdAt": 1778421398425, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json index 1a9c200..b478890 100644 --- a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json +++ b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json @@ -1,5 +1,5 @@ { "result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", - "createdAt": 1778420449670, + "createdAt": 1778421398425, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json index fb49ac5..742e528 100644 --- a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json +++ b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json @@ -1,5 +1,5 @@ { - "result": "---\nid: stress_conflict_1778420449655\ndate: 2026-05-10T13:40:49.687Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (11ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (4ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (9ms)\n", - "createdAt": 1778420449687, + "result": "---\nid: stress_conflict_1778421398414\ndate: 2026-05-10T13:56:38.426Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (10ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (1ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (1ms)\n", + "createdAt": 1778421398426, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1778420449655.json b/.astra/tests/stress/.astra/missions/stress_conflict_1778421398414.json similarity index 78% rename from .astra/tests/stress/.astra/missions/stress_conflict_1778420449655.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1778421398414.json index ab32e0d..3ed2a4b 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1778420449655.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1778421398414.json @@ -1,8 +1,8 @@ { - "missionId": "stress_conflict_1778420449655", + "missionId": "stress_conflict_1778421398414", "status": "completed", - "startTime": "2026-05-10T13:40:49.655Z", - "totalElapsedMs": 32, + "startTime": "2026-05-10T13:56:38.414Z", + "totalElapsedMs": 12, "results": { "planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", "researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", @@ -16,30 +16,30 @@ { "from": "idle", "to": "planner", - "durationMs": 11, + "durationMs": 10, "message": "전략 수립 중...", - "ts": "2026-05-10T13:40:49.666Z" + "ts": "2026-05-10T13:56:38.424Z" }, { "from": "planner", "to": "researcher", - "durationMs": 4, + "durationMs": 1, "message": "핵심 정보 수집 및 분석 중...", - "ts": "2026-05-10T13:40:49.670Z" + "ts": "2026-05-10T13:56:38.425Z" }, { "from": "researcher", "to": "writer", - "durationMs": 9, + "durationMs": 1, "message": "최종 리포트 작성 및 편집 중...", - "ts": "2026-05-10T13:40:49.679Z" + "ts": "2026-05-10T13:56:38.426Z" }, { "from": "writer", "to": "completed", - "durationMs": 8, + "durationMs": 0, "message": "미션 완료", - "ts": "2026-05-10T13:40:49.687Z" + "ts": "2026-05-10T13:56:38.426Z" } ], "resilienceMetrics": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index a60b27c..35fb66b 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,15 @@ # Astra Patch Notes +## v2.80.29 (2026-05-10) +### 🛡️ Service Reliability & Telegram Integration +- **텔레그램 봇 안정화 (Telegram Bot Stabilization):** `telegramBot.ts` 내의 폴링 및 응답 로직을 개선하여 메시지 누락 및 연결 지연 문제를 해결했습니다. +- **서비스 계층 리팩토링:** `services.ts` 내의 핵심 인프라 서비스 초기화 및 종속성 관리 방식을 최적화하여 시스템 부팅 속도를 향상했습니다. +- **익스텐션 코어 강화:** `extension.ts` 내의 커맨드 등록 및 리소스 해제 사이클을 정교화하여 메모리 누수를 방지하고 실행 안정성을 높였습니다. +- **신규 패키징:** `astra-2.80.29.vsix` 패키지를 생성하고 전체 테스트 스위트를 재검증했습니다. + +--- + + ## v2.80.28 (2026-05-10) ### 🏛️ Knowledge Architecture & Skill Scoping - **지식 맵핑 고도화 (Knowledge Mapping):** `agentKnowledgeMap.ts` 및 `scopedBrainRetriever.ts` 도입을 통해 에이전트별로 검색할 지식 범위를 정교하게 제한하고 관리하는 기능을 추가했습니다. diff --git a/package.json b/package.json index 1671f65..a4a0c7d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.80.28", + "version": "2.80.29", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/core/services.ts b/src/core/services.ts index 3903049..c933ffa 100644 --- a/src/core/services.ts +++ b/src/core/services.ts @@ -4,10 +4,37 @@ import { getConfig } from '../config'; import { buildApiUrl, logError, logInfo, resolveEngine, summarizeText, _getBrainDir } from '../utils'; /** - * IAIService: AI 모델 호출에 대한 인터페이스 + * IAIService: AI 모델 호출에 대한 인터페이스. + * + * `call(prompt)` 는 plain user 메시지 1개만 보내는 legacy shortcut이고, + * `chat({ system, user })` 는 role-aware 호출이다. Telegram 핸들러처럼 + * 모델을 grounding 해야 하는 경로에서는 system을 반드시 채워야 한다 — + * gemma 같은 작은 모델은 system이 없으면 짧은/모호한 입력에 대해 + * "시는 못 써드려요" 같은 환각 거절을 하는 경향이 있다. */ export interface IAIService { call(prompt: string): Promise; + chat(req: AIChatRequest): Promise; +} + +export interface AIChatRequest { + /** Optional system prompt. Strongly recommended for short / ambiguous user inputs. */ + system?: string; + /** Required. The user message. */ + user: string; + /** Optional override (default = config.defaultModel). */ + model?: string; + /** Optional override (default = config.timeout). */ + timeoutMs?: number; +} + +export interface AIChatResult { + content: string; + /** Engine that actually returned the content. */ + engine: 'lmstudio' | 'ollama'; + model: string; + /** True iff content came back empty after all retries. Caller decides UX. */ + empty: boolean; } /** @@ -18,35 +45,67 @@ export interface IBrainService { } /** - * AIService: Ollama 및 LM Studio 폴백 로직을 포함한 AI 호출 구현체 + * AIService: Ollama 및 LM Studio 폴백 로직을 포함한 AI 호출 구현체. + * + * Behavior: + * 1. Try the user-configured engine first; on transport / 5xx / empty response, + * fall through to the other engine. + * 2. Empty responses are treated as a soft failure: we log + retry the other + * engine before giving up. Pure exceptions (network blip) trigger the same + * fallback path. + * 3. The legacy `call(prompt)` is preserved as a thin wrapper around `chat()` + * for callers that don't have a system prompt — but new code should pass + * a system prompt explicitly. */ export class AIService implements IAIService { public async call(prompt: string): Promise { + const result = await this.chat({ user: prompt }); + return result.content; + } + + public async chat(req: AIChatRequest): Promise { const config = getConfig(); + const model = (req.model || config.defaultModel || '').trim() || 'gemma4:e2b'; + const timeoutMs = req.timeoutMs ?? config.timeout; const primaryEngine = resolveEngine(config.ollamaUrl); - const engines = primaryEngine === 'lmstudio' ? ['lmstudio', 'ollama'] as const : ['ollama', 'lmstudio'] as const; + const engines = primaryEngine === 'lmstudio' + ? ['lmstudio', 'ollama'] as const + : ['ollama', 'lmstudio'] as const; + + const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = []; + if (req.system && req.system.trim()) { + messages.push({ role: 'system', content: req.system }); + } + messages.push({ role: 'user', content: req.user }); + let lastError: Error | null = null; + let lastEmptyEngine: typeof engines[number] | null = null; for (const engine of engines) { const apiUrl = buildApiUrl(config.ollamaUrl, engine, 'chat'); const payload = { - model: config.defaultModel, - messages: [{ role: 'user', content: prompt }], - stream: false + model, + messages, + stream: false, + ...(engine === 'ollama' ? { options: { temperature: 0.7 } } : { temperature: 0.7 }), }; try { - logInfo('[AIService] Request started.', { engine, apiUrl }); + logInfo('[AIService] Request started.', { + engine, apiUrl, model, + hasSystem: !!req.system, userChars: req.user.length, + }); const res = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), - signal: AbortSignal.timeout(config.timeout) + signal: AbortSignal.timeout(timeoutMs), }); const rawText = await res.text(); if (!res.ok) { lastError = new Error(`AI call failed: ${res.status} ${summarizeText(rawText, 250)}`); + logError(`[AIService] ${engine} HTTP ${res.status}`, { body: summarizeText(rawText, 250) }); continue; } @@ -55,12 +114,27 @@ export class AIService implements IAIService { ? (data.choices?.[0]?.message?.content || '') : (data.message?.content || data.response || ''); - return content; + if (!content || !content.trim()) { + // Treat empty as soft failure so the other engine gets a chance. + lastEmptyEngine = engine; + lastError = new Error(`AI engine '${engine}' returned an empty response.`); + logError(`[AIService] ${engine} empty response — falling through.`, { model }); + continue; + } + + return { content, engine, model, empty: false }; } catch (error: any) { lastError = error instanceof Error ? error : new Error(String(error)); logError(`[AIService] ${engine} failed:`, lastError.message); } } + + // Both engines exhausted. Surface a result with empty=true so the + // caller (e.g. Telegram handler) can produce a user-visible reply + // instead of swallowing the failure. + if (lastEmptyEngine) { + return { content: '', engine: lastEmptyEngine, model, empty: true }; + } throw lastError || new Error('All AI engines failed.'); } } diff --git a/src/extension.ts b/src/extension.ts index ae4ac60..0cc8971 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -182,6 +182,65 @@ export async function activate(context: vscode.ExtensionContext) { getToken: () => _cachedTelegramToken, }); const telegramAi = new AIService(); + + /** + * Build the Telegram-specific system prompt. + * + * Why this matters: small local models (gemma e2b/e4b) drift badly when + * called as a single user message with no role grounding. The reported + * symptom ("path 입력 → 시 못 써드려요" 같은 환각 거절) is exactly that + * drift — the model invents an interpretation because it has no anchor. + * + * The prompt does four things: + * 1. Names the role (Astra Telegram assistant) so the model has a + * consistent persona across messages. + * 2. States the language rule (mirror the user's language). + * 3. Tells the model how to treat brain context (evidence when relevant, + * ignore otherwise — never refuse the question because context + * doesn't match). + * 4. Specifies behavior for ambiguous inputs (paths, single words, + * fragments) — ask a clarifying question instead of guessing. + */ + const buildTelegramSystemPrompt = (hasContext: boolean) => { + const base = [ + 'You are Astra, a Telegram assistant connected to the user\'s personal Second Brain knowledge base.', + 'Reply in the user\'s language (mirror Korean ↔ English exactly as the user wrote).', + 'Be concise but complete. Telegram messages should feel like a knowledgeable friend, not a formal report.', + '', + 'Behavior rules:', + '- Never refuse a question by claiming you can only do certain things. If you can answer, just answer.', + '- If the user\'s message is ambiguous (a single word, a file path, a fragment with no question), ask one short clarifying question instead of guessing what they meant.', + '- Do NOT invent that the user asked for poetry, songs, code, or any content type they did not request.', + ]; + if (hasContext) { + base.push( + '', + 'You will receive a [SECOND BRAIN CONTEXT] block before the user\'s message.', + '- Use it as evidence only when it directly answers the question. Cite the file path (relative form, e.g. `10_Wiki/Topics/Foo.md`) inline when you do.', + '- If the context is unrelated to the question, ignore it silently. Do NOT mention that the context exists, do NOT explain why it doesn\'t apply, do NOT refuse the question because of it.', + ); + } + return base.join('\n'); + }; + + /** Telegram has a 4096-char per-message limit. Split on paragraph/sentence boundaries to keep replies readable. */ + const chunkTelegramMessage = (text: string, max = 4000): string[] => { + if (text.length <= max) return [text]; + const out: string[] = []; + let remaining = text; + while (remaining.length > max) { + // Prefer splitting on the last paragraph or sentence break before the limit. + let cut = remaining.lastIndexOf('\n\n', max); + if (cut < max * 0.5) cut = remaining.lastIndexOf('\n', max); + if (cut < max * 0.5) cut = remaining.lastIndexOf('. ', max); + if (cut < max * 0.5) cut = max; + out.push(remaining.slice(0, cut).trim()); + remaining = remaining.slice(cut).trim(); + } + if (remaining) out.push(remaining); + return out; + }; + const telegramBot = new TelegramBot({ client: telegramClient, handle: async (text, chatId) => { @@ -192,7 +251,17 @@ export async function activate(context: vscode.ExtensionContext) { return null; } - // Per-chat agent override → fall back to global default → fall back to mapping default. + // Trace every accepted message at the entry point so silent failures + // can be diagnosed against the log: if the user reports "no reply" + // and we have no `Telegram message received` line, the message + // never made it here (allowlist or polling drop). + logInfo('Telegram message received.', { + chatId, + chars: text.length, + preview: text.length > 80 ? text.slice(0, 80) + '…' : text, + }); + + // Per-chat agent override → global default → mapping default. const perChatAgents = cfg.get>('telegram.agentByChatId', {}) || {}; const perChatAgent = perChatAgents[String(chatId)]; const defaultAgent = cfg.get('telegram.defaultAgent', '') || ''; @@ -203,8 +272,10 @@ export async function activate(context: vscode.ExtensionContext) { const scope = resolveScopeForAgent(agentName, brainRoot); // RAG retrieval — even with no agent match we still search the whole - // brain so the bot stays useful. The buildContextBlock label tells - // the user which mode they're in. + // brain so the bot stays useful. buildContextBlock returns '' when + // nothing relevant was found, in which case we drop the section + // entirely (cleaner prompt + lets the system prompt skip the + // context-handling rule). let contextBlock = ''; if (brainRoot) { try { @@ -226,15 +297,47 @@ export async function activate(context: vscode.ExtensionContext) { } } - const composed = contextBlock - ? `${contextBlock}\n\n[사용자 질문]\n${text}\n\n[지시] 위 컨텍스트가 관련 있을 때만 활용하고, 답변에는 출처(파일 경로)를 인용하세요.` + const systemPrompt = buildTelegramSystemPrompt(!!contextBlock); + const userMessage = contextBlock + ? `[SECOND BRAIN CONTEXT]\n${contextBlock}\n\n[USER MESSAGE]\n${text}` : text; try { - const reply = await telegramAi.call(composed); - return (reply && reply.trim()) ? reply : '(빈 응답)'; + const result = await telegramAi.chat({ system: systemPrompt, user: userMessage }); + logInfo('Telegram AI reply generated.', { + chatId, engine: result.engine, model: result.model, + empty: result.empty, chars: result.content.length, + }); + + if (result.empty) { + // Reach the user instead of going silent. The user can then + // restart the model or simplify the question. + return [ + '⚠️ AI 모델이 빈 응답을 반환했습니다.', + '', + '다음을 시도해보세요:', + '• LM Studio에서 모델이 실제로 로드되어 있는지 확인', + '• 더 짧고 구체적인 질문으로 다시 보내기', + '• `Astra: Test Telegram Connection` 으로 연결 상태 확인', + ].join('\n'); + } + + // Telegram has a hard 4096 char/message limit. Long replies are + // chunked and joined with a "(이어서)" hint so the user knows + // multiple messages belong together. + const chunks = chunkTelegramMessage(result.content); + if (chunks.length === 1) return chunks[0]; + // Join all chunks with separators — the bot framework will send + // this as one Telegram message; for proper multi-message we'd + // need a return-array contract, but a single concatenated reply + // is already a real improvement over silently dropping content. + return chunks.map((c, i) => i === 0 ? c : `(이어서 ${i + 1}/${chunks.length})\n\n${c}`).join('\n\n---\n\n').slice(0, 4000); } catch (e: any) { - return `⚠️ Astra error: ${e?.message ?? e}`; + // Even on hard failure, ALWAYS reply with something so the user + // knows the bot is alive. Silent failures were the second + // reported pain point. + logError('Telegram handler threw.', { chatId, error: e?.message ?? String(e) }); + return `⚠️ Astra 처리 중 오류가 발생했습니다.\n${e?.message ?? e}\n\nLM Studio가 실행 중인지, 모델이 로드되어 있는지 확인해주세요.`; } }, }); diff --git a/src/integrations/telegram/telegramBot.ts b/src/integrations/telegram/telegramBot.ts index 4d2691f..165957f 100644 --- a/src/integrations/telegram/telegramBot.ts +++ b/src/integrations/telegram/telegramBot.ts @@ -224,17 +224,47 @@ export class TelegramBot { reply = `⚠️ Astra 처리 중 오류: ${e?.message ?? e}`; } - if (reply == null || !reply.trim()) return; - try { - await this._deps.client.sendMessage({ - chatId, - text: reply, - signal: this._abort?.signal, - }); - } catch (e: any) { - // Sending the reply failed — log and move on. Don't tear down the - // loop because of a single send failure. - logError('Telegram reply send failed.', { chatId, error: e?.message ?? String(e) }); + if (reply == null) { + // Handler intentionally suppressed (e.g. allowlist drop). Different + // from an empty string — empty means we tried and got nothing back, + // which is a bug we want to know about. + return; + } + if (!reply.trim()) { + logError('Telegram reply was empty after handle(). Sending placeholder so the user knows the bot is alive.', { chatId }); + reply = '⚠️ 빈 응답이 생성되었습니다. 다시 시도해주세요.'; + } + + // One-shot retry on transient send failures (network blip, Telegram + // rate-limit). The previous behavior — log once and move on — was the + // most likely cause of the "messages disappear sometimes" report: + // the AI replied successfully, but the send never reached Telegram + // and we never retried. + let sent = false; + for (let attempt = 0; attempt < 2 && !sent; attempt++) { + try { + await this._deps.client.sendMessage({ + chatId, + text: reply, + signal: this._abort?.signal, + }); + sent = true; + logInfo('Telegram reply sent.', { chatId, chars: reply.length, attempt }); + } catch (e: any) { + logError('Telegram reply send failed.', { + chatId, attempt, error: e?.message ?? String(e), + }); + if (attempt === 0 && this._running) { + // Brief backoff before the retry — don't tight-loop on a + // 429 rate limit. + await new Promise((r) => { + const t = setTimeout(r, 1500); + if (typeof t === 'object' && t && 'unref' in t) (t as any).unref(); + }); + } else { + break; + } + } } } }