diff --git a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json index f3a270f..0b8439c 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": 1778684049645, + "createdAt": 1778685193682, "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 8f780e6..1f3597d 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": 1778684049641, + "createdAt": 1778685193681, "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 2a4c637..7e0f67b 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": 1778684049636, + "createdAt": 1778685193680, "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 ed5eb8c..eded489 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_1778684049621\ndate: 2026-05-13T14:54:09.649Z\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]** 핵심 정보 수집 및 분석 중... (5ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (8ms)\n", - "createdAt": 1778684049649, + "result": "---\nid: stress_conflict_1778685193665\ndate: 2026-05-13T15:13:13.682Z\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]** 핵심 정보 수집 및 분석 중... (5ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (0ms)\n", + "createdAt": 1778685193682, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1778684049621.json b/.astra/tests/stress/.astra/missions/stress_conflict_1778685193665.json similarity index 80% rename from .astra/tests/stress/.astra/missions/stress_conflict_1778684049621.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1778685193665.json index bdcea08..fcf87f5 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1778684049621.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1778685193665.json @@ -1,8 +1,8 @@ { - "missionId": "stress_conflict_1778684049621", + "missionId": "stress_conflict_1778685193665", "status": "completed", - "startTime": "2026-05-13T14:54:09.621Z", - "totalElapsedMs": 29, + "startTime": "2026-05-13T15:13:13.666Z", + "totalElapsedMs": 16, "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-13T14:54:09.632Z" + "ts": "2026-05-13T15:13:13.676Z" }, { "from": "planner", "to": "researcher", "durationMs": 5, "message": "핵심 정보 수집 및 분석 중...", - "ts": "2026-05-13T14:54:09.637Z" + "ts": "2026-05-13T15:13:13.681Z" }, { "from": "researcher", "to": "writer", - "durationMs": 8, + "durationMs": 0, "message": "최종 리포트 작성 및 편집 중...", - "ts": "2026-05-13T14:54:09.645Z" + "ts": "2026-05-13T15:13:13.681Z" }, { "from": "writer", "to": "completed", - "durationMs": 5, + "durationMs": 1, "message": "미션 완료", - "ts": "2026-05-13T14:54:09.650Z" + "ts": "2026-05-13T15:13:13.682Z" } ], "resilienceMetrics": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 1e4d1c9..a371da7 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,15 @@ # Astra Patch Notes +## v2.0.6 (2026-05-14) +### 🚀 Intelligence & UX Optimization +- **UI & UX 정교화:** 사이드바(`sidebar.js`, `sidebar.css`)의 인터랙션과 스타일을 개선하여 더욱 매끄러운 사용자 경험을 제공합니다. +- **텔레그램 대화 기록 관리:** `conversationHistory.ts`를 도입하여 텔레그램 연동 시의 대화 맥락 유지 기능을 강화했습니다. +- **비즈니스 엔진 고도화:** `dispatcher.ts` 및 `promptBuilder.ts` 최적화를 통해 CEO 에이전트의 의사결정 및 작업 할당 정밀도를 높였습니다. +- **아키텍처 분석 강화:** 프로젝트 구조 스캔 및 컨텍스트 주입 로직을 보완하여 더 깊은 코드 이해가 가능하도록 개선했습니다. +- **신규 패키징:** `astra-2.0.6.vsix` 패키지를 통해 통합된 성능 및 인터페이스 개선 사항을 배포합니다. + +--- + ## v2.0.5 (2026-05-13) ### 📢 Telegram Business Reporting & Core Resilience - **텔레그램 비즈니스 리포팅 도입:** 비즈니스 에이전트의 성과를 실시간으로 보고하는 `telegramReport.ts`를 추가하여 원격 모니터링 기능을 강화했습니다. diff --git a/media/sidebar.css b/media/sidebar.css index 2620a85..4853eb3 100644 --- a/media/sidebar.css +++ b/media/sidebar.css @@ -469,7 +469,7 @@ .company-phase-card .cph-meta { color: var(--text-dim); font-size: 10px; } .company-phase-card.report .cph-head { color: var(--accent); } - /* Project Architecture chip — sits just above the input when project mode is on. */ + /* Project Architecture chip — three-state surface above the input. */ .arch-chip { display: none; align-items: center; @@ -481,7 +481,20 @@ border-radius: 8px; font-size: 11px; } - .arch-chip[data-active="true"] { display: flex; } + .arch-chip[data-state="active"], + .arch-chip[data-state="inactive"] { display: flex; } + /* Inactive state has a muted look so it doesn't compete with active chips. */ + .arch-chip[data-state="inactive"] { + background: var(--bg-secondary); + border-style: dashed; + } + .arch-chip[data-state="inactive"] .arch-chip-title { color: var(--text-dim); } + /* Per-state button visibility — JS only flips the chip's data-state. */ + .arch-chip[data-state="active"] .arch-chip-inactive-only { display: none; } + .arch-chip[data-state="inactive"] .arch-chip-active-only { display: none; } + .arch-chip[data-state="inactive"] #archAttachBtn { + color: var(--accent); border-color: var(--accent); + } .arch-chip-icon { font-size: 14px; flex-shrink: 0; } .arch-chip-info { flex: 1; min-width: 0; line-height: 1.3; } .arch-chip-title { @@ -505,6 +518,22 @@ border-color: var(--border-bright); } + /* Inline refresh-result card so the user sees what the refresh did. */ + .arch-refresh-card { + border: 1px solid var(--border); + background: var(--surface); + border-radius: 8px; + padding: 6px 10px; + margin: 4px 0; + font-size: 10.5px; + color: var(--text-primary); + } + .arch-refresh-card .arc-head { + color: var(--text-bright); font-weight: 600; margin-bottom: 2px; + } + .arch-refresh-card .arc-meta { color: var(--text-dim); font-size: 9.5px; } + .arch-refresh-card.no-changes { border-style: dashed; } + /* Inline model picker that lives in the input footer, next to the attach button. Replaces the (now-removed) bottom model row + the separate "Model: ..." status text — one surface, click to change. */ diff --git a/media/sidebar.html b/media/sidebar.html index f9a12ac..d9bc85c 100644 --- a/media/sidebar.html +++ b/media/sidebar.html @@ -292,16 +292,28 @@ message with active=true. Click "Open" / "Refresh" / "Detach" to route back to the chatHandlers cases. --> -
+ +
📋
-
Auto-load Off
+
-
- - - +
+ + + + + +
diff --git a/media/sidebar.js b/media/sidebar.js index d9b3bf6..2be9fda 100644 --- a/media/sidebar.js +++ b/media/sidebar.js @@ -804,21 +804,28 @@ break; } case 'architectureStatus': { - // Show / hide the chip + reflect current state. + // Three-state chip: + // active — full info + Open/Refresh/Detach + // inactive — name + [Attach] button (user previously detached, OR doc not yet generated) + // hidden — no project + no workspace const chip = document.getElementById('archChip'); const title = document.getElementById('archChipTitle'); const meta = document.getElementById('archChipMeta'); if (!chip || !title || !meta) break; const v = msg.value || {}; - if (!v.active) { - chip.setAttribute('data-active', 'false'); - break; + if (v.active) { + chip.setAttribute('data-state', 'active'); + title.textContent = `${v.projectName || 'Project'} architecture`; + const updatedLabel = v.lastUpdated ? `updated ${formatRelativeTime(v.lastUpdated)}` : 'just attached'; + const autoLabel = v.autoUpdate === false ? 'Auto-update Off' : 'Auto-update On'; + meta.textContent = `${updatedLabel} · ${autoLabel}`; + } else if (v.canAttach && v.projectName) { + chip.setAttribute('data-state', 'inactive'); + title.textContent = `${v.projectName} architecture`; + meta.textContent = v.detached ? 'detached — click Attach to re-enable' : 'not yet activated'; + } else { + chip.setAttribute('data-state', 'hidden'); } - chip.setAttribute('data-active', 'true'); - title.textContent = `${v.projectName || 'Project'} architecture`; - const updatedLabel = v.lastUpdated ? `updated ${formatRelativeTime(v.lastUpdated)}` : 'just attached'; - const autoLabel = v.autoUpdate === false ? 'Auto-update Off' : 'Auto-update On'; - meta.textContent = `${updatedLabel} · ${autoLabel}`; break; } case 'architectureRefreshFailed': { @@ -830,6 +837,34 @@ } break; } + case 'architectureRefreshResult': { + // Trust-building stats card: shows exactly what the + // refresh did so users don't have to guess whether the + // 0.1s click actually accomplished anything. + const v = msg.value || {}; + const card = document.createElement('div'); + card.className = 'arch-refresh-card'; + const noChanges = (v.newlyAnalyzed | 0) === 0 && (v.deleted | 0) === 0; + if (noChanges) card.classList.add('no-changes'); + const head = noChanges + ? `📋 ${escAttr(v.projectName || 'Project')} architecture — 변경 사항 없음` + : `📋 ${escAttr(v.projectName || 'Project')} architecture refreshed`; + const parts = [ + `${v.newlyAnalyzed | 0} newly analysed`, + `${v.cached | 0} cached`, + ]; + if ((v.deleted | 0) > 0) parts.push(`${v.deleted | 0} deleted`); + parts.push(`${v.durationMs | 0}ms`); + card.innerHTML = + `
${head}
` + + `
${parts.join(' · ')}
`; + const chatEl = document.getElementById('chat'); + if (chatEl) { + chatEl.appendChild(card); + chatEl.scrollTop = chatEl.scrollHeight; + } + break; + } case 'knowledgeMix': { // Initial sync: reflect whatever weight is currently in settings. if (msg.value && typeof msg.value.weight === 'number') { @@ -1386,6 +1421,10 @@ if (_archOpenBtn) _archOpenBtn.onclick = () => vscode.postMessage({ type: 'openArchitectureDoc' }); if (_archRefreshBtn) _archRefreshBtn.onclick = () => vscode.postMessage({ type: 'refreshArchitecture' }); if (_archDetachBtn) _archDetachBtn.onclick = () => vscode.postMessage({ type: 'detachArchitecture' }); + // [Attach] is visible only in the inactive chip state; clicking it + // re-enables architecture mode for the current workspace's project. + const _archAttachBtn = document.getElementById('archAttachBtn'); + if (_archAttachBtn) _archAttachBtn.onclick = () => vscode.postMessage({ type: 'attachArchitecture' }); // ── 1인 기업 (Company) Mode chip + manage overlay ───────────────────── // The chip itself toggles enabled/disabled. The ▾ button opens the diff --git a/package.json b/package.json index 453889f..50a3f5d 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.0.5", + "version": "2.0.6", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -111,6 +111,10 @@ "command": "g1nation.architecture.detach", "title": "Astra: Detach Project Architecture Context" }, + { + "command": "g1nation.architecture.attach", + "title": "Astra: Attach Project Architecture Context" + }, { "command": "g1nation.architecture.open", "title": "Astra: Open Project Architecture Doc" diff --git a/src/extension.ts b/src/extension.ts index e8b1c41..3213cd2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -306,9 +306,24 @@ export async function activate(context: vscode.ExtensionContext) { } const systemPrompt = buildTelegramSystemPrompt(!!contextBlock); - const userMessage = contextBlock - ? `[SECOND BRAIN CONTEXT]\n${contextBlock}\n\n[USER MESSAGE]\n${text}` - : text; + // Per-chat conversation history — without this every inbound + // is a fresh turn, so the user "tells the bot something" and + // it gets immediately forgotten. We inline the recent N + // exchanges into the user message because the AI service's + // {system, user} surface doesn't carry a messages array. + const { appendTelegramMessage, getRecentMessages, formatHistoryForPrompt } = + await import('./integrations/telegram/conversationHistory'); + const history = getRecentMessages(chatId, 10); + const historyBlock = formatHistoryForPrompt(history); + const pieces: string[] = []; + if (contextBlock) pieces.push(`[SECOND BRAIN CONTEXT]\n${contextBlock}`); + if (historyBlock) pieces.push(historyBlock); + pieces.push(`[USER MESSAGE]\n${text}`); + const userMessage = pieces.join('\n\n'); + + // Persist the user's message *before* the AI call so failures + // still leave a trail (next inbound will see what they said). + appendTelegramMessage({ chatId, role: 'user', text, kind: 'user' }); try { const result = await telegramAi.chat({ system: systemPrompt, user: userMessage }); @@ -330,6 +345,10 @@ export async function activate(context: vscode.ExtensionContext) { ].join('\n'); } + // Persist the assistant's reply so the *next* inbound sees + // what we just said. Without this, the bot would forget its + // own answer the moment the user follows up. + appendTelegramMessage({ chatId, role: 'assistant', text: result.content, kind: 'reply' }); // 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. @@ -446,6 +465,11 @@ export async function activate(context: vscode.ExtensionContext) { await provider._detachArchitecture(); vscode.window.showInformationMessage('Astra: Project architecture auto-attach turned off.'); }), + vscode.commands.registerCommand('g1nation.architecture.attach', async () => { + if (!provider) return; + await provider._attachArchitecture(); + vscode.window.showInformationMessage('Astra: Project architecture auto-attach turned on.'); + }), vscode.commands.registerCommand('g1nation.architecture.open', async () => { if (!provider) return; await provider._openArchitectureDoc(); diff --git a/src/features/company/dispatcher.ts b/src/features/company/dispatcher.ts index 2b0e6db..c71fa66 100644 --- a/src/features/company/dispatcher.ts +++ b/src/features/company/dispatcher.ts @@ -284,7 +284,8 @@ async function _dispatchOne( // hit disk / shell. The report (e.g. "✅ Created: foo.py") is // appended to the response so the user sees what really happened. let finalResponse = rawResponse || '_(empty response)_'; - if (rawResponse && deps.executeActionTags && _hasActionTag(rawResponse)) { + const hasTag = !!rawResponse && _hasActionTag(rawResponse); + if (rawResponse && deps.executeActionTags && hasTag) { try { const report = await deps.executeActionTags(rawResponse); if (report.length > 0) { @@ -297,12 +298,30 @@ async function _dispatchOne( logError('company.dispatcher: action-tag execution failed.', { agentId, err }); finalResponse = `${rawResponse}\n\n---\n⚠️ Action 실행 실패: ${err}`; } + } else if (rawResponse && !hasTag && _claimsFileCreation(rawResponse)) { + // Hallucination guard: small models love to *narrate* file + // creation ("foo.py를 생성했습니다 …") without emitting the + // tag — so the user sees ✅ in chat but nothing + // on disk. Catch the mismatch here and flag it loudly so the + // CEO synthesis (which reads this response) and the user both + // know nothing was actually written. + const warning = '⚠️ **실제 파일이 생성되지 않았습니다.** Agent가 파일 생성을 텍스트로 설명했지만 ConnectAI 액션 태그(`` 등)를 사용하지 않아 디스크에 아무것도 만들어지지 않았어요. 같은 요청을 다시 시도하거나, 사용자가 직접 만드세요.'; + finalResponse = `${rawResponse}\n\n---\n${warning}`; + logInfo('company.dispatcher: agent claimed creation without action tag.', { agentId }); } + // `error: 'no-action-tag-but-claimed'` is *advisory* — we still let + // the turn complete because some agents (Writer, Researcher) are + // legitimately answer-only. But by flagging the agent output we + // mark it as not-fully-successful so the CEO synthesis can read + // the warning verbatim. + const claimedButDidnt = rawResponse && !hasTag && _claimsFileCreation(rawResponse); return { agentId, task, response: finalResponse, durationMs: Date.now() - startedAt, - error: rawResponse ? undefined : 'empty-response', + error: rawResponse + ? (claimedButDidnt ? 'claimed-creation-no-tag' : undefined) + : 'empty-response', }; } catch (e: any) { const err = e?.message ?? String(e); @@ -325,3 +344,27 @@ async function _dispatchOne( function _hasActionTag(text: string): boolean { return /<\s*(?:create_file|edit_file|delete_file|read_file|list_files|list_brain|run_command|read_brain|reveal_in_explorer|open_file|glob|grep)\b/i.test(text); } + +/** + * Heuristic: does the response *narrate* having created files/folders? + * + * We look for the combination of (a) a Korean / English creation verb and + * (b) a filename-like or "folder" mention. The intent is to catch the + * hallucination pattern where an agent writes "foo.py 파일을 생성했습니다" + * or "Created `bar/` directory" without emitting the corresponding + * `` tag, so the dispatcher can flag it back to the CEO and + * the user instead of silently reporting success. + * + * Kept narrow on purpose — a *plan* like "다음에는 X를 만들어야 합니다" + * shouldn't trigger this. We require past-tense / completion phrasing. + */ +function _claimsFileCreation(text: string): boolean { + // Past-tense creation verbs (Korean + English). + const claimRe = /(?:생성했|만들었|작성했|저장했|구현했|created|wrote|saved|built|generated)/i; + if (!claimRe.test(text)) return false; + // Combined with either an explicit filename (something.ext) or the word + // "폴더" / "directory" / "folder" near the verb. + const fileLike = /\b[\w\-./]+\.(?:py|js|ts|tsx|jsx|md|json|html|css|sh|yaml|yml|sql|java|go|rs|c|cpp|rb|php)\b/i.test(text); + const folderLike = /(?:폴더|디렉토리|directory|folder)/i.test(text); + return fileLike || folderLike; +} diff --git a/src/features/company/promptBuilder.ts b/src/features/company/promptBuilder.ts index af9ba8f..f29441f 100644 --- a/src/features/company/promptBuilder.ts +++ b/src/features/company/promptBuilder.ts @@ -77,15 +77,39 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string { parts.push('- 추측·일반론·placeholder 금지. 가진 정보만 인용.'); // ── Tool contract ── - // ConnectAI's existing AgentExecutor parses these tags automatically - // after the streaming response completes. Keeping the syntax identical - // means specialists can write files / run commands the same way the - // base chat already does — no new plumbing on the agent side. + // Hard rule about action tags. Earlier wording ("태그 없이 평문으로만 + // 답해도 됩니다") let small models (gemma 4 e2b etc.) emit ```python + // code blocks and then *claim* to have created files — the user got + // ✅ in chat but nothing on disk. This block is now phrased so the + // model cannot rationalise its way out of the tag contract. parts.push(''); - parts.push('## 도구 사용 규칙 (필요할 때만)'); - parts.push('실제 파일 생성·명령 실행이 필요하면 ConnectAI의 액션 태그를 사용하세요.'); - parts.push('예) `내용`, `npm test` 등.'); - parts.push('태그 없이 평문으로만 답해도 됩니다 — 기획·분석·아이디어 작업은 보통 태그가 필요 없습니다.'); + parts.push('## ⚠️ 실제 파일·명령 실행 (이 섹션 매우 중요)'); + parts.push('당신은 사용자의 **실제** 파일 시스템과 터미널에 직접 연결되어 있습니다.'); + parts.push('**텍스트로 "만들었다 / 작성했다 / 생성했다 / 저장했다" 라고 말해도 사용자 디스크엔 아무 일도 안 일어납니다.**'); + parts.push('파일을 만들거나 명령을 실행하려면 **반드시** 아래 액션 태그로 감싸세요. 시스템이 자동으로 디스크에 적용합니다:'); + parts.push(''); + parts.push(' • `내용` — 새 파일 생성·덮어쓰기'); + parts.push(' • `옛 내용새 내용` — 부분 편집'); + parts.push(' • `` — 32KB까지 읽기 (편집 전엔 반드시 먼저 read)'); + parts.push(' • `` — 파일·디렉토리 삭제'); + parts.push(' • `` — 디렉토리 목록 보기'); + parts.push(' • `명령` — 셸 실행 (디렉토리 생성 등)'); + parts.push(''); + parts.push('🛑 **경로 규칙 (위반 시 권한 거부됨)**:'); + parts.push('- 경로는 **워크스페이스 루트 상대 경로**로 쓰세요. 예: `timertest/timer.py`, `src/utils.py`'); + parts.push('- 절대 경로 가능하지만 **반드시 워크스페이스 내부**여야 함. `/antigravity/...` `/tmp/...` 같은 시스템 루트 경로는 거부됨.'); + parts.push('- 디렉토리는 ``이 자동으로 만들어줍니다 (mkdir -p). 별도 명령 불필요.'); + parts.push(''); + parts.push('❌ **하지 말아야 할 패턴**:'); + parts.push(' - ```python\\nprint("...")\\n``` 코드 블록만 답하고 "생성 완료"라고 말하기 → 디스크엔 만들어지지 않음'); + parts.push(' - `` 같은 시스템 루트 경로 → 거부됨'); + parts.push(' - 사용자가 "X 만들어줘"라고 했는데 코드만 보여주고 끝내기 → 사용자는 결과물을 받지 못함'); + parts.push(''); + parts.push('✅ **올바른 패턴**:'); + parts.push(' 사용자: "타이머 파이썬 스크립트 만들어줘"'); + parts.push(' 당신: 짧은 설명 한두 줄 + `import time\\n...` + 자가평가'); + parts.push(''); + parts.push('기획·분석·아이디어처럼 *결과물이 파일 아닌 경우*에는 액션 태그 없이 마크다운으로만 답해도 됩니다.'); // ── Peer context (this turn) ── const peers = inputs.peerOutputs ?? []; diff --git a/src/features/company/telegramReport.ts b/src/features/company/telegramReport.ts index 47bf4da..e6a2fdd 100644 --- a/src/features/company/telegramReport.ts +++ b/src/features/company/telegramReport.ts @@ -25,6 +25,7 @@ import * as vscode from 'vscode'; import { logError, logInfo } from '../../utils'; import { TelegramHttpClient } from '../../integrations/telegram/telegramClient'; +import { appendTelegramMessage } from '../../integrations/telegram/conversationHistory'; import { COMPANY_AGENTS } from './agents'; import { AgentTurnOutput, CompanyState, CompanyTaskPlan } from './types'; @@ -72,6 +73,13 @@ export async function buildTelegramReporter( return async (text: string): Promise => { if (!text || !text.trim()) return false; + // Append to the per-chat history *before* the send. The bot's + // next inbound turn reads this history, so even if delivery + // fails the user's follow-up question still has context for + // "what did you just say to me?". Persisting on attempt also + // means timing matches the user's perception ("the bot reported + // X, then I replied"). + appendTelegramMessage({ chatId, role: 'assistant', text, kind: 'company-mirror' }); try { await client.sendMessage({ chatId, text, parseMode: 'Markdown' }); return true; diff --git a/src/features/projectArchitecture/index.ts b/src/features/projectArchitecture/index.ts index 423b56a..cc17644 100644 --- a/src/features/projectArchitecture/index.ts +++ b/src/features/projectArchitecture/index.ts @@ -115,6 +115,14 @@ export interface BuildResult { created: boolean; /** Result of the scan that fed this build. */ scan: ArchitectureScanResult; + /** + * What the underlying deep-scan actually did this run — how many files + * were freshly analysed vs. served from the on-disk cache, and whether + * any tracked files have disappeared. The sidebar surfaces these counts + * after every Refresh so users can trust the operation actually ran + * (instead of the previous mysterious "updated just now in 0.1s"). + */ + refreshStats: RefreshStats; } /** Resolve the architecture doc path for a given project root. */ @@ -181,7 +189,7 @@ export function buildOrRefreshArchitectureDoc( newlyAnalyzed: deep.refreshStats.newlyAnalyzed, cached: deep.refreshStats.cached, }); - return { docPath, created: true, scan }; + return { docPath, created: true, scan, refreshStats: deep.refreshStats }; } // In-place refresh: rewrite the auto-managed block, keep user-owned sections. @@ -196,7 +204,7 @@ export function buildOrRefreshArchitectureDoc( deleted: deep.refreshStats.deleted.length, }); } - return { docPath, created: false, scan }; + return { docPath, created: false, scan, refreshStats: deep.refreshStats }; } /** diff --git a/src/integrations/telegram/conversationHistory.ts b/src/integrations/telegram/conversationHistory.ts new file mode 100644 index 0000000..5a8d53f --- /dev/null +++ b/src/integrations/telegram/conversationHistory.ts @@ -0,0 +1,154 @@ +/** + * Per-chat conversation history for the Telegram bot. + * + * Why this exists: the previous bot was *stateless* — every inbound + * message hit `AIService.chat({system, user})` in isolation, with no + * memory of what the user said two minutes ago or what the secretary + * just reported. Users hit this immediately: "I just told you about + * project X, why don't you remember?" + * + * This module solves it with a thin append-only log: + * + * - Each `(chatId, role, text)` triple is appended to a JSONL file at + * `/.astra/company/_agents/secretary/telegram_history.jsonl`. + * One file per workspace, scoped by `chatId` at read time, so + * multi-chat setups don't bleed into each other. + * - A small in-memory cache (last 200 entries) sits in front so the + * hot path is `O(1)` lookup, not "scan the whole file". + * - `getRecentMessages` returns the most recent N entries for a chat, + * including the secretary's company-turn mirror entries that the + * dispatcher appends — so when the user asks a follow-up question + * ("저 폴더가 없다고"), the AI sees its own report from 30 seconds ago. + * + * The file is human-readable on purpose — users can grep it / delete it + * to clear history without any CLI. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { logError } from '../../utils'; + +export interface TelegramHistoryEntry { + chatId: number; + role: 'user' | 'assistant'; + text: string; + /** ISO timestamp at append time. */ + ts: string; + /** Optional tag: `'company-mirror'` for secretary's auto-reports, `'reply'` for normal replies. */ + kind?: 'company-mirror' | 'reply' | 'user'; +} + +/** Max entries kept across all chats in the in-memory cache. */ +const MEMORY_CAP = 200; +/** Max entries returned by `getRecentMessages`. */ +const DEFAULT_RECENT = 12; + +let _cache: TelegramHistoryEntry[] = []; +let _hydratedForWorkspace: string | null = null; + +function _historyPath(): string { + const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!ws) return ''; + return path.join(ws, '.astra', 'company', '_agents', 'secretary', 'telegram_history.jsonl'); +} + +/** + * Lazy-load the on-disk JSONL into the cache the first time we need it + * for the current workspace. Switching workspaces re-hydrates from the + * new file — that way each project keeps its own chat memory. + */ +function _hydrateIfNeeded(): void { + const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; + if (_hydratedForWorkspace === ws) return; + _hydratedForWorkspace = ws; + _cache = []; + const p = _historyPath(); + if (!p || !fs.existsSync(p)) return; + try { + const lines = fs.readFileSync(p, 'utf8').split('\n').filter((l) => l.trim()); + // Trim to the cap from the *end* so we keep the freshest entries. + const tail = lines.length > MEMORY_CAP ? lines.slice(-MEMORY_CAP) : lines; + for (const line of tail) { + try { + const entry = JSON.parse(line) as Partial; + if (typeof entry.chatId === 'number' + && (entry.role === 'user' || entry.role === 'assistant') + && typeof entry.text === 'string' + && typeof entry.ts === 'string') { + _cache.push(entry as TelegramHistoryEntry); + } + } catch { + // Skip malformed line — keep going so a single bad write doesn't poison everything. + } + } + } catch (e: any) { + logError('telegram.history: read failed.', { error: e?.message ?? String(e) }); + } +} + +/** + * Append one entry. Best-effort: cache always updates so the *current* + * session sees its own writes immediately even if the disk write fails. + */ +export function appendTelegramMessage(entry: Omit & { ts?: string }): void { + _hydrateIfNeeded(); + const fullEntry: TelegramHistoryEntry = { + chatId: entry.chatId, + role: entry.role, + text: entry.text, + kind: entry.kind, + ts: entry.ts ?? new Date().toISOString(), + }; + _cache.push(fullEntry); + if (_cache.length > MEMORY_CAP) _cache.splice(0, _cache.length - MEMORY_CAP); + const p = _historyPath(); + if (!p) return; + try { + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.appendFileSync(p, JSON.stringify(fullEntry) + '\n', 'utf8'); + } catch (e: any) { + logError('telegram.history: append failed.', { chatId: entry.chatId, error: e?.message ?? String(e) }); + } +} + +/** + * Return the most recent N messages for one chat, oldest-first so the + * caller can feed them to an LLM in turn order. `maxEntries` defaults to + * 12 — enough for a couple of follow-up turns without exploding the + * context window of a small local model. + */ +export function getRecentMessages( + chatId: number, + maxEntries: number = DEFAULT_RECENT, +): TelegramHistoryEntry[] { + _hydrateIfNeeded(); + const forChat = _cache.filter((e) => e.chatId === chatId); + return forChat.slice(-Math.max(1, maxEntries)); +} + +/** + * Format recent history as a readable block we can inline into the user + * message. We don't use the AI service's multi-message API because + * `IAIService.chat({system, user})` only takes one user turn — embedding + * the back-and-forth in the user message keeps the API surface unchanged + * while still giving the model the context it needs. + */ +export function formatHistoryForPrompt(history: TelegramHistoryEntry[]): string { + if (!history.length) return ''; + const lines: string[] = ['[Previous conversation in this Telegram chat]']; + for (const e of history) { + const speaker = e.role === 'user' ? 'User' : 'Bot'; + // Keep each message bounded so a single huge company-mirror report + // doesn't dominate the prompt budget. + const trimmed = e.text.length > 800 ? e.text.slice(0, 800) + '…' : e.text; + lines.push(`${speaker}: ${trimmed}`); + } + lines.push('[End of previous conversation]'); + return lines.join('\n'); +} + +/** For tests + the "clear history" command. */ +export function _resetCacheForTests(): void { + _cache = []; + _hydratedForWorkspace = null; +} diff --git a/src/sidebar/chatHandlers.ts b/src/sidebar/chatHandlers.ts index fda1fe4..62310e4 100644 --- a/src/sidebar/chatHandlers.ts +++ b/src/sidebar/chatHandlers.ts @@ -151,6 +151,11 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any case 'detachArchitecture': await provider._detachArchitecture(); return true; + case 'attachArchitecture': + // Re-enable architecture context for the current workspace — + // user clicked the inactive chip's [Attach] button. + await provider._attachArchitecture(); + return true; case 'activateArchitectureFromText': { // Optional explicit-toggle path: webview can pass arbitrary text // (e.g. the current input draft) for one-shot intent detection. diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index e2ace34..4f7e30b 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -1121,8 +1121,16 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn logInfo('architecture: detached.', { projectId: profile.projectId }); } - /** Force a refresh of the architecture doc for the active project. */ + /** + * Force a refresh of the architecture doc for the active project. + * + * Always rewrites the auto-managed block (so the "Last Refresh" stamp + + * stats reflect the click). Emits an `architectureRefreshResult` event + * with the per-file work breakdown — that's what makes the operation + * visibly trustworthy in the UI (no more "0.1s, nothing visible"). + */ async _refreshArchitecture(): Promise { + const startedAt = Date.now(); const profile = this._getActiveChronicleProject(); if (!profile || !profile.projectRoot) { this._view?.webview.postMessage({ @@ -1145,6 +1153,111 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn : p); await this._putChronicleProjects(next); await this._sendArchitectureStatus(); + // Tell the webview exactly what the scan did so the user can + // trust the "Refresh" button actually ran. The three numbers + // (newly / cached / deleted) together explain whether the doc + // changed or just had its timestamp bumped. + this._view?.webview.postMessage({ + type: 'architectureRefreshResult', + value: { + projectName: profile.projectName, + docPath: result.docPath, + newlyAnalyzed: result.refreshStats.newlyAnalyzed, + cached: result.refreshStats.cached, + deleted: result.refreshStats.deleted.length, + durationMs: Date.now() - startedAt, + }, + }); + } + + /** + * Re-attach the architecture context for the active project after a + * prior Detach. Rebuilds the doc (so the user gets a fresh scan), + * flips `architectureAutoAttach=true`, re-registers the watcher, and + * broadcasts the chip back to its active state. The complement of + * `_detachArchitecture`. + */ + async _attachArchitecture(): Promise { + // `_ensureActiveProjectForWorkspace` guarantees the active project + // matches the current VS Code workspace — without that, hitting + // Attach right after opening a different folder would silently + // attach to whatever was last active in the *previous* workspace. + const profile = await this._ensureActiveProjectForWorkspace(); + if (!profile || !profile.projectRoot) { + this._view?.webview.postMessage({ + type: 'architectureRefreshFailed', + value: { reason: 'no-active-project' }, + }); + return; + } + await this._activateArchitectureForProject(profile.projectId, { + fallbackName: profile.projectName, + fallbackRoot: profile.projectRoot, + }); + } + + /** + * Make sure the active chronicle project actually corresponds to the + * folder the user has open in VS Code. Three cases: + * + * 1. Active project already matches workspace → return it as-is. + * 2. A *different* chronicle project matches the workspace → flip + * the active id to that one (the user switched folders since + * last session). + * 3. No chronicle project matches → synthesise a new one from the + * workspace folder name + register it. + * + * Returns the (possibly newly created) active project, or `null` when + * no workspace is open. Idempotent — calling repeatedly with no change + * is free. + */ + async _ensureActiveProjectForWorkspace(): Promise { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) return null; + const projects = this._getChronicleProjects(); + const active = this._getActiveChronicleProject(); + const norm = (p: string | undefined) => (p || '').replace(/[\\/]+$/, '').toLowerCase(); + if (active && active.projectRoot && norm(active.projectRoot) === norm(workspaceRoot)) { + return active; + } + // Case 2: another chronicle project matches → switch active to it + const matching = projects.find((p) => norm(p.projectRoot) === norm(workspaceRoot)); + if (matching) { + await this._context.globalState.update( + SidebarChatProvider.activeChronicleProjectStateKey, + matching.projectId, + ); + logInfo('architecture: switched active project to match workspace.', { + from: active?.projectId, + to: matching.projectId, + }); + return matching; + } + // Case 3: synthesise a fresh entry for this workspace + const projectName = path.basename(workspaceRoot) || 'Current Project'; + const projectId = this._slugify(projectName); + const now = new Date().toISOString(); + const profile: ProjectProfile = { + projectId, + projectName, + projectRoot: workspaceRoot, + recordRoot: path.join(workspaceRoot, 'docs', 'records', projectName), + description: 'Auto-detected from workspace folder.', + corePurpose: '', + detailLevel: 'standard', + createdAt: now, + updatedAt: now, + }; + const nextProjects = projects.filter((p) => p.projectId !== projectId).concat(profile); + await this._putChronicleProjects(nextProjects); + await this._context.globalState.update( + SidebarChatProvider.activeChronicleProjectStateKey, + projectId, + ); + logInfo('architecture: registered new project from workspace.', { + projectId, projectRoot: workspaceRoot, + }); + return profile; } /** @@ -1166,24 +1279,84 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn }); } - /** Webview chip data — shown above the input box when active. */ + /** + * Webview chip data. Three states: + * + * 1. **active** — project mode is on; doc is being auto-attached. + * 2. **inactive** — there's a project + workspace, but architecture + * is either never-activated or user-detached. + * The chip shows an `[Attach]` button instead of + * hiding entirely, so users always have a one- + * click path back into project mode. + * 3. **hidden** — no workspace open and no project at all. + * + * Also does an auto-activation pass for the *fresh-workspace* case: + * when the active project has no `architectureDocPath` yet AND the + * user hasn't explicitly detached, we generate the doc + flip + * `autoAttach=true` so the user opens a new folder and immediately + * sees the architecture context working. Existing detach choices are + * always respected. + */ async _sendArchitectureStatus(): Promise { if (!this._view) return; - const p = this._getActiveChronicleProject(); - const active = !!(p && p.architectureDocPath && p.architectureAutoAttach !== false); - this._view.webview.postMessage({ - type: 'architectureStatus', - value: active && p - ? { + // Always sync the active project to the current VS Code workspace + // before reporting — otherwise switching workspaces leaves the + // chip pointing at the *previous* project's doc. + const p = await this._ensureActiveProjectForWorkspace(); + if (!p) { + this._view.webview.postMessage({ type: 'architectureStatus', value: { active: false } }); + return; + } + const wasDetached = p.architectureAutoAttach === false; + const hasDoc = !!(p.architectureDocPath && fs.existsSync(p.architectureDocPath)); + + // Auto-activation for fresh workspaces: never been activated AND + // never been detached → kick off a build and re-broadcast. Single + // recursion is safe because the post-activate state will hit the + // `active` branch below. + if (!hasDoc && !wasDetached && p.projectRoot) { + try { + await this._activateArchitectureForProject(p.projectId, { + fallbackName: p.projectName, + fallbackRoot: p.projectRoot, + }); + return; // _activateArchitectureForProject sends its own status + } catch (e: any) { + logError('architecture: auto-activate failed.', { error: e?.message ?? String(e) }); + // Fall through to the inactive state so the user still sees an Attach button. + } + } + + const fullyActive = hasDoc && !wasDetached; + if (fullyActive) { + this._view.webview.postMessage({ + type: 'architectureStatus', + value: { active: true, projectId: p.projectId, projectName: p.projectName, docPath: p.architectureDocPath, lastUpdated: p.architectureLastUpdated || '', autoUpdate: p.architectureAutoUpdate !== false, - } - : { active: false }, - }); + }, + }); + // Re-register the watcher in case it was disposed (e.g. workspace switch). + this._registerArchitectureWatcher(p); + } else { + // Inactive but attachable: surface the project name + an Attach hook. + this._view.webview.postMessage({ + type: 'architectureStatus', + value: { + active: false, + canAttach: !!p.projectRoot, + projectId: p.projectId, + projectName: p.projectName, + // Distinguishes "never activated" from "detached" so the + // chip can choose the right label ("Activate" vs "Re-attach"). + detached: wasDetached, + }, + }); + } } // ─── 1인 기업 (Company) Mode ────────────────────────────────────────────