feat: v2.2.74 → v2.2.82 — chunked writer + 코드 리뷰 패치 + /youtube 확장

주요 변경:

[chunked writer 아키텍처 (v2.2.74~v2.2.75)]
- 5-stage 다중 에이전트(planner/researcher/reflector/writer/synthesizer)
  파이프라인 제거 → 단일 ChunkedWriter 의 outline → section[N] → polish
  3-step 으로 교체. 본문 분석에서 추상화 손실 / 토큰 폭증 문제 해소
- 답변 길이 자동 분기: 짧은 prompt 는 fast-path direct 1회 호출,
  본문 분석은 chunked. outline 빈 배열도 direct 폴백

[코드 리뷰 9개 항목 일괄 패치 (v2.2.76)]
- /research polling hang 방어 (heartbeat + status 정규화 + 연속 실패 abort)
- 회사 모드 dispatcher abort 신호를 AIService.chat 까지 전달
- bridgeFetch 에 onHeartbeat 콜백 도입 (slow endpoint 사용자 친화적)
- dead code 정리: reflectionPersister.ts 제거 + enableReflection 등 좀비 config 키
- parseOutline 의 empty vs fallback reason 명시적 분리
- chatHandlers 의 회사 모드 케이스 ~325줄을 src/sidebar/companyHandlers.ts 로 분리
- Intent Alignment 라운드 한도 도달 시 smart 모드 자동 진행
- LM Studio doSwitch unload 실패 시 currentModel 정리 + load 강행
- retrieval informationDensity → queryCoverage 정합화

[/youtube 채널 지원 (v2.2.77~v2.2.82)]
- 채널/플레이리스트 URL 자동 감지 + n:N 으로 영상 개수 지정 (최대 50)
- 채널 루트 URL 에 /videos 탭 자동 append (yt-dlp enumeration 정상화)
- 영상별 순차 처리 (queue 패턴) + i/N 진행 표시 + 마지막 통계 요약
- mode:info / mode:benchmark / mode:both 분석 모드 분기
  - info: 영상 내용을 지식 카드로 추출 (튜토리얼·강의·뉴스용)
  - benchmark: 4-렌즈 대본 역기획서 (콘텐츠 제작 벤치마크용)
  - both: 둘 다 (기본)
  - bare keyword 도 허용: /youtube <url> n:1 info
- bridge 에러 메시지 [object Object] 깨짐 수정 (구조화 에러 추출)
- "패키지 없음" 등 환경 의존성 에러에 자동 가이드 첨부

[Astra: Setup Datacollect Dependencies 명령 추가 (v2.2.80)]
- Python 자동 감지 + yt-dlp / youtube-transcript-api 자동 설치
- macOS PEP 668 환경 자동 폴백 (--user --break-system-packages)
- /youtube 등에서 패키지 미설치 감지 시 "Install Now" 버튼 notification

