`;
+ 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 ────────────────────────────────────────────