release: v2.0.7 - Enhanced Telegram Reporting & File Visibility (2026-05-14)

This commit is contained in:
g1nation
2026-05-14 00:37:41 +09:00
parent f1d5dbf031
commit 8104caf8d9
11 changed files with 187 additions and 20 deletions
+117 -1
View File
@@ -33,6 +33,7 @@ import type { ProjectTemplateId } from './scaffolder/templates';
import { TelegramHttpClient } from './integrations/telegram/telegramClient';
import { TelegramBot } from './integrations/telegram/telegramBot';
import { AIService } from './core/services';
import type { CompanyState } from './features/company';
import { SettingsPanelProvider } from './features/settings/settingsPanelProvider';
import { resolveScopeForAgent, openKnowledgeMapEditor } from './skills/agentKnowledgeMap';
import { getBrainTokenIndex } from './retrieval';
@@ -232,6 +233,83 @@ export async function activate(context: vscode.ExtensionContext) {
};
/** Telegram has a 4096-char per-message limit. Split on paragraph/sentence boundaries to keep replies readable. */
/**
* Cheap heuristic: does the message look like a *work order* the user
* wants the company to execute? Triggers company-turn routing.
*
* Conservative matches only — we'd rather miss a borderline case
* (user retries with clearer wording) than mis-route a question
* into a company turn (which spends LLM calls + writes to disk).
*
* Positive signals:
* • Explicit dispatch prefix: "CEO한테", "회사한테", "팀한테"
* • Korean imperative verbs at sentence end: 만들어/해줘/작성해줘/
* 짜줘/구현해/만들어줘/돌려줘/실행해줘/분석해줘/정리해줘
* • English imperatives: "make X", "build X", "create X", "implement"
*
* Negative signals (override → treat as question, not order):
* • Ends with "?" — pure question
* • Contains "알려줘 / 어디 / 뭐야 / what / where" — informational
*/
function _looksLikeWorkOrder(text: string): boolean {
const t = (text || '').trim();
if (!t) return false;
// Explicit dispatch prefix wins regardless of other signals.
if (/(CEO|회사|팀)\s*(한테|에게|보고|에)/i.test(t)) return true;
// Strong informational signals — *not* a work order.
if (/[?]$/.test(t)) return false;
if (/(어디(에|에서|야)|뭐야|얼마|언제|왜|^(누구|어떻게|뭐))/i.test(t)) return false;
// Korean imperative tails (한국어 청유·명령형 종결).
if (/(만들어|짜줘|작성해|구현해|돌려줘|실행해|분석해|정리해|보고해|해줘|짜봐|만들어줘)/i.test(t)) return true;
// English imperative leads.
if (/^\s*(make|build|create|implement|run|analyze|generate|write|fix|add|remove)\b/i.test(t)) return true;
return false;
}
/**
* Build a `[COMPANY CONTEXT]` block describing the workspace, the
* current company state, and the most recent session directory. Lets
* the bot answer questions like "어디에 저장했어?" by reading its own
* mirror history *plus* the resolved absolute path on disk.
*
* Returns '' when company mode is off, so the prompt stays minimal
* for users who only use the Telegram bot for RAG-chat.
*/
function _buildTelegramCompanyContext(state: CompanyState, ctx: vscode.ExtensionContext): string {
if (!state.enabled) return '';
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
const lines: string[] = [`[COMPANY CONTEXT]`];
lines.push(`회사명: ${state.companyName || '1인 기업'}`);
if (ws) lines.push(`작업 폴더 (워크스페이스 루트): ${ws}`);
// Surface the most recent session dir so follow-up questions
// ("폴더 어디에 있어?", "방금 만든 파일 경로") have a concrete answer.
const latestSession = _latestCompanySessionDir(ctx);
if (latestSession) {
lines.push(`최근 작업 세션 폴더: ${latestSession}`);
lines.push(`(이 안에 _brief.md, _report.md, 각 에이전트별 산출물이 저장됨)`);
}
lines.push('');
lines.push('당신의 역할: 이 회사의 비서(Secretary). 사용자(사장님)의 질문에 답할 때 위 경로 정보를 *그대로* 활용하세요.');
lines.push('"실제 파일 시스템에 접근할 수 없다" 같은 답변은 잘못된 것입니다 — 위 경로가 실제 시스템 경로입니다.');
return lines.join('\n');
}
/** Find the newest `<workspace>/.astra/company/sessions/<ts>/` directory, or '' if none. */
function _latestCompanySessionDir(ctx: vscode.ExtensionContext): string {
try {
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const baseDir = ws
? path.join(ws, '.astra', 'company', 'sessions')
: path.join(ctx.globalStorageUri.fsPath, 'company', 'sessions');
if (!fs.existsSync(baseDir)) return '';
const dirs = fs.readdirSync(baseDir)
.filter((n) => fs.statSync(path.join(baseDir, n)).isDirectory())
.sort()
.reverse();
return dirs[0] ? path.join(baseDir, dirs[0]) : '';
} catch { return ''; }
}
const chunkTelegramMessage = (text: string, max = 4000): string[] => {
if (text.length <= max) return [text];
const out: string[] = [];
@@ -269,6 +347,36 @@ export async function activate(context: vscode.ExtensionContext) {
preview: text.length > 80 ? text.slice(0, 80) + '…' : text,
});
// ── 1인 기업 모드 라우팅 ────────────────────────────────────────
// When company mode is on AND the message looks like a work
// *order* (imperative verbs like "만들어줘 / 해줘 / 작성해줘" or
// an explicit "CEO한테 …" prefix), route through the company
// dispatcher instead of the simple RAG-chat path. The dispatcher
// emits a Telegram mirror at the end, so the user gets a proper
// report back. This fixes the previous symptom where the bot
// refused to "deliver messages to CEO" — that was a routing gap,
// not a missing capability.
const { readCompanyState } = await import('./features/company');
const companyState = readCompanyState(context);
if (companyState.enabled && _looksLikeWorkOrder(text)) {
const { appendTelegramMessage } = await import('./integrations/telegram/conversationHistory');
appendTelegramMessage({ chatId, role: 'user', text, kind: 'user' });
logInfo('Telegram: routing to company turn.', { chatId, preview: text.slice(0, 60) });
// Fire-and-forget: the dispatcher's secretary mirror sends
// the final report. We return an immediate ack so the user
// sees the bot acknowledged the order.
void (async () => {
try {
await provider!._runCompanyTurn(text);
} catch (e: any) {
logError('Telegram → company turn failed.', { error: e?.message ?? String(e) });
}
})();
const ack = '🧭 CEO에게 전달했어요. 작업 끝나면 보고드릴게요.';
appendTelegramMessage({ chatId, role: 'assistant', text: ack, kind: 'reply' });
return ack;
}
// Per-chat agent override → global default → mapping default.
const perChatAgents = cfg.get<Record<string, string>>('telegram.agentByChatId', {}) || {};
const perChatAgent = perChatAgents[String(chatId)];
@@ -305,7 +413,15 @@ export async function activate(context: vscode.ExtensionContext) {
}
}
const systemPrompt = buildTelegramSystemPrompt(!!contextBlock);
// Build the system prompt with company-aware context. The bot
// is the *secretary* of a virtual company when company mode is
// on — telling it so lets it answer "where did you save X?"
// properly instead of falling back to "I don't have file
// system access".
const companyContextBlock = _buildTelegramCompanyContext(companyState, context);
const systemPrompt = buildTelegramSystemPrompt(!!contextBlock)
+ (companyContextBlock ? `\n\n${companyContextBlock}` : '');
// 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
+4
View File
@@ -195,6 +195,7 @@ export async function runCompanyTurn(
outputs,
report: reportResult.report,
sessionTimestamp: timestamp,
sessionDir,
});
telegramOk = await reporter(tgText);
if (!telegramOk) telegramReason = 'delivery-failed';
@@ -284,10 +285,12 @@ 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)_';
let actionReport: string[] | undefined;
const hasTag = !!rawResponse && _hasActionTag(rawResponse);
if (rawResponse && deps.executeActionTags && hasTag) {
try {
const report = await deps.executeActionTags(rawResponse);
actionReport = report;
if (report.length > 0) {
finalResponse = `${rawResponse}\n\n---\n**Action 실행 결과:**\n${report.map((r) => `- ${r}`).join('\n')}`;
}
@@ -322,6 +325,7 @@ async function _dispatchOne(
error: rawResponse
? (claimedButDidnt ? 'claimed-creation-no-tag' : undefined)
: 'empty-response',
actionReport,
};
} catch (e: any) {
const err = e?.message ?? String(e);
+33 -3
View File
@@ -105,7 +105,10 @@ export async function buildTelegramReporter(
/**
* Compose the per-turn Telegram message. Format mirrors Connect_origin's
* Secretary report so users moving between the two products see consistent
* notifications: header + brief + agent list + CEO synthesis + session id.
* notifications: header + brief + agent list + CEO synthesis + session id +
* **concrete file paths** so follow-up questions like "어디에 만들었어?"
* have a clean answer (the bot's previous answer "I don't have file system
* access" was the user-visible bug we're fixing here).
*
* The function is pure (no I/O) and exported separately so it can be unit-
* tested without touching VS Code APIs.
@@ -117,6 +120,9 @@ export function formatCompanyTelegramReport(opts: {
outputs: AgentTurnOutput[];
report: string;
sessionTimestamp: string;
/** Absolute path of the session directory — surfaced in the report so
* the user can locate generated files without asking the bot again. */
sessionDir: string;
}): string {
const company = opts.state.companyName || '1인 기업';
const header = `*📱 ${company} — 작업 라운드 보고*`;
@@ -130,9 +136,33 @@ export function formatCompanyTelegramReport(opts: {
return `${mark} ${def?.emoji ?? ''} ${def?.name ?? t.agent}`;
}).join('\n')
: '';
// Concrete artefacts: extract the file paths the action-tag executor
// actually wrote, so the user can copy-paste them into Finder or `cd` to
// the right place. Empty when no agent emitted file/command tags.
const artefactsLine = _formatArtefactsBlock(opts.outputs);
const reportBody = opts.report.trim()
? `\n\n${opts.report.trim().slice(0, REPORT_BUDGET)}`
: '';
const sessionLine = `\n\n_세션: ${opts.sessionTimestamp}_`;
return `${header}\n\n${cmdLine}${brief}${agentsLine}${reportBody}${sessionLine}`;
const sessionLine = `\n\n*세션 폴더:* \`${opts.sessionDir}\``;
return `${header}\n\n${cmdLine}${brief}${agentsLine}${artefactsLine}${reportBody}${sessionLine}`;
}
/**
* Pull a `*결과물:*` block out of the per-agent action reports. The
* executor emits lines like `"✅ Created: testtimer/timer.py"` —
* we re-format them with the workspace-relative path intact so the user
* can `cd <workspace>/testtimer` and find the file.
*/
function _formatArtefactsBlock(outputs: AgentTurnOutput[]): string {
const lines: string[] = [];
for (const out of outputs) {
if (!out.actionReport || out.actionReport.length === 0) continue;
for (const r of out.actionReport) {
// Keep success lines verbatim; trim long shell-output blocks.
const trimmed = r.length > 200 ? r.slice(0, 200) + '…' : r;
lines.push(`${trimmed}`);
}
}
if (lines.length === 0) return '';
return '\n\n*결과물:*\n' + lines.join('\n');
}
+7
View File
@@ -101,6 +101,13 @@ export interface AgentTurnOutput {
durationMs: number;
/** Populated when the dispatch failed; `response` then holds the error. */
error?: string;
/**
* Lines returned by the action-tag executor — e.g. `"✅ Created: testtimer/timer.py"`.
* Kept separate from `response` so downstream surfaces (Telegram mirror,
* sidebar chip) can extract concrete file paths instead of regex-scraping
* the prose. Empty / absent when the agent emitted no action tags.
*/
actionReport?: string[];
}
/** The whole result of a company turn — persisted under sessions/<timestamp>/. */