[테스트]
- tests/agentEngine.test.ts 를 chunked flow 에 맞춰 전체 재작성
- tests/resilience_stress.test.ts Scenario B/D 를 role-aware mock 으로 갱신
- 399/399 통과

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
g1nation
2026-05-23 23:13:21 +09:00
parent 0712014fcb
commit ded3eea7ce
39 changed files with 2098 additions and 1820 deletions
+136 -162
View File
@@ -63,7 +63,7 @@ export abstract class BaseAgent {
}
const data = await response.json() as any;
// 강력한 응답 추출 (Multi-path parsing)
let content = '';
if (data.message?.content) content = data.message.content;
@@ -71,7 +71,7 @@ export abstract class BaseAgent {
else if (data.choices?.[0]?.text) content = data.choices[0].text;
else if (data.response) content = data.response;
else if (typeof data === 'string') content = data;
return content || '';
} catch (error: any) {
clearTimeout(timeoutId);
@@ -99,175 +99,149 @@ function anySignal(signals: AbortSignal[]): AbortSignal {
return controller.signal;
}
export class PlannerAgent extends BaseAgent {
private readonly persona = `You are the [Master Strategist & Planner].
Your sole purpose is to transform vague requests into flawless, high-resolution execution blueprints.
- THINKING PROCESS: You must analyze the request from multiple angles (technical, logical, structural).
- OUTPUT RULE: You MUST output a structured <blueprint> using Markdown.
- COMPONENTS: Each blueprint must have [Objective], [Core Challenges], [Data Requirements], and [Step-by-Step Research Tasks].
- CONSTRAINT: Do not be vague. Use professional terminology. If the request is too simple, expand it with relevant technical considerations.`;
async execute(input: string, brainContext?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
const wrappedInput = `### SYSTEM INSTRUCTION: GENERATE EXECUTION BLUEPRINT
1. Target Goal: ${input}
2. Available Knowledge Base & Policy: ${brainContext}
3. Mission: Create a comprehensive research roadmap.`;
return this.callLLM(this.persona, wrappedInput, signal);
}
}
export class ResearcherAgent extends BaseAgent {
private readonly persona = `You are the [Senior Technical Researcher].
Your mission is to extract, filter, and synthesize critical data based on a strategic blueprint.
- DATA INTEGRITY: Only provide high-quality, verified-style information.
- FORMAT: Use [Key Facts], [Technical Deep-Dive], and [Summary of Knowledge] sections.
- CRITICAL THINKING: Identify gaps in the plan and provide extra insights to fill those gaps.
- NO FLUFF: Be concise but extremely dense with information.`;
async execute(input: string, brainContext?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
const wrappedInput = `### SYSTEM INSTRUCTION: DATA HARVESTING
1. Blueprint to Follow: ${input}
2. Contextual Constraints & Policy: ${brainContext}
3. Mission: Provide a dense summary of facts and technical insights.`;
return this.callLLM(this.persona, wrappedInput, signal);
}
}
export class WriterAgent extends BaseAgent {
// [5-stage pipeline] Writer는 이제 "Drafter" 역할: 빠르게 1차 초안만 생성한다.
// 최종 다듬기/요약/critique 반영은 후속 SynthesizerAgent가 담당하므로,
// 작은 모델이 한 번에 모든 것을 끝내려 컨텍스트를 폭주시키는 일이 없도록 한다.
private readonly persona = `You are the [Section Drafter].
Your goal is to produce a STRUCTURED FIRST DRAFT that the downstream Synthesizer will polish.
- SCOPE: Cover each major topic from the research as its own section. Each section starts with a short plain-text label on its own line (e.g. "잘된 점", "부족한 점") — NO "#", "##", "**", "__", ">" markers. Use "- " for bullets, never "* ".
- DENSITY: Pack facts; skip flowery prose, executive summaries, and closing remarks (the Synthesizer adds those).
- TONE: Plain, factual, developer-readable Korean.
- BREVITY: Keep each section tight — better to leave the Synthesizer something to merge than to run out of tokens mid-section.
- SELF-CORRECTION: When a [REFLECTION CRITIQUE] block is provided, address each listed gap inline in the relevant section. Do not silently ignore the critique.
- LANGUAGE: KOREAN.`;
async execute(input: string, originalRequest?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
// [Astra v4.0] Advisor 모드 처리
if (options?.config?.role === 'advisor') {
const advisorPersona = `You are the [Strategic Proactive Advisor].
Analyze the provided report and suggest 3 high-impact next actions for the user.
- Focus on decision forks, risk mitigation, or immediate implementation steps.
- Be extremely concrete and actionable.
- Respond in KOREAN.`;
return this.callLLM(advisorPersona, input, signal);
}
// Fix 3: Trim input if it's too long (Basic Context Diet)
const trimmedData = input.length > 8000 ? input.substring(0, 8000) + '... [Data Trimmed for Performance]' : input;
const policy = options?.context || '';
const reflection = options?.priorResults?.reflection;
// Reflector 결과가 있으면 별도 블록으로 주입. 길이 4000자 cap (Writer 입력 비대화 방지).
const reflectionBlock = reflection && reflection.trim().length > 0
? `\n5. [REFLECTION CRITIQUE — must be addressed]:\n${reflection.length > 4000 ? reflection.substring(0, 4000) + '... [Critique Trimmed]' : reflection}`
: '';
const wrappedInput = `### SYSTEM INSTRUCTION: SECTIONED DRAFT
1. Gathered Research Data: ${trimmedData}
2. User's Original Objective: ${originalRequest}
3. Applied Knowledge & Filtering Policy: ${policy}
4. Mission: Produce a STRUCTURED FIRST DRAFT in KOREAN — section per topic, factual bullets allowed.
Do NOT add a final executive summary or closing remarks; the Synthesizer will handle those.${reflectionBlock}`;
return this.callLLM(this.persona, wrappedInput, signal);
}
/**
* Section outline shape produced by ChunkedWriter in the 'outline' role.
* Tokens are kept minimal — heading is what the section is about, scope tells
* the next call what facts to keep inside that section so adjacent sections
* don't duplicate content.
*/
export interface SectionOutline {
heading: string;
scope: string;
}
/**
* [5-stage pipeline] SynthesizerAgent
* Drafter가 작성한 1차 초안을 받아 최종 사용자 답변으로 다듬는다.
* - 입력이 "이미 정리된 draft" 라서 컨텍스트가 작다 → 작은 로컬 모델도 한 번에 처리 가능.
* - 역할은 (a) 도입 한 줄 (b) 섹션 흐름 정리 (c) 결론/제안 한 단락. 새로운 사실을 만들지 않는다.
* - Reflector critique이 함께 전달되면, 그 항목들이 답변에 정말 반영되었는지 한 번 더 점검한다.
* ChunkedWriter — single-agent replacement for the old 5-stage pipeline.
*
* Why this exists: the old pipeline (planner → researcher → reflector → writer
* → synthesizer) was different *personas* in series, which (a) burned tokens
* by repeating context at every hop and (b) drifted away from the user's
* actual request because intermediate agents only saw earlier agents'
* abstractions — never the original message. The user's intent was simpler:
* **split the *answer* into chunks so each LLM call stays under the token
* cap, then join.** That's what this class does.
*
* Flow inside `AgentEngine.runMission`:
* 1. role='outline' → 1 LLM call returns a JSON list of section outlines
* (N = 1..MAX, the model decides based on expected
* output length).
* 2. role='section' → N LLM calls, one per outline entry, each given the
* original prompt + this section's scope + already-
* written sections (truncated) so it can avoid
* repeating earlier content.
* 3. role='polish' → 1 LLM call takes the joined draft and produces a
* final clean copy (fixes typos, removes
* hallucinations / unsupported claims, smooths flow).
*
* Every role uses the *same* model — no persona mismatch, no agent-to-agent
* abstraction loss. The only thing that changes is the per-call system
* prompt picked here based on `options.config.role`.
*/
export class SynthesizerAgent extends BaseAgent {
private readonly persona = `You are the [Final Editor & Synthesizer].
You receive a structured FIRST DRAFT (already broken into sections) plus the user's original request and (optionally) a reflection critique.
Your only job is to produce the FINAL user-facing answer.
export class ChunkedWriter extends BaseAgent {
/** Hard cap on section count regardless of what the outline model returns. */
static readonly MAX_SECTIONS = 5;
[OUTPUT FORMAT — 7 hard rules — these override every other formatting habit]
R1. CONCLUSION FIRST. The very first sentence is the conclusion / verdict / recommendation. No greeting, no "분석해보겠습니다", no scene-setting paragraph, no "핵심 요약" label line on top. Just the conclusion as sentence 1. A reader who stops after sentence 1 must know what you decided.
R2. AT MOST 3 SECTIONS. Total. A section = a label line + body, or a clearly separated numbered group. If the answer fits without sections, use none. Three is the ceiling, not a target.
R3. NO REPETITION. Each sentence carries new information. If you said it in the conclusion, do NOT restate it in a later section.
R4. BOLD ≤ 3 INSTANCES. Across the entire answer, use bold at most 3 times — reserve it for truly load-bearing words (file name, verdict word, hard number). Most answers should have zero.
R5. JUDGE WITHOUT ASKING. If a defensible decision is reachable from the draft + original request, deliver it and act. Do NOT ask permission, do NOT bounce the question back.
R6. ASK ONE QUESTION ONLY WHEN: (a) the path forks into two materially different directions and user intent is unknown, OR (b) the next step is irreversible (delete, force-push, drop table, overwrite uncommitted work, send external message). One plain sentence on its own line at the end. No "핵심 확인 질문" label, no "질문 의도", no follow-ups.
R7. GUESS-AND-ACT WITH STATED ASSUMPTION. If a detail is missing but a reasonable guess exists, guess and act, declaring the assumption in one line prefixed "가정:".
private readonly outlinePersona = `You are a concise editor planning the structure of a Korean answer.
Decide how many sections the answer needs (0..${ChunkedWriter.MAX_SECTIONS}). Pick the *smallest* number that still covers the user's request well — a short factual question should be 0-1 section, a meaty analysis 3-5.
[PLAIN TEXT]
- NEVER emit "#", "##", "###", "__", "> " markers. Section labels are plain text on their own line.
- Bullets: "- " only. No "* " / "• ".
- No tables. No HTML.
- Inline code with backticks is OK (e.g. \`src/agent.ts\`). Triple-backtick code blocks only for actual code.
Output STRICTLY a JSON array of objects: \`[{"heading": "...", "scope": "..."}]\`. No prose, no fences, no leading text.
- 🟢 **빈 배열 \`[]\`** = "쪼갤 필요 없음". 사용자 질문이 간단해서 단일 LLM 호출로 즉답이 더 빠르고 자연스러울 때 (예: 단순 사실 질문, 짧은 코드 한 줄, 정의 묻기). 시스템이 이걸 받으면 outline·section 단계 건너뛰고 1회 직답으로 처리한다.
- heading: a short Korean section label (≤ 24 chars). For 1-section answers, set heading to "본문".
- scope: one Korean sentence describing exactly what facts/points belong inside this section so adjacent sections don't overlap.
[CONTENT]
- Preserve every factual claim from the draft. Do NOT invent new facts, do NOT add hidden reasoning, do NOT write meta-commentary.
- DO NOT EMIT: <think>, <analysis>, <|channel|> markers, "Thinking Process:", planning notes, or any hidden reasoning.
- If a [REFLECTION CRITIQUE] is provided, verify each item is addressed. If something is missing, say so explicitly rather than fabricating coverage.
- LANGUAGE: KOREAN. Tone: direct, technical, developer-friendly.`;
판단 기준:
- 답변이 한 단락 (대략 3~5문장) 이내로 완결 가능 → \`[]\`
- 본문 분석·여러 측면 비교·구조화된 보고서가 필요 → N개 섹션
async execute(input: string, originalRequest?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
const draft = input.length > 12000 ? input.substring(0, 12000) + '... [Draft Trimmed]' : input;
const reflection = options?.priorResults?.reflection;
const reflectionBlock = reflection && reflection.trim().length > 0
? `\n4. [REFLECTION CRITIQUE — verify the draft addresses each item]:\n${reflection.length > 3000 ? reflection.substring(0, 3000) + '... [Critique Trimmed]' : reflection}`
If the user attached source content (article/code/log) the sections must cover *that content*, not analysis methodology.`;
private readonly sectionPersona = `You are writing ONE section of a longer Korean answer. You will be given:
- the user's original request (possibly with attached content),
- this section's heading + scope,
- the full outline (for context only — DO NOT write other sections),
- already-written previous sections (so you can avoid repeating them).
Rules:
- Stay strictly inside this section's scope. Do NOT cover other outline entries.
- Korean, plain markdown (no top-level "#" — the heading will be added by the joiner).
- Pack facts. Avoid filler / executive summaries / closing remarks (the polish pass adds those).
- If the user attached source content, cite from it; do not invent facts.
- Do NOT output the heading itself — only the body of this section.`;
private readonly polishPersona = `You are the final editor producing the user-facing Korean answer from a sectioned draft.
Job:
1. Fix typos, broken markdown, inconsistent terminology.
2. Remove unsupported claims / hallucinations: if a sentence asserts a fact that isn't grounded in the user's request (or the earlier sections themselves), delete it. Better to be short than wrong.
3. Smooth section transitions and remove duplicated information across sections.
4. Open with the conclusion / key takeaway in the first sentence (no "분석해보겠습니다", no preamble).
5. Preserve every factually grounded claim from the draft. Don't invent new facts.
Output rules:
- Korean. Plain markdown. Section labels as plain text on their own line — no "#", "##".
- Bullets with "- " only. No tables, no HTML, no triple-bar separators.
- If the draft already has a sensible structure, keep it; only refactor when sections clearly overlap or contradict.
- DO NOT emit hidden reasoning (<think>, "Thinking:", etc.).`;
/**
* Single-pass 직답 persona. 짧은 질문·정의 묻기·간단한 사실 확인처럼
* 쪼갤 필요 없는 입력을 1회 호출로 끝낸다. outline → section → polish 의
* 3회 LLM 호출을 통째로 우회 → 작은 모델로 즉답 가능.
*/
private readonly directPersona = `You are answering a Korean user request in one shot. No outline, no drafting — just the final answer.
Rules:
- 첫 문장이 결론 / 직답이다. "분석해보겠습니다" "좋은 질문입니다" 같은 서문 금지.
- Korean. Plain markdown — "#", "##" 같은 헤더 금지, "- " bullet 만 허용. No tables, no HTML.
- 짧은 질문엔 짧은 답. 한 문장으로 충분하면 한 문장.
- 사용자가 본문(코드·기사·로그)을 첨부했으면 그 본문에서 인용. 본문에 없는 사실 지어내지 말 것.
- 추론 과정·"Thinking:"·<think> 노출 금지.`;
async execute(input: string, context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
const role = (options?.config?.role as string | undefined) || 'section';
switch (role) {
case 'outline':
return this.callLLM(this.outlinePersona, this.buildOutlinePrompt(input, context), signal);
case 'polish':
return this.callLLM(this.polishPersona, this.buildPolishPrompt(input, options), signal);
case 'direct':
return this.callLLM(this.directPersona, this.buildDirectPrompt(input, context), signal);
case 'section':
default:
return this.callLLM(this.sectionPersona, this.buildSectionPrompt(input, context, options), signal);
}
}
private buildOutlinePrompt(userRequest: string, brainContext?: string): string {
const ctx = brainContext && brainContext.trim().length > 0
? `\n\n[보조 지식 컨텍스트 — 답변에 직접 인용하기보단 분할 결정에만 참고]\n${brainContext.substring(0, 1200)}`
: '';
return `[사용자 요청 — 본문이 포함돼 있다면 그게 1차 자료입니다]\n${userRequest}${ctx}\n\n위 요청에 대한 답변을 ${ChunkedWriter.MAX_SECTIONS}개 이내의 섹션으로 어떻게 나눌지 JSON 배열로만 출력하세요.`;
}
const wrappedInput = `### SYSTEM INSTRUCTION: FINAL SYNTHESIS
1. User's Original Request: ${originalRequest || '(unavailable)'}
2. Structured Draft (from Drafter — your input to polish):
${draft}
3. Mission: Produce the FINAL user-facing answer in KOREAN. Do not restart from scratch — polish, smooth, and conclude.${reflectionBlock}`;
return this.callLLM(this.persona, wrappedInput, signal);
}
}
export class ReflectorAgent extends BaseAgent {
private readonly persona = `You are the [Internal Critic & Self-Reflection Officer].
Your sole role is META-COGNITION: stress-test the plan and the research output BEFORE the Writer commits to a final report.
- POSTURE: Skeptical, rigorous, blunt. You are looking for what is WRONG, not what is right.
- DO NOT: rewrite the report, add new content, or speculate beyond the evidence provided.
- DO: surface gaps, unsupported claims, contradictions, drift from the original objective, and missing perspectives.
- OUTPUT STRICTLY in this Markdown shape (Korean):
## 🧭 Alignment with Objective
- <원래 요청 대비 일치/이탈 평가>
## 🕳 Gaps & Missing Evidence
- <plan에는 있지만 research가 다루지 않은 항목>
## ⚖️ Contradictions / Conflicts
- <research 내부 또는 brain context와의 모순; 없으면 "발견되지 않음">
## 🚨 Unsupported / Weak Claims
- <근거가 빈약하거나 일반화된 진술>
## ✅ Guidance for Writer
- <Writer가 최종 리포트에서 반드시 보정해야 할 3~5개 구체 지시>
- CONSTRAINT: 최대 500단어. 새 지식을 만들지 말고, 제공된 자료에서만 판단할 것.`;
async execute(input: string, _context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
const plan = options?.priorResults?.plan || '(plan unavailable)';
const research = input;
const originalPrompt = options?.priorResults?.originalPrompt || '(original prompt unavailable)';
const brainContext = options?.context || '';
// Reflector 는 중간 단계이므로 비대한 입력을 방지하기 위해 각 섹션을 cap.
const cap = (s: string, n: number) => s.length > n ? s.substring(0, n) + '... [trimmed]' : s;
const wrappedInput = `### SYSTEM INSTRUCTION: SELF-REFLECTION PASS
1. Original User Objective:
${cap(originalPrompt, 1500)}
2. Planner Blueprint:
${cap(plan, 3000)}
3. Researcher Output (to be critiqued):
${cap(research, 5000)}
4. Knowledge / Brain Context (for cross-check only — do not invent beyond this):
${cap(brainContext, 2000)}
5. Mission: Run a single rigorous reflection pass and output the structured critique exactly as specified by your persona.`;
return this.callLLM(this.persona, wrappedInput, signal);
private buildSectionPrompt(input: string, brainContext?: string, options?: AgentExecuteOptions): string {
const prior = options?.priorResults ?? {};
const heading = prior.sectionHeading ?? '본문';
const scope = prior.sectionScope ?? '사용자 요청 전체';
const outlineJoined = prior.outlineSummary ?? '';
const prev = prior.prevSectionsTrimmed ?? '';
const originalPrompt = prior.originalPrompt ?? input;
const ctx = brainContext && brainContext.trim().length > 0
? `\n\n[보조 지식 컨텍스트]\n${brainContext.substring(0, 2000)}`
: '';
return `[사용자 원본 요청]\n${originalPrompt}\n\n[이 섹션 정보]\nheading: ${heading}\nscope: ${scope}\n\n[전체 outline — 다른 섹션은 다루지 마세요]\n${outlineJoined}\n\n[이미 작성된 섹션들 — 중복 금지]\n${prev || '(없음 — 첫 섹션)'}${ctx}\n\n위 scope만 다루는 섹션 본문을 작성하세요. heading 줄은 출력하지 말고 본문만.`;
}
private buildPolishPrompt(draft: string, options?: AgentExecuteOptions): string {
const prior = options?.priorResults ?? {};
const originalPrompt = prior.originalPrompt ?? '(원본 요청 없음)';
return `[사용자 원본 요청]\n${originalPrompt}\n\n[섹션별 초안 — 이것을 다듬어 최종 답변으로]\n${draft}\n\n위 초안을 사용자에게 보낼 최종본으로 다듬으세요. 새 사실 추가 금지, 근거 없는 주장은 제거.`;
}
private buildDirectPrompt(userRequest: string, brainContext?: string): string {
const ctx = brainContext && brainContext.trim().length > 0
? `\n\n[보조 지식 컨텍스트 — 필요할 때만 인용]\n${brainContext.substring(0, 2000)}`
: '';
return `[사용자 요청]\n${userRequest}${ctx}\n\n위 요청에 대한 최종 답변을 1회로 끝내세요.`;
}
}