feat: Bridge 타깃 토글 + /research 제거 + 환각·오염 방지 강화 (v2.2.205)

- Datacollect Bridge 로컬/NAS 타깃 토글(Settings 패널) + NAS URL/x-bridge-token.
  기본 local = 현행 동작 유지. (백엔드 NAS 분리 준비)
- /research(NotebookLM) 제거 — 로컬 Datacollect 앱 전용으로 분리.
- 에러로그 오염 차단: STT/스택트레이스/에러덤프를 장기기억 채굴 제외 + 자동
  추출 항목 14일 TTL(참조 시 슬라이딩 연장). 기존·수동 항목 무영향.
- 컨텍스트 [주제] 태깅 + 교차오염 방지 경계 지침.
- "확인 불가" 사실 날조 금지 규칙(R7과 구분).
- /meet STT 오타 보정: 철자 정규화 허용하되 사실 날조는 차단.

타입체크 + 407 테스트 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 16:47:55 +09:00
parent 2ea5185cd6
commit 6b017b0d31
14 changed files with 213 additions and 127 deletions
+13
View File
@@ -1,5 +1,18 @@
# Astra Patch Notes # Astra Patch Notes
## v2.2.205 (2026-06-05)
### 🧹 백엔드 분리 준비 — Bridge 타깃 토글(로컬/NAS) + /research 제거
- **Datacollect Bridge 타깃 설정** 추가 — Astra Settings 패널에서 `로컬/NAS` 전환 + NAS URL/토큰(`x-bridge-token`). 기본 `로컬` = 현행 동작 그대로. ([bridgeClient.ts](src/features/datacollect/bridgeClient.ts) · [settings-panel](media/settings-panel.html) · [settingsPanelProvider.ts](src/features/settings/settingsPanelProvider.ts))
- **`/research`(NotebookLM) 제거** — Chrome/Google 로그인 의존이라 로컬 Datacollect 앱 전용으로 분리. benchmark/youtube/wikify/blog/meet 는 유지. ([handlers.ts](src/features/datacollect/handlers.ts))
### 🛡️ 환각·오염 방지 강화 (코드 검토 기반)
- **에러로그 오염 차단** — STT/스택트레이스/에러덤프를 장기기억 채굴에서 제외(`looksLikeErrorLog`, `ERROR_NOISE`) + 자동 추출 항목에 14일 TTL(참조 시 슬라이딩 연장). 기존·수동 항목 무영향. ([LongTermMemory.ts](src/memory/LongTermMemory.ts) · [MemoryExtractor.ts](src/memory/MemoryExtractor.ts))
- **컨텍스트 주제 태깅** — 검색 청크에 `[주제]` 태그 + "다른 프로젝트·주제 섞지 말라" 경계 지침으로 무성 교차오염 방지. ([contextBudget.ts](src/retrieval/contextBudget.ts))
- **"확인 불가" 블랭킷 규칙** — 근거 없는 사실 날조 금지(수치/날짜/고유명사/결정), R7(가정 후 진행)과 구분. ([utils.ts](src/utils.ts))
### 🎙️ /meet STT 오타 보정
- 음성→텍스트 오타를 문맥·도메인 지식으로 정규화하되 **"철자 보정 ≠ 사실 날조"** 명시 — 오타 하나로 전체를 "확인 불가"로 막지 않게. metadata 를 즉석 용어집으로 활용. ([meetPrompt.ts](src/features/datacollect/prompts/meetPrompt.ts))
## v2.2.204 (2026-06-04) ## v2.2.204 (2026-06-04)
### ✨ `/weekly` 전면 교체 — 캘린더 task 기반 주간 보고서 (금주/차주) ### ✨ `/weekly` 전면 교체 — 캘린더 task 기반 주간 보고서 (금주/차주)
- **기존 `/weekly`(대표용 CEO 주간 리뷰 카드 — 고객/채용/런웨이 집계)는 제거**하고, `/weekly` 를 task 기반 금주/차주 보고서로 일원화. (제거: [dashboards.ts](src/features/teamops/handlers/dashboards.ts) `runWeekly` + weekly 전용 헬퍼) - **기존 `/weekly`(대표용 CEO 주간 리뷰 카드 — 고객/채용/런웨이 집계)는 제거**하고, `/weekly` 를 task 기반 금주/차주 보고서로 일원화. (제거: [dashboards.ts](src/features/teamops/handlers/dashboards.ts) `runWeekly` + weekly 전용 헬퍼)
+29 -2
View File
@@ -48,14 +48,41 @@
<!-- Datacollect --> <!-- Datacollect -->
<section class="section" data-section="datacollect"> <section class="section" data-section="datacollect">
<h2>Datacollect (slash 명령)</h2> <h2>Datacollect (slash 명령)</h2>
<p class="hint">채팅에서 <code>/research</code> · <code>/benchmark</code> · <code>/youtube</code> 를 입력하면 Datacollect Bridge로 위임됩니다. Bridge는 Datacollect 프로젝트에서 <code>npm run bridge</code> 로 실행해야 합니다.</p> <p class="hint">채팅에서 <code>/research</code> · <code>/benchmark</code> · <code>/youtube</code> 를 입력하면 Datacollect Bridge로 위임됩니다. <strong>타깃</strong>으로 로컬(<code>npm run bridge</code>) 또는 NAS의 경량 Bridge 중 어디를 호출할지 선택합니다.</p>
<div class="row"> <div class="row">
<label for="dcBridgeUrl">Bridge URL</label> <label for="dcBridgeTarget">Bridge 타깃</label>
<div class="input-group narrow">
<select id="dcBridgeTarget">
<option value="local">로컬 (Local)</option>
<option value="nas">NAS</option>
</select>
<button data-save="datacollect.bridgeTarget">저장</button>
</div>
<small class="hint"><strong>local</strong> = 아래 로컬 Bridge URL 사용. <strong>nas</strong> = NAS Bridge URL(+토큰) 사용. nas인데 URL이 비어 있으면 안전하게 로컬로 폴백합니다.</small>
</div>
<div class="row">
<label for="dcBridgeUrl">로컬 Bridge URL</label>
<div class="input-group"> <div class="input-group">
<input id="dcBridgeUrl" type="text" placeholder="http://127.0.0.1:3002" spellcheck="false" /> <input id="dcBridgeUrl" type="text" placeholder="http://127.0.0.1:3002" spellcheck="false" />
<button data-save="datacollect.bridgeUrl">저장</button> <button data-save="datacollect.bridgeUrl">저장</button>
</div> </div>
</div> </div>
<div class="row">
<label for="dcBridgeNasUrl">NAS Bridge URL</label>
<div class="input-group">
<input id="dcBridgeNasUrl" type="text" placeholder="https://your-nas-domain 또는 http://nas-ip:3002" spellcheck="false" />
<button data-save="datacollect.bridgeNasUrl">저장</button>
</div>
<small class="hint">타깃이 <strong>nas</strong>일 때 호출할 NAS 경량 Bridge 주소.</small>
</div>
<div class="row">
<label for="dcBridgeNasToken">NAS Bridge 토큰</label>
<div class="input-group">
<input id="dcBridgeNasToken" type="text" placeholder="(NAS의 BRIDGE_AUTH_TOKEN 값)" spellcheck="false" />
<button data-save="datacollect.bridgeNasToken">저장</button>
</div>
<small class="hint">NAS Bridge의 <code>x-bridge-token</code>. <strong>nas</strong> 타깃일 때만 요청 헤더에 실립니다.</small>
</div>
<div class="row"> <div class="row">
<label for="dcSavePath">결과물 저장 폴더</label> <label for="dcSavePath">결과물 저장 폴더</label>
<div class="input-group"> <div class="input-group">
+17
View File
@@ -25,7 +25,10 @@
const cnModelHint = $('cnModelHint'); const cnModelHint = $('cnModelHint');
// ---- Datacollect ---- // ---- Datacollect ----
const dcBridgeTarget = $('dcBridgeTarget');
const dcBridgeUrl = $('dcBridgeUrl'); const dcBridgeUrl = $('dcBridgeUrl');
const dcBridgeNasUrl = $('dcBridgeNasUrl');
const dcBridgeNasToken = $('dcBridgeNasToken');
const dcSavePath = $('dcSavePath'); const dcSavePath = $('dcSavePath');
const dcCrawlDepth = $('dcCrawlDepth'); const dcCrawlDepth = $('dcCrawlDepth');
const dcMaxPages = $('dcMaxPages'); const dcMaxPages = $('dcMaxPages');
@@ -125,9 +128,18 @@
); );
// ---- Datacollect listeners ---- // ---- Datacollect listeners ----
document.querySelector('[data-save="datacollect.bridgeTarget"]').addEventListener('click', () =>
vscode.postMessage({ type: 'datacollect.update', bridgeTarget: dcBridgeTarget.value })
);
document.querySelector('[data-save="datacollect.bridgeUrl"]').addEventListener('click', () => document.querySelector('[data-save="datacollect.bridgeUrl"]').addEventListener('click', () =>
vscode.postMessage({ type: 'datacollect.update', bridgeUrl: dcBridgeUrl.value }) vscode.postMessage({ type: 'datacollect.update', bridgeUrl: dcBridgeUrl.value })
); );
document.querySelector('[data-save="datacollect.bridgeNasUrl"]').addEventListener('click', () =>
vscode.postMessage({ type: 'datacollect.update', bridgeNasUrl: dcBridgeNasUrl.value })
);
document.querySelector('[data-save="datacollect.bridgeNasToken"]').addEventListener('click', () =>
vscode.postMessage({ type: 'datacollect.update', bridgeNasToken: dcBridgeNasToken.value })
);
document.querySelector('[data-save="datacollect.savePath"]').addEventListener('click', () => document.querySelector('[data-save="datacollect.savePath"]').addEventListener('click', () =>
vscode.postMessage({ type: 'datacollect.update', savePath: dcSavePath.value }) vscode.postMessage({ type: 'datacollect.update', savePath: dcSavePath.value })
); );
@@ -385,7 +397,12 @@
// ---- Datacollect ---- // ---- Datacollect ----
const dc = state.datacollect; const dc = state.datacollect;
if (dc) { if (dc) {
if (dcBridgeTarget && document.activeElement !== dcBridgeTarget && (dc.bridgeTarget === 'local' || dc.bridgeTarget === 'nas')) {
dcBridgeTarget.value = dc.bridgeTarget;
}
setIfNotFocused(dcBridgeUrl, dc.bridgeUrl); setIfNotFocused(dcBridgeUrl, dc.bridgeUrl);
setIfNotFocused(dcBridgeNasUrl, dc.bridgeNasUrl);
setIfNotFocused(dcBridgeNasToken, dc.bridgeNasToken);
setIfNotFocused(dcSavePath, dc.savePath); setIfNotFocused(dcSavePath, dc.savePath);
setIfNotFocused(dcCrawlDepth, dc.crawlDepth); setIfNotFocused(dcCrawlDepth, dc.crawlDepth);
setIfNotFocused(dcMaxPages, dc.maxPages); setIfNotFocused(dcMaxPages, dc.maxPages);
+18 -2
View File
@@ -2,7 +2,7 @@
"name": "astra", "name": "astra",
"displayName": "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.", "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.2.204", "version": "2.2.205",
"publisher": "g1nation", "publisher": "g1nation",
"license": "MIT", "license": "MIT",
"icon": "assets/icon.png", "icon": "assets/icon.png",
@@ -204,10 +204,26 @@
"default": false, "default": false,
"description": "Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks." "description": "Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks."
}, },
"g1nation.datacollectBridgeTarget": {
"type": "string",
"enum": ["local", "nas"],
"default": "local",
"markdownDescription": "Datacollect 백엔드(Bridge)를 어디로 보낼지 선택. **`local`**(기본) = `g1nation.datacollectBridgeUrl`(로컬 `npm run bridge`). **`nas`** = `g1nation.datacollectBridgeNasUrl`(NAS의 경량 Bridge). `nas`인데 URL이 비어 있으면 안전하게 로컬로 폴백합니다."
},
"g1nation.datacollectBridgeUrl": { "g1nation.datacollectBridgeUrl": {
"type": "string", "type": "string",
"default": "http://127.0.0.1:3002", "default": "http://127.0.0.1:3002",
"description": "Wiki/Datacollect MCP Bridge URL. /research, /benchmark, /youtube chat slash commands route here. The Bridge must be running (`npm run bridge` in the Datacollect project)." "description": "[local 타깃] Wiki/Datacollect MCP Bridge URL. /benchmark, /youtube, /wikify chat slash commands route here. The Bridge must be running (`npm run bridge` in the Datacollect project)."
},
"g1nation.datacollectBridgeNasUrl": {
"type": "string",
"default": "",
"markdownDescription": "[nas 타깃] NAS에서 도는 경량 Bridge URL (예: `https://your-nas-domain` 또는 `http://nas-ip:3002`). `datacollectBridgeTarget`을 `nas`로 두면 여기로 호출합니다. 비워두면 로컬로 폴백."
},
"g1nation.datacollectBridgeNasToken": {
"type": "string",
"default": "",
"markdownDescription": "[nas 타깃] NAS Bridge가 요구하는 `x-bridge-token` 값(Bridge의 `BRIDGE_AUTH_TOKEN`과 일치). `nas` 타깃일 때만 요청 헤더에 실립니다. 로컬 타깃에는 영향 없음."
}, },
"g1nation.datacollectSavePath": { "g1nation.datacollectSavePath": {
"type": "string", "type": "string",
+29 -16
View File
@@ -4,19 +4,36 @@ import * as vscode from 'vscode';
* Datacollect (Wiki/Datacollect 프로젝트)의 MCP Bridge HTTP 클라이언트. * Datacollect (Wiki/Datacollect 프로젝트)의 MCP Bridge HTTP 클라이언트.
* *
* Bridge는 사용자가 별도로 띄우는 Node Express 서버(`npm run bridge`)이고 * Bridge는 사용자가 별도로 띄우는 Node Express 서버(`npm run bridge`)이고
* 기본 포트는 3002. Research(NotebookLM)/Web Benchmark(Playwright)/YouTube * 기본 포트는 3002. Web Benchmark(Playwright)/YouTube(yt-dlp+transcript)/Wikify
* (yt-dlp+transcript) 같은 무거운 기능을 노출하므로, Astra는 이 endpoint를 * 같은 무거운 기능을 노출하므로, Astra는 이 endpoint를 thin client로 호출만 한다
* thin client로 호출만 한다 — Playwright/Chrome/NotebookLM-MCP 의존성을 * — Playwright/Chrome/Python 의존성을 Astra가 직접 들고 갈 필요 없음.
* Astra가 직접 들고 갈 필요 없음. * (NotebookLM Deep Research 는 ASTRA 에서 제거 — 로컬 Datacollect 앱 전용.)
* *
* URL은 `astra.datacollectBridgeUrl` VS Code 설정으로 override 가능, 기본값 * 타깃은 `g1nation.datacollectBridgeTarget`(`local`|`nas`)으로 전환한다.
* `http://127.0.0.1:3002`. 사용자가 다른 머신/포트에서 띄우면 그쪽으로 가게. * - local(기본): `g1nation.datacollectBridgeUrl` (기본 `http://127.0.0.1:3002`)
* - nas: `g1nation.datacollectBridgeNasUrl` (+ `datacollectBridgeNasToken` 헤더)
* nas 인데 URL 이 비어 있으면 안전하게 local 로 폴백한다(절대 깨지지 않게).
*/ */
export function getBridgeBaseUrl(): string { export function getBridgeBaseUrl(): string {
const raw = vscode.workspace.getConfiguration('g1nation').get<string>('datacollectBridgeUrl'); const cfg = vscode.workspace.getConfiguration('g1nation');
const url = (raw && raw.trim()) || 'http://127.0.0.1:3002'; const localUrl = (cfg.get<string>('datacollectBridgeUrl')?.trim()) || 'http://127.0.0.1:3002';
return url.replace(/\/$/, ''); if (cfg.get<string>('datacollectBridgeTarget', 'local') === 'nas') {
const nasUrl = cfg.get<string>('datacollectBridgeNasUrl')?.trim();
if (nasUrl) return nasUrl.replace(/\/$/, '');
// nas 선택했으나 URL 미설정 → 로컬로 폴백 (구동 끊기지 않게).
}
return localUrl.replace(/\/$/, '');
}
/**
* nas 타깃일 때 NAS Bridge 의 `x-bridge-token` 값. local 이거나 미설정이면 ''.
* bridgeFetch 가 이 값을 요청 헤더에 실어 보낸다(빈 문자열이면 헤더 미부착).
*/
export function getBridgeAuthToken(): string {
const cfg = vscode.workspace.getConfiguration('g1nation');
if (cfg.get<string>('datacollectBridgeTarget', 'local') !== 'nas') return '';
return (cfg.get<string>('datacollectBridgeNasToken')?.trim()) || '';
} }
/** /**
@@ -26,19 +43,13 @@ export function getBridgeBaseUrl(): string {
* 바뀌면 5+ 곳을 동시 수정. 이 객체로 모아 한 곳만 바꾸면 됨. * 바뀌면 5+ 곳을 동시 수정. 이 객체로 모아 한 곳만 바꾸면 됨.
* *
* 카테고리: * 카테고리:
* - research: NotebookLM Deep Research 워크플로
* - youtube: yt-dlp + youtube-transcript-api 기반 영상 분석 * - youtube: yt-dlp + youtube-transcript-api 기반 영상 분석
* - web: Playwright 기반 웹 페이지 추출·벤치마크 * - web: Playwright 기반 웹 페이지 추출·벤치마크
* - wiki: 생성된 위키 문서 디스크 저장 * - wiki: 생성된 위키 문서 디스크 저장
* - lm: 사용자의 LM Studio / Ollama 로 LLM 호출 프록시 * - lm: 사용자의 LM Studio / Ollama 로 LLM 호출 프록시
*/ */
export const BRIDGE_API = { export const BRIDGE_API = {
research: { // research(NotebookLM)는 ASTRA 에서 제거됨(v2.2.205) — 로컬 Datacollect 앱 전용.
start: '/api/research/start',
status: '/api/research/status',
import: '/api/research/import',
synthesize: '/api/research/synthesize',
},
youtube: { youtube: {
extract: '/api/youtube/extract', extract: '/api/youtube/extract',
}, },
@@ -104,11 +115,13 @@ export async function bridgeFetch<T = any>(
} }
try { try {
const token = getBridgeAuthToken();
const res = await fetch(url, { const res = await fetch(url, {
...init, ...init,
signal: controller.signal, signal: controller.signal,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(token ? { 'x-bridge-token': token } : {}),
...(init?.headers || {}), ...(init?.headers || {}),
}, },
}); });
+5 -96
View File
@@ -1,5 +1,6 @@
/** /**
* Datacollect handlers — /research · /benchmark · /youtube · /blog · /wikify · /meet. * Datacollect handlers — /benchmark · /youtube · /blog · /wikify · /meet.
* (/research(NotebookLM)는 v2.2.205 에서 제거 — 로컬 Datacollect 앱 전용으로 분리)
* *
* v2.2.201 에서 slashRouter.ts 에서 분리. Datacollect bridge (port 3002) 통합 * v2.2.201 에서 slashRouter.ts 에서 분리. Datacollect bridge (port 3002) 통합
* 슬래시 명령 클러스터 — NotebookLM Deep Research · Playwright 웹 스캔 · * 슬래시 명령 클러스터 — NotebookLM Deep Research · Playwright 웹 스캔 ·
@@ -33,100 +34,6 @@ import {
parseActionItems, parseActionItems,
} from './scheduling/calendarHelpers'; } from './scheduling/calendarHelpers';
// ───────────────────────────── /research ─────────────────────────────
async function runResearch(topic: string, view: Webview | undefined): Promise<boolean> {
if (!topic) {
chunk(view, `사용법: \`/research <주제>\`\n주제를 입력해 NotebookLM Deep Research를 시작합니다.\n`);
return true;
}
chunk(view, `🚀 **Research 시작**: \`${topic}\`\n\n`);
const start = await bridgeFetch<{ success: boolean; notebookId: string; taskId: string }>(
BRIDGE_API.research.start,
{ method: 'POST', body: JSON.stringify({ topic }) },
{ timeoutMs: 60_000 },
);
chunk(view, `- notebookId: \`${start.notebookId}\`\n- taskId: \`${start.taskId}\`\n\n⏳ 상태 polling (5초 간격, 최대 10분)…\n`);
const deadline = Date.now() + 10 * 60_000;
const HEARTBEAT_MS = 30_000;
const MAX_CONSECUTIVE_FAILS = 5;
const COMPLETED_SET = new Set(['completed', 'done', 'success', 'finished']);
const FAILED_SET = new Set(['failed', 'error', 'cancelled', 'canceled', 'aborted']);
let lastStatus = '';
let lastChangeAt = Date.now();
let consecutiveFails = 0;
let pollCount = 0;
let researchOk = false;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 5_000));
pollCount++;
let st: { success: boolean; result: any } | undefined;
try {
st = await bridgeFetch<{ success: boolean; result: any }>(
`${BRIDGE_API.research.status}?notebookId=${encodeURIComponent(start.notebookId)}&taskId=${encodeURIComponent(start.taskId)}`,
{ method: 'GET' },
{ timeoutMs: 60_000 },
);
consecutiveFails = 0;
} catch (e: any) {
consecutiveFails++;
if (consecutiveFails >= MAX_CONSECUTIVE_FAILS) {
chunk(view, `\n❌ Status polling 연속 실패 ${consecutiveFails}회 — bridge 가 응답하지 않습니다. 중단합니다.\n(원인: ${e?.message || String(e)})\n`);
return true;
}
chunk(view, `\n · status 호출 실패 ${consecutiveFails}/${MAX_CONSECUTIVE_FAILS} (${e?.message || 'unknown'})\n`);
continue;
}
const status = String(st.result?.status || st.result || '').trim().toLowerCase();
if (status && status !== lastStatus) {
chunk(view, ` · ${status}\n`);
lastStatus = status;
lastChangeAt = Date.now();
} else if (Date.now() - lastChangeAt > HEARTBEAT_MS) {
chunk(view, ` · ⏳ 대기 중 (${Math.round((Date.now() - lastChangeAt) / 1000)}s, 폴링 ${pollCount}회)\n`);
lastChangeAt = Date.now();
}
if (COMPLETED_SET.has(status)) { researchOk = true; break; }
if (FAILED_SET.has(status)) {
chunk(view, `\n❌ Research 실패: ${JSON.stringify(st.result).slice(0, 400)}\n`);
return true;
}
}
if (!researchOk) {
chunk(view, `\n❌ 10분 polling 후에도 완료 신호가 오지 않았습니다 (마지막 status: \`${lastStatus || '(없음)'}\`). 중단합니다.\n`);
return true;
}
chunk(view, `\n📥 import…\n`);
await bridgeFetch(BRIDGE_API.research.import, {
method: 'POST',
body: JSON.stringify({ notebookId: start.notebookId, taskId: start.taskId }),
}, {
timeoutMs: 300_000,
onHeartbeat: (elapsedMs) => chunk(view, ` · import 진행 중 (${Math.round(elapsedMs / 1000)}s)\n`),
});
chunk(view, `🧪 synthesize…\n\n`);
const synth = await bridgeFetch<{ success: boolean; markdown?: string; result?: string }>(
BRIDGE_API.research.synthesize,
{
method: 'POST',
body: JSON.stringify({ notebookId: start.notebookId, topic, rootTopic: topic, includeKnowledgeConnections: true }),
},
{
timeoutMs: 600_000,
onHeartbeat: (elapsedMs) => chunk(view, ` · synthesize LLM 작업 중 (${Math.round(elapsedMs / 1000)}s)\n`),
},
);
const md = synth.markdown || synth.result || '(빈 응답)';
chunk(view, `---\n\n${md}\n`);
return true;
}
// ───────────────────────────── /benchmark ───────────────────────────── // ───────────────────────────── /benchmark ─────────────────────────────
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> { async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
@@ -749,7 +656,9 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
// ─── 등록 ───────────────────────────────────────────────────────────────── // ─── 등록 ─────────────────────────────────────────────────────────────────
registerSlashCommand({ name: '/research', description: 'NotebookLM Deep Research 호출 후 위키 합성', handler: runResearch }); // /research(NotebookLM Deep Research)는 v2.2.205 에서 제거 — NotebookLM 은 로컬
// Datacollect 앱 전용으로 분리(Chrome/Google 로그인 의존). ASTRA 백엔드는 NAS 경량
// Bridge 로 운영 가능해야 하므로 brower-auth 가 필요한 명령은 두지 않는다.
registerSlashCommand({ name: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark }); registerSlashCommand({ name: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark });
registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube }); registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube });
registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog }); registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog });
+13 -4
View File
@@ -6,8 +6,9 @@ export function buildMeetPrompt(transcript: string, metadata: string): string {
const metaBlock = metadata.trim() const metaBlock = metadata.trim()
|| '(메타데이터 미입력 — 녹취록 내용에서 추론하거나 "확인 불가"로 표기)'; || '(메타데이터 미입력 — 녹취록 내용에서 추론하거나 "확인 불가"로 표기)';
return `# 임무 (Objective) return `# 임무 (Objective)
제공된 회의 녹취 텍스트와 메타데이터를 기반으로, 외부 지식 없이 사실 기반의 제공된 회의 녹취 텍스트와 메타데이터를 기반으로, 사실 기반의 구조화된
구조화된 회의록(Actionable Minutes)을 생성한다. 회의록(Actionable Minutes)을 생성한다. 외부/도메인 지식은 *STT 오타 보정과 용어
해석*에만 사용하고, *녹취록에 없는 새로운 사실을 추가*하는 데는 절대 쓰지 않는다.
# 역할 (Role) # 역할 (Role)
- Fact Extractor: 녹취록에 명시적으로 존재하는 사실만 추출 - Fact Extractor: 녹취록에 명시적으로 존재하는 사실만 추출
@@ -19,6 +20,14 @@ export function buildMeetPrompt(transcript: string, metadata: string): string {
# 데이터 우선순위 (Data Priority) # 데이터 우선순위 (Data Priority)
1순위: 메타데이터 / 2순위: 녹취록 내용. 충돌 시 반드시 메타데이터를 사용한다. 1순위: 메타데이터 / 2순위: 녹취록 내용. 충돌 시 반드시 메타데이터를 사용한다.
# STT 오타 보정 (Transcription Noise Handling — 이 녹취록은 음성→텍스트 변환물이라 오타가 많다)
- 발음이 유사한 단어가 잘못 표기돼 있다(예: "Dovrunner"→"Doverunner", "페어플레이"→"페어플래이"). **한 단어의 철자에 집착하지 말고 주변 문맥(앞뒤 키워드)으로 의미를 복원하라.**
- 발음이 유사한 명백한 오타는 문맥상 맞는 기술 용어·고유명사로 **정규화**하라. 흔한 기술 용어(DRM, SDK, 딥링크, API, 렌더링, 페어플레이, 암호화 등)는 도메인 지식으로 보정해도 된다.
- 메타데이터에 인명·기업명·제품명·용어가 주어졌으면 그것을 **정답 표기**로 보고, 녹취록의 유사 오타를 그 표기로 맞춘다(메타데이터는 사실상 용어집 역할).
- **핵심 구분 (절대 혼동 금지)**: '녹취록에 *있는* 단어의 철자를 문맥으로 바로잡는 것'은 허용·권장된다. '녹취록에 *없는* 사실(수치·결정·없던 항목)을 지어내는 것'은 금지다. **철자 보정 ≠ 사실 날조.**
- 철자가 틀려도 문맥상 의미가 분명하면 그 의미를 확정된 것으로 다뤄라 — **오타 하나 때문에 멀쩡한 내용 전체를 "확인 불가"로 막지 말 것.**
- 정규화는 했지만 문맥으로도 정체가 끝내 모호한 용어에 한해, 정규화 표기 옆에 원문을 함께 남긴다: 예) \`Doverunner(원문: "Dovrunner", 표기 불확실)\`.
# 처리 절차 (Processing Flow) # 처리 절차 (Processing Flow)
1. Speaker Tracking — 발언자 ID/이름을 끝까지 유지한다. **누가 한 말인지를 절대 임의로 바꾸거나 합치지 말 것.** 1. Speaker Tracking — 발언자 ID/이름을 끝까지 유지한다. **누가 한 말인지를 절대 임의로 바꾸거나 합치지 말 것.**
2. Topic Reclustering — 이 녹취록은 비선형이다(A→B→Z→다시 A 식으로 주제가 튄다). 녹취록 전체를 훑어 **흩어진 발언을 주제별로 다시 묶은 뒤** 정리한다. 녹취록상 앞뒤로 붙어 있다는 이유만으로 두 발언을 인과·연결 관계로 엮지 말 것(**인접 ≠ 연결**). 2. Topic Reclustering — 이 녹취록은 비선형이다(A→B→Z→다시 A 식으로 주제가 튄다). 녹취록 전체를 훑어 **흩어진 발언을 주제별로 다시 묶은 뒤** 정리한다. 녹취록상 앞뒤로 붙어 있다는 이유만으로 두 발언을 인과·연결 관계로 엮지 말 것(**인접 ≠ 연결**).
@@ -34,8 +43,8 @@ export function buildMeetPrompt(transcript: string, metadata: string): string {
# 근거·정확성 규칙 (Grounding Rules — 반드시 준수, 할루시네이션 방지) # 근거·정확성 규칙 (Grounding Rules — 반드시 준수, 할루시네이션 방지)
- **녹취록에 명시된 내용만 적는다.** 추론으로 빈칸을 메우거나, 별개의 발언을 하나의 인과 사슬로 합성하지 말 것. - **녹취록에 명시된 내용만 적는다.** 추론으로 빈칸을 메우거나, 별개의 발언을 하나의 인과 사슬로 합성하지 말 것.
- **발언 주체가 불명확하면 추측하지 말 것.** 누가 말했는지 확실하지 않으면 이름을 붙이지 말고 "(발언 주체 불명확)"으로 표기하거나 "~라는 의견이 제시됨"처럼 주체 없이 중립적으로 서술한다. 어떤 발언자의 말을 다른 발언자의 결론으로 옮기는 것은 가장 심각한 오류다. - **발언 주체가 불명확하면 추측하지 말 것.** 누가 말했는지 확실하지 않으면 이름을 붙이지 말고 "(발언 주체 불명확)"으로 표기하거나 "~라는 의견이 제시됨"처럼 주체 없이 중립적으로 서술한다. 어떤 발언자의 말을 다른 발언자의 결론으로 옮기는 것은 가장 심각한 오류다.
- **녹취록에 없는 숫자·날짜·금액·고유명사·제품명을 만들어내지 말 것.** 불확실하면 "확인 필요"로 둔다. - **녹취록에 없는 숫자·날짜·금액·결정·없던 항목을 만들어내지 말 것.** (단, 녹취록에 *있는데 철자만 틀린* 용어·고유명사를 문맥으로 정규화하는 것은 허용 — 위 'STT 오타 보정' 참조.) 정말 근거 없는 *사실*만 "확인 필요"로 둔다.
- 어떤 항목의 근거가 녹취록에서 약하거나 모호하면, 지어내지 말고 해당 항목 끝에 "(확인 필요)"를 붙인다. - 어떤 항목의 *내용 자체*가 녹취록에서 약하거나 모호하면(=철자 문제가 아니라 사실이 불확실) 지어내지 말고 해당 항목 끝에 "(확인 필요)"를 붙인다. 단순 표기 오타는 여기 해당하지 않는다.
- Decision은 명시적 합의 표현이 있을 때만 '결정됨'이다. 합의가 불명확하면 '논의 중' 또는 오픈 이슈로 둔다. - Decision은 명시적 합의 표현이 있을 때만 '결정됨'이다. 합의가 불명확하면 '논의 중' 또는 오픈 이슈로 둔다.
# 출력 검증 (Validation) # 출력 검증 (Validation)
@@ -88,7 +88,13 @@ interface SettingsState {
polishPersonaOverride: string; polishPersonaOverride: string;
}; };
datacollect: { datacollect: {
/** 'local' | 'nas' — 어느 Bridge 인스턴스를 호출할지. */
bridgeTarget: string;
bridgeUrl: string; bridgeUrl: string;
/** NAS 경량 Bridge URL (nas 타깃일 때). */
bridgeNasUrl: string;
/** NAS Bridge 의 x-bridge-token (nas 타깃일 때 헤더로 전송). */
bridgeNasToken: string;
/** Empty → results saved to the Bridge's WIKI_RAW_PATH default. */ /** Empty → results saved to the Bridge's WIKI_RAW_PATH default. */
savePath: string; savePath: string;
crawlDepth: number; crawlDepth: number;
@@ -605,9 +611,19 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
// savePath 가 비어 있으면 Bridge 의 WIKI_RAW_PATH 환경변수가 저장 위치를 결정한다. // savePath 가 비어 있으면 Bridge 의 WIKI_RAW_PATH 환경변수가 저장 위치를 결정한다.
private async _handleDatacollectUpdate(msg: any): Promise<void> { private async _handleDatacollectUpdate(msg: any): Promise<void> {
if (typeof msg.bridgeTarget === 'string') {
const t = msg.bridgeTarget.trim() === 'nas' ? 'nas' : 'local';
await this._safeConfigUpdate('datacollectBridgeTarget', t);
}
if (typeof msg.bridgeUrl === 'string') { if (typeof msg.bridgeUrl === 'string') {
await this._safeConfigUpdate('datacollectBridgeUrl', msg.bridgeUrl.trim()); await this._safeConfigUpdate('datacollectBridgeUrl', msg.bridgeUrl.trim());
} }
if (typeof msg.bridgeNasUrl === 'string') {
await this._safeConfigUpdate('datacollectBridgeNasUrl', msg.bridgeNasUrl.trim());
}
if (typeof msg.bridgeNasToken === 'string') {
await this._safeConfigUpdate('datacollectBridgeNasToken', msg.bridgeNasToken.trim());
}
if (typeof msg.savePath === 'string') { if (typeof msg.savePath === 'string') {
await this._safeConfigUpdate('datacollectSavePath', msg.savePath.trim()); await this._safeConfigUpdate('datacollectSavePath', msg.savePath.trim());
} }
@@ -675,7 +691,10 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
polishPersonaOverride: cfg.get<string>('polishPersonaOverride', '') ?? '', polishPersonaOverride: cfg.get<string>('polishPersonaOverride', '') ?? '',
}, },
datacollect: { datacollect: {
bridgeTarget: cfg.get<string>('datacollectBridgeTarget', 'local') || 'local',
bridgeUrl: cfg.get<string>('datacollectBridgeUrl', '') || '', bridgeUrl: cfg.get<string>('datacollectBridgeUrl', '') || '',
bridgeNasUrl: cfg.get<string>('datacollectBridgeNasUrl', '') || '',
bridgeNasToken: cfg.get<string>('datacollectBridgeNasToken', '') || '',
savePath: cfg.get<string>('datacollectSavePath', '') || '', savePath: cfg.get<string>('datacollectSavePath', '') || '',
crawlDepth: cfg.get<number>('datacollectCrawlDepth', 1) ?? 1, crawlDepth: cfg.get<number>('datacollectCrawlDepth', 1) ?? 1,
maxPages: cfg.get<number>('datacollectMaxPages', 8) ?? 8, maxPages: cfg.get<number>('datacollectMaxPages', 8) ?? 8,
+1 -1
View File
@@ -165,7 +165,7 @@ export async function runDatacollectSetup(): Promise<void> {
if (after.missingPackages.length === 0) { if (after.missingPackages.length === 0) {
output.appendLine('\n✅ 설치 후 import 검증 통과. Datacollect 슬래시 명령을 바로 쓸 수 있습니다.'); output.appendLine('\n✅ 설치 후 import 검증 통과. Datacollect 슬래시 명령을 바로 쓸 수 있습니다.');
vscode.window.showInformationMessage( vscode.window.showInformationMessage(
`Astra Setup 완료: ${probe.missingPackages.join(', ')} 설치됨. /youtube /research 등 다시 시도해 보세요.`, `Astra Setup 완료: ${probe.missingPackages.join(', ')} 설치됨. /youtube 등 다시 시도해 보세요.`,
); );
} else { } else {
output.appendLine(`\n⚠️ pip 은 성공으로 끝났지만 import 검증에서 여전히 ${after.missingPackages.join(', ')} 가 안 보입니다.`); output.appendLine(`\n⚠️ pip 은 성공으로 끝났지만 import 검증에서 여전히 ${after.missingPackages.join(', ')} 가 안 보입니다.`);
+2 -2
View File
@@ -311,8 +311,8 @@ const HELP_CATEGORIES: HelpCategory[] = [
{ {
title: '리서치·분석', title: '리서치·분석',
emoji: '🔬', emoji: '🔬',
match: (n) => ['/research', '/benchmark', '/youtube', '/blog', '/wikify', '/meet'].includes(n), match: (n) => ['/benchmark', '/youtube', '/blog', '/wikify', '/meet'].includes(n),
blurb: 'Datacollect bridge 통합 — Deep Research / 웹 벤치마크 / YouTube 4-렌즈 / Blog 파이프라인 / Wikify / 회의록', blurb: 'Datacollect bridge 통합 — 웹 벤치마크 / YouTube 4-렌즈 / Blog 파이프라인 / Wikify / 회의록 (NotebookLM Deep Research 는 로컬 Datacollect 앱으로 분리)',
}, },
{ {
title: '시스템·메모리', title: '시스템·메모리',
+43 -2
View File
@@ -169,6 +169,12 @@ export class LongTermMemory {
.slice(0, 5); .slice(0, 5);
if (alwaysInclude.length === 0) return null; if (alwaysInclude.length === 0) return null;
// 표시되는(=사용되는) 자동 추출 항목의 만료를 연장.
const refreshAt = Date.now() + LongTermMemory.AUTO_EXTRACT_TTL_MS;
for (const e of alwaysInclude) {
if (e.expiresAt) { e.expiresAt = refreshAt; this.dirty = true; }
}
const content = alwaysInclude const content = alwaysInclude
.map((e) => `- [${e.category}] ${e.content}`) .map((e) => `- [${e.category}] ${e.content}`)
.join('\n'); .join('\n');
@@ -181,10 +187,13 @@ export class LongTermMemory {
}; };
} }
// Mark as referenced // Mark as referenced — 자동 추출(만료 있음) 항목은 참조 시 만료를 슬라이딩 연장해
// '쓰면 살아남고, 안 쓰면 TTL 뒤 소멸'. 영속(수동) 항목은 expiresAt 이 없어 무영향.
const refreshAt = Date.now() + LongTermMemory.AUTO_EXTRACT_TTL_MS;
for (const { entry } of relevant) { for (const { entry } of relevant) {
entry.lastReferencedAt = Date.now(); entry.lastReferencedAt = Date.now();
entry.referenceCount++; entry.referenceCount++;
if (entry.expiresAt) entry.expiresAt = refreshAt;
} }
this.dirty = true; this.dirty = true;
@@ -202,6 +211,34 @@ export class LongTermMemory {
// ─── Extraction Helpers ─── // ─── Extraction Helpers ───
/** 자동 추출 장기기억 기본 TTL (14일). 참조될 때마다 슬라이딩 연장된다. */
public static readonly AUTO_EXTRACT_TTL_MS = 14 * 24 * 60 * 60 * 1000;
/** 짧은 후보 문자열에 박힌 구체적 에러 시그니처(예외명/에러코드/스택 조각) 탐지. */
private static readonly ERROR_NOISE = /\b(?:TypeError|ReferenceError|SyntaxError|RangeError|KeyError|ValueError|Exception|Traceback|ECONNREFUSED|ETIMEDOUT|ENOENT|EACCES|ECONNRESET|errno|npm ERR!)\b|error\s+TS\d|:\d+:\d+\)|File ".+", line \d/i;
/**
* 붙여넣은 에러 로그·스택 트레이스·실패 출력처럼 보이는 텍스트인지 *보수적으로* 추정.
* 이런 입력은 '분석 대상'(휘발)이지 '지식'(영속)이 아니므로 장기 기억 채굴에서 제외한다.
* 일반 산문이 'error' 를 한 번 언급한 정도로는 걸리지 않게 강한/약한 신호를 구분한다.
*/
public static looksLikeErrorLog(text: string): boolean {
if (!text) return false;
const strong = [
/Traceback \(most recent call last\)/,
/^\s*at\s+.+\(.+:\d+:\d+\)/m, // JS 스택 프레임
/\bFile ".+", line \d+/, // Python 프레임
/npm ERR!/,
/\b(?:TypeError|ReferenceError|SyntaxError|RangeError|KeyError|ValueError)\b/,
/\b(?:ECONNREFUSED|ETIMEDOUT|ENOENT|EACCES|ECONNRESET)\b/,
/error\s+TS\d{3,}/i, // tsc 에러
];
if (strong.some((re) => re.test(text))) return true;
const weak = (text.match(/\b(?:error|errors|exception|failed|failure|stacktrace|errno|fatal|panic|assertion)\b/gi) || []).length
+ (text.match(/\[(?:error|warn|fatal)\]/gi) || []).length;
return weak >= 3 && text.split('\n').length >= 3;
}
/** /**
* 대화 메시지에서 장기 기억 후보를 패턴 매칭으로 추출합니다. * 대화 메시지에서 장기 기억 후보를 패턴 매칭으로 추출합니다.
* LLM 호출 없이 동작합니다. * LLM 호출 없이 동작합니다.
@@ -235,6 +272,8 @@ export class LongTermMemory {
for (const msg of messages) { for (const msg of messages) {
if (msg.role !== 'user') continue; if (msg.role !== 'user') continue;
const text = msg.content; const text = msg.content;
// 에러 로그/스택 트레이스 덤프는 '분석 대상'(휘발)이므로 통째로 채굴 제외.
if (LongTermMemory.looksLikeErrorLog(text)) continue;
for (const pattern of rulePatterns) { for (const pattern of rulePatterns) {
pattern.lastIndex = 0; pattern.lastIndex = 0;
@@ -269,9 +308,11 @@ export class LongTermMemory {
} }
} }
// Deduplicate by content // Deduplicate by content + 에러 시그니처가 박힌 후보 제거
// ('goal: fix ECONNREFUSED ...' 같은 에러 내용이 지식으로 흡수되는 오염 방지).
const seen = new Set<string>(); const seen = new Set<string>();
return candidates.filter((c) => { return candidates.filter((c) => {
if (LongTermMemory.ERROR_NOISE.test(c.content)) return false;
const key = c.content.toLowerCase(); const key = c.content.toLowerCase();
if (seen.has(key)) return false; if (seen.has(key)) return false;
seen.add(key); seen.add(key);
+6 -1
View File
@@ -38,13 +38,18 @@ export class MemoryExtractor {
}; };
// 1. Long-Term Memory 추출 // 1. Long-Term Memory 추출
// 자동 추출 항목엔 TTL(14일)을 부여 — 참조될 때마다 슬라이딩 연장되므로 실제로
// 쓰이는 지식은 살아남고, 한 번 들어온 일회성·잡음 내용은 14일 뒤 자연 소멸한다.
// (에러 로그/실패 데이터는 extractCandidates 단계에서 이미 걸러짐.)
const candidates = LongTermMemory.extractCandidates(messages); const candidates = LongTermMemory.extractCandidates(messages);
const expiresAt = Date.now() + LongTermMemory.AUTO_EXTRACT_TTL_MS;
for (const candidate of candidates) { for (const candidate of candidates) {
longTermMemory.addEntry( longTermMemory.addEntry(
candidate.category, candidate.category,
candidate.content, candidate.content,
`session:${sessionId}`, `session:${sessionId}`,
0.7 // 자동 추출이므로 기본 신뢰도 0.7 0.7, // 자동 추출이므로 기본 신뢰도 0.7
{ expiresAt },
); );
} }
result.longTermCandidates = candidates.length; result.longTermCandidates = candidates.length;
+17 -1
View File
@@ -90,6 +90,19 @@ export function selectWithinBudget(
return { selected, dropped, tokensUsed }; return { selected, dropped, tokensUsed };
} }
/**
* 청크의 '주제(Subject)' 태그를 도출한다 — 서로 다른 프로젝트/주제의 정보가 한
* 컨텍스트에 섞일 때 모델이 경계를 인지하도록(무성 교차오염 방지). category 가 있으면
* 그걸, 없으면 title/filePath 의 최상위 폴더 세그먼트를 주제로 본다. 파일명만 있으면 ''.
*/
function deriveSubject(chunk: RetrievalChunk): string {
const cat = (chunk.metadata.category || '').trim();
if (cat) return cat;
const ref = (chunk.title || chunk.metadata.filePath || '').replace(/\\/g, '/');
const seg = ref.split('/').filter(Boolean);
return seg.length >= 2 ? seg[0] : '';
}
/** /**
* 선택된 청크들을 하나의 컨텍스트 문자열로 조립합니다. * 선택된 청크들을 하나의 컨텍스트 문자열로 조립합니다.
* 소스별로 그룹화하여 가독성을 높입니다. * 소스별로 그룹화하여 가독성을 높입니다.
@@ -123,9 +136,11 @@ export function assembleContext(chunks: RetrievalChunk[]): string {
const items = groupChunks const items = groupChunks
.map((c) => { .map((c) => {
const metadata = c.metadata; const metadata = c.metadata;
const subject = deriveSubject(c);
const subjectTag = subject ? `[${subject}] ` : '';
const conflictTag = metadata.conflictDetected ? ` [⚠️ CONFLICT: ${metadata.conflictSeverity}]` : ''; const conflictTag = metadata.conflictDetected ? ` [⚠️ CONFLICT: ${metadata.conflictSeverity}]` : '';
const coverageTag = metadata.queryCoverage !== undefined ? ` (Coverage: ${metadata.queryCoverage.toFixed(2)})` : ''; const coverageTag = metadata.queryCoverage !== undefined ? ` (Coverage: ${metadata.queryCoverage.toFixed(2)})` : '';
return `- ${c.title}${conflictTag}${coverageTag}: ${c.content}`; return `- ${subjectTag}${c.title}${conflictTag}${coverageTag}: ${c.content}`;
}) })
.join('\n'); .join('\n');
sections.push(`### ${label}\n${items}`); sections.push(`### ${label}\n${items}`);
@@ -134,6 +149,7 @@ export function assembleContext(chunks: RetrievalChunk[]): string {
return [ return [
'[MEMORY CONTEXT]', '[MEMORY CONTEXT]',
'Review this layered memory before preparing the answer. Use it only when relevant, and prefer the current user request when there is conflict.', 'Review this layered memory before preparing the answer. Use it only when relevant, and prefer the current user request when there is conflict.',
'각 항목 앞의 [주제] 태그와 섹션 출처를 확인하라. **현재 요청과 다른 프로젝트·주제의 항목은 사용하지 마라** — 서로 다른 프로젝트의 규칙·결정·수치·고유명사를 섞지 말 것. 어느 항목이 현재 작업과 관련 있는지 불확실하면 그 항목에 의존하지 마라.',
'', '',
sections.join('\n\n') sections.join('\n\n')
].join('\n'); ].join('\n');
+1
View File
@@ -234,6 +234,7 @@ Then reply with one short line stating what was started and where.
2. [NO MARKDOWN MARKERS] PLAIN TEXT ONLY. Do NOT emit "#", "##", "###", "**", "__", "> ", "* " as formatting. Section labels are bare Korean words on their own line (e.g. a line that says just "핵심 요약" — no "#", no "**"). Bullets use "- " only. Inline code with backticks (e.g. \`src/agent.ts\`) and triple-backtick code blocks for actual code are fine. 2. [NO MARKDOWN MARKERS] PLAIN TEXT ONLY. Do NOT emit "#", "##", "###", "**", "__", "> ", "* " as formatting. Section labels are bare Korean words on their own line (e.g. a line that says just "핵심 요약" — no "#", no "**"). Bullets use "- " only. Inline code with backticks (e.g. \`src/agent.ts\`) and triple-backtick code blocks for actual code are fine.
3. [NO INTERNAL LOGS] Never output <details>, "2nd Brain Trace", or "Debug JSON" blocks. 3. [NO INTERNAL LOGS] Never output <details>, "2nd Brain Trace", or "Debug JSON" blocks.
4. [NO SECTION LEAKAGE] Never output sections named "요청 요약", "사용자 의도 추론", "프로젝트 기록 대상 확인", "핵심 확인 질문", or "근거 파일 경로". 4. [NO SECTION LEAKAGE] Never output sections named "요청 요약", "사용자 의도 추론", "프로젝트 기록 대상 확인", "핵심 확인 질문", or "근거 파일 경로".
5. [확인 불가 — 사실 날조 금지] 지식 베이스·제공된 컨텍스트·이번 세션에 읽은 파일에 근거가 없는 사실(수치, 날짜, 금액, 고유명사, 파일/함수/포트명, 결정 사항, "이미 ~다/~로 정해졌다" 류 단정)은 지어내지 마라. 근거가 없으면 추측으로 메우지 말고 "확인 불가" 또는 "근거 없음 — 확인 필요"라고 명시하라. 불확실하면 단정 톤을 낮춰라("~로 보인다", "확인 필요"). 단, 이 규칙은 *사실 주장*에만 적용된다 — R7 의 '합리적 가정 후 진행'은 *작업 수행*의 기본값 선택에는 그대로 유효하다(가정은 "가정:" 한 줄로 밝힌다).
[OUTPUT FORMAT — 7 hard rules] [OUTPUT FORMAT — 7 hard rules]
These rules override any other formatting habit. Apply them to EVERY answer. These rules override any other formatting habit. Apply them to EVERY answer.