chore: version up to 2.80.35 and package with experience memory
This commit is contained in:
+66
-15
@@ -39,6 +39,7 @@ import {
|
||||
} from './features/secondBrainTrace';
|
||||
import { MemoryManager } from './memory';
|
||||
import { RetrievalOrchestrator } from './retrieval';
|
||||
import { buildLessonChecklistBlock, isQaRegressionFeedback, findUnaddressedChecklistItems } from './retrieval/lessonHelpers';
|
||||
import { resolveScopeForAgent } from './skills/agentKnowledgeMap';
|
||||
import {
|
||||
estimateTokens,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
truncateSystemPromptContext,
|
||||
classifyStopReason,
|
||||
truncationNotice,
|
||||
shouldShowTruncationNotice,
|
||||
estimateModelParamsB,
|
||||
type ContextLimits,
|
||||
} from './lib/contextManager';
|
||||
@@ -136,9 +138,12 @@ export class AgentExecutor {
|
||||
configuredFolders: string[]; // relative to brain root
|
||||
usedBrainFiles: string[]; // relative to brain root
|
||||
usedMemoryLayers: string[]; // raw RetrievalSource ids
|
||||
lessonFiles: string[]; // relative to brain root — lesson/playbook/qa-finding cards injected this turn
|
||||
totalChunks: number;
|
||||
selectedChunks: number;
|
||||
} | null = null;
|
||||
/** Lesson card *contents* injected this turn — kept to check the answer against their Prevention Checklists. */
|
||||
private _lastLessonContents: string[] = [];
|
||||
|
||||
private readonly options: AgentExecutorOptions;
|
||||
|
||||
@@ -264,6 +269,8 @@ export class AgentExecutor {
|
||||
agentEvents.emit(AgentEventTypes.TRANSACTION_ROLLED_BACK);
|
||||
this.statusBarManager.updateStatus(AgentStatus.Idle, 'Changes rolled back.');
|
||||
this.webview?.postMessage({ type: 'streamChunk', value: '\n❌ **작업이 거부되어 모든 변경사항이 취소되었습니다.**' });
|
||||
// The user judged this change wrong — a good moment to capture why, so it doesn't recur.
|
||||
this.webview?.postMessage({ type: 'lessonCandidate', value: { trigger: 'rejected' } });
|
||||
}
|
||||
|
||||
public async handlePrompt(
|
||||
@@ -306,6 +313,7 @@ export class AgentExecutor {
|
||||
}
|
||||
|
||||
const hasVisionContent = Array.isArray(visionContent) ? visionContent.length > 0 : !!visionContent;
|
||||
const isCasualConversation = prompt ? this.isCasualConversationPrompt(prompt) : false;
|
||||
let requestTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
if (!this.webview) return;
|
||||
@@ -339,7 +347,7 @@ export class AgentExecutor {
|
||||
: getActiveBrainProfile();
|
||||
const brainFiles = findBrainFiles(activeBrain.localBrainPath);
|
||||
let secondBrainTrace: SecondBrainTrace | null = null;
|
||||
if (options.secondBrainTraceEnabled && prompt && loopDepth === 0) {
|
||||
if (options.secondBrainTraceEnabled && prompt && loopDepth === 0 && !isCasualConversation) {
|
||||
secondBrainTrace = buildSecondBrainTrace(prompt, activeBrain.localBrainPath, {
|
||||
force: this.isExplicitSecondBrainRequest(prompt),
|
||||
limit: Math.max(config.memoryLongTermFiles, 5)
|
||||
@@ -358,7 +366,7 @@ export class AgentExecutor {
|
||||
activeBrain.description ? `Description: ${activeBrain.description}` : '',
|
||||
brainPreview ? `Available file examples:\n${brainPreview}` : 'Files: none found'
|
||||
].filter(Boolean).join('\n');
|
||||
const brainInventoryCtx = prompt && this.isSecondBrainInventoryRequest(prompt)
|
||||
const brainInventoryCtx = prompt && !isCasualConversation && this.isSecondBrainInventoryRequest(prompt)
|
||||
? `\n\n${this.buildSecondBrainInventoryContext(activeBrain, brainFiles)}`
|
||||
: '';
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
@@ -375,19 +383,19 @@ export class AgentExecutor {
|
||||
if (localPathContext) {
|
||||
contextBlock += `\n\n${localPathContext}`;
|
||||
}
|
||||
const recentProjectKnowledgeContext = prompt && loopDepth === 0 && !localPathContext
|
||||
const recentProjectKnowledgeContext = prompt && loopDepth === 0 && !isCasualConversation && !localPathContext
|
||||
? this.buildRecentProjectKnowledgeContext(prompt, rootPath)
|
||||
: '';
|
||||
if (recentProjectKnowledgeContext) {
|
||||
contextBlock += `\n\n${recentProjectKnowledgeContext}`;
|
||||
}
|
||||
const projectBriefContext = prompt && loopDepth === 0
|
||||
const projectBriefContext = prompt && loopDepth === 0 && !isCasualConversation
|
||||
? this.buildJarvisProjectBriefContext(prompt, localPathContext, recentProjectKnowledgeContext)
|
||||
: '';
|
||||
if (projectBriefContext) {
|
||||
contextBlock += `\n\n${projectBriefContext}`;
|
||||
}
|
||||
const modeArchitectureContext = prompt && loopDepth === 0
|
||||
const modeArchitectureContext = prompt && loopDepth === 0 && !isCasualConversation
|
||||
? this.buildAstraModeArchitectureContext(prompt)
|
||||
: '';
|
||||
if (modeArchitectureContext) {
|
||||
@@ -448,7 +456,12 @@ export class AgentExecutor {
|
||||
const secondBrainTraceCtx = secondBrainTrace
|
||||
? `\n\n${renderSecondBrainTraceContext(secondBrainTrace)}`
|
||||
: '';
|
||||
const memoryCtx = this.buildMemoryContext(prompt || '', activeBrain, options.agentSkillFile);
|
||||
const memoryCtx = isCasualConversation
|
||||
? ''
|
||||
: this.buildMemoryContext(prompt || '', activeBrain, options.agentSkillFile);
|
||||
const knowledgeContextForPrompt = isCasualConversation
|
||||
? ''
|
||||
: `${brainContext}${brainInventoryCtx}`;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// [Agent Mode v3] 에이전트가 선택된 경우, Astra 기본 포맷/페르소나 섹션을
|
||||
@@ -478,16 +491,16 @@ export class AgentExecutor {
|
||||
|
||||
// 3. 조립: 기본(축소) → 유틸리티 컨텍스트 → 에이전트 프롬프트(최후단)
|
||||
// [CONTEXT] … [/CONTEXT] 사이만 컨텍스트 초과 시 trim 대상 — agentDirective/negative 는 보호.
|
||||
fullSystemPrompt = `${strippedSystemPrompt}${internetCtx}${memoryCtx}${designerCtx}${secondBrainTraceCtx}\n\n[CONTEXT]\n${brainContext}${brainInventoryCtx}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}${agentDirective}`;
|
||||
fullSystemPrompt = `${strippedSystemPrompt}${internetCtx}${memoryCtx}${designerCtx}${secondBrainTraceCtx}\n\n[CONTEXT]\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}${agentDirective}`;
|
||||
} else {
|
||||
// 기존 Astra 모드 (에이전트 미선택)
|
||||
const localProjectKnowledgeCtx = prompt && localPathContext && this.isProjectKnowledgeCreationRequest(prompt)
|
||||
? `\n\n[LOCAL PROJECT KNOWLEDGE CREATION OVERRIDE]\nThe user gave an accessible local project path and asked to create project knowledge. Do not ask blocking scope questions. Use a sensible default MVP: create or propose a project overview note from the inspected tree and priority file previews. If writing is not explicitly safe, provide the concrete note draft and target path.`
|
||||
: '';
|
||||
const thinkingPartnerCtx = prompt && this.isThinkingPartnerRequest(prompt)
|
||||
const thinkingPartnerCtx = prompt && !isCasualConversation && this.isThinkingPartnerRequest(prompt)
|
||||
? `\n\n[JARVIS THINKING PARTNER MODE]\nThe user is using this tool to clarify project direction, not just to receive generic advice. Give a clear opinionated verdict first. Then separate confirmed facts, inferences, concerns, decision forks, and the next small action. Do not merely say the direction is good. If evidence is thin, say exactly what is missing and what file or record should be checked next.`
|
||||
: '';
|
||||
const astraStanceCtx = prompt
|
||||
const astraStanceCtx = prompt && !isCasualConversation
|
||||
? `\n\n${this.buildAstraStanceContext(prompt, localPathContext)}`
|
||||
: '';
|
||||
const v4PolicyCtx = [
|
||||
@@ -498,7 +511,10 @@ export class AgentExecutor {
|
||||
].join('\n');
|
||||
|
||||
// [CONTEXT] … [/CONTEXT] 사이만 컨텍스트 초과 시 trim 대상 — negative constraints 는 보호.
|
||||
fullSystemPrompt = `${systemPrompt}${internetCtx}${memoryCtx}${designerCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}\n\n[CONTEXT]\n${brainContext}${brainInventoryCtx}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
|
||||
const casualCtx = isCasualConversation
|
||||
? '\n\n[CASUAL CONVERSATION MODE]\nThe user sent a greeting, acknowledgement, or light conversational message. Reply naturally and briefly to the message itself. Do not use Second Brain, memory, project records, reports, references, or analysis unless the user explicitly asks for them.'
|
||||
: '';
|
||||
fullSystemPrompt = `${systemPrompt}${internetCtx}${memoryCtx}${designerCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${casualCtx}\n\n[CONTEXT]\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
|
||||
}
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// [Context Limit Manager] context length 는 "답변을 그만큼 길게 써도 된다"
|
||||
@@ -624,6 +640,11 @@ export class AgentExecutor {
|
||||
smallModel: cappedForSmallModel || (modelParamB !== null && modelParamB <= 3 && inputTokens > 8000),
|
||||
},
|
||||
});
|
||||
// If the user's message reads like a regression complaint ("또 안 돼", "비슷한 실수", "왜 반복돼"…),
|
||||
// offer to record a lesson — a recurring problem is exactly what Experience Memory is for.
|
||||
if (prompt && isQaRegressionFeedback(prompt)) {
|
||||
this.webview.postMessage({ type: 'lessonCandidate', value: { trigger: 'qa-feedback' } });
|
||||
}
|
||||
this.webview.postMessage({ type: 'streamStart' });
|
||||
this.options.onStreamLifecycle?.start();
|
||||
}
|
||||
@@ -858,7 +879,10 @@ export class AgentExecutor {
|
||||
inputTokens, maxOutputTokens, answerChars: assistantContent.length,
|
||||
});
|
||||
}
|
||||
const notice = truncationNotice(stopKind);
|
||||
const outputTokens = estimateTokens(assistantContent);
|
||||
const notice = shouldShowTruncationNotice(stopKind, outputTokens, maxOutputTokens)
|
||||
? truncationNotice(stopKind)
|
||||
: '';
|
||||
if (notice && assistantContent.trim()) {
|
||||
assistantContent = assistantContent.trimEnd() + notice;
|
||||
}
|
||||
@@ -945,9 +969,11 @@ export class AgentExecutor {
|
||||
|
||||
this.statusBarManager.updateStatus(AgentStatus.Success);
|
||||
if (this._lastRetrievalInfo) {
|
||||
// Non-blocking flag: lesson Prevention-Checklist items the answer doesn't visibly touch on.
|
||||
const unaddressedChecklist = findUnaddressedChecklistItems(finalAssistantContent, this._lastLessonContents);
|
||||
this.webview.postMessage({
|
||||
type: 'usedScope',
|
||||
value: { ...this._lastRetrievalInfo, hasAgentSelected: !!options.agentSkillFile },
|
||||
value: { ...this._lastRetrievalInfo, hasAgentSelected: !!options.agentSkillFile, unaddressedChecklist },
|
||||
});
|
||||
}
|
||||
this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent });
|
||||
@@ -1479,6 +1505,21 @@ export class AgentExecutor {
|
||||
return /(어떤\s*거?\s*같|어때|어떻게\s*생각|의견|판단|방향|설계|아키텍처|구조|자비스|생각.*정리|갈림길|architecture|design|direction|opinion|think|judge)/i.test(prompt);
|
||||
}
|
||||
|
||||
private isCasualConversationPrompt(prompt: string): boolean {
|
||||
const normalized = (prompt || '')
|
||||
.trim()
|
||||
.replace(/[~!?.。!?\s]+$/g, '')
|
||||
.toLowerCase();
|
||||
if (!normalized) return false;
|
||||
if (normalized.length > 40) return false;
|
||||
|
||||
// Greetings, acknowledgements, and light conversational nudges should
|
||||
// not trigger Second Brain/RAG. Otherwise a single "안녕" can retrieve
|
||||
// old project records and the model answers that stale context instead
|
||||
// of the user's actual greeting.
|
||||
return /^(안녕|안녕하세요|하이|헬로|hello|hi|hey|yo|ㅎㅇ|좋아|오케이|ok|okay|ㅇㅋ|고마워|감사|thanks|thank you|넵|네|응|음|흠|그래)$/.test(normalized);
|
||||
}
|
||||
|
||||
private isAstraModeArchitectureQuestion(prompt: string): boolean {
|
||||
const mentionsGuard = /\bguard\b|가드|Guard|Chronicle Guard|Project Chronicle/i.test(prompt);
|
||||
const mentionsMultiAgent = /\bMA\b|multi[-\s]?agent|멀티\s*에이전트|다중\s*에이전트|Planner|Researcher|Writer/i.test(prompt);
|
||||
@@ -2126,6 +2167,7 @@ export class AgentExecutor {
|
||||
private buildMemoryContext(currentPrompt: string, activeBrain: BrainProfile, agentSkillFile?: string): string {
|
||||
const config = getConfig();
|
||||
this._lastRetrievalInfo = null;
|
||||
this._lastLessonContents = [];
|
||||
if (!config.memoryEnabled) return '';
|
||||
|
||||
// Update memory manager config in case settings changed
|
||||
@@ -2161,6 +2203,7 @@ export class AgentExecutor {
|
||||
// Stash what actually fed this turn so handlePrompt can show it under the answer.
|
||||
const brainRoot = activeBrain.localBrainPath;
|
||||
const rel = (p?: string) => (p ? (path.relative(brainRoot, p) || p) : '');
|
||||
const lessonChunks = result.lessonChunks || [];
|
||||
this._lastRetrievalInfo = {
|
||||
agentName: scope.agent?.name ?? null,
|
||||
scoped: scope.folders.length > 0,
|
||||
@@ -2175,11 +2218,17 @@ export class AgentExecutor {
|
||||
.filter((c) => c.source !== 'brain-memory' && c.source !== 'brain-trace')
|
||||
.map((c) => c.source as string)
|
||||
)),
|
||||
lessonFiles: lessonChunks.map((c) => rel(c.metadata.filePath)).filter((p, i, arr) => p && arr.indexOf(p) === i),
|
||||
totalChunks: result.totalChunks,
|
||||
selectedChunks: result.selectedChunks.length,
|
||||
};
|
||||
|
||||
return this.retrievalOrchestrator.buildContextString(result);
|
||||
this._lastLessonContents = lessonChunks.map((c) => c.content);
|
||||
// Lessons go ahead of the regular RAG context (and ahead of [CONTEXT] in the system prompt),
|
||||
// so they're prominent and survive context-overflow truncation.
|
||||
const lessonBlock = buildLessonChecklistBlock(lessonChunks.map((c) => ({ title: c.title, content: c.content })));
|
||||
const memoryBlock = this.retrievalOrchestrator.buildContextString(result);
|
||||
return [lessonBlock, memoryBlock].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
private emitHistoryChanged() {
|
||||
@@ -2662,7 +2711,9 @@ export class AgentExecutor {
|
||||
const g1Error = error instanceof AgentExecutionError ? error : new AgentExecutionError(error.message, error);
|
||||
report.push(`🛑 Transaction Failed: ${g1Error.message}. All file changes rolled back.`);
|
||||
logError('Action execution failed, rolled back.', g1Error);
|
||||
// We return the report with the failure message instead of throwing
|
||||
// A failed-and-rolled-back action is a strong "something went wrong" signal — offer to record a lesson.
|
||||
this.webview?.postMessage({ type: 'lessonCandidate', value: { trigger: 'rollback', reason: g1Error.message } });
|
||||
// We return the report with the failure message instead of throwing
|
||||
// so the agent can see the failure and decide what to do next
|
||||
}
|
||||
return report;
|
||||
@@ -2678,4 +2729,4 @@ export class AgentExecutor {
|
||||
logError('Second Brain sync failed.', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ import { TelegramBot } from './integrations/telegram/telegramBot';
|
||||
import { AIService } from './core/services';
|
||||
import { SettingsPanelProvider } from './features/settings/settingsPanelProvider';
|
||||
import { resolveScopeForAgent, openKnowledgeMapEditor } from './skills/agentKnowledgeMap';
|
||||
import { getBrainTokenIndex } from './retrieval';
|
||||
import { lessonTemplate, lessonSlug, parseLessonFrontmatter, normalizeLessonTitle, bumpLessonOccurrences } from './retrieval/lessonHelpers';
|
||||
import { retrieveScoped, buildContextBlock } from './skills/scopedBrainRetriever';
|
||||
|
||||
let _lifecycleManager: ModelLifecycleManager | undefined;
|
||||
@@ -404,8 +406,151 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
vscode.commands.registerCommand('g1nation.skills.editKnowledgeMap', async () => {
|
||||
await openKnowledgeMapEditor();
|
||||
}),
|
||||
// Experience Memory — create / browse lesson cards in the active brain.
|
||||
vscode.commands.registerCommand('g1nation.lesson.create', () => createLessonCard()),
|
||||
vscode.commands.registerCommand('g1nation.lesson.fromConversation', () => {
|
||||
// Pre-fill the Situation section from the most recent user request + assistant reply.
|
||||
const history = agent.getHistory().filter((m: any) => !m.internal);
|
||||
const lastUser = [...history].reverse().find((m: any) => m.role === 'user');
|
||||
const lastAssistant = [...history].reverse().find((m: any) => m.role === 'assistant');
|
||||
if (!lastUser && !lastAssistant) {
|
||||
vscode.window.showInformationMessage('현재 대화 내용이 없습니다. 먼저 대화를 한 뒤 사용하세요. (빈 교훈을 만들려면 "Astra: New Lesson" 사용)');
|
||||
return;
|
||||
}
|
||||
const clip = (s: any, n: number) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n) + '…' : t; };
|
||||
const situation = [
|
||||
lastUser ? `요청: ${clip(lastUser.content, 600)}` : '',
|
||||
lastAssistant ? `Astra 답변(요약): ${clip(lastAssistant.content, 800)}` : '',
|
||||
'',
|
||||
'<위 작업에서 무엇이 잘못됐거나 위험했는지를 아래 Mistake/Root Cause 에 적으세요>',
|
||||
].filter(Boolean).join('\n');
|
||||
return createLessonCard(situation);
|
||||
}),
|
||||
vscode.commands.registerCommand('g1nation.lesson.manage', () => manageLessons()),
|
||||
);
|
||||
|
||||
/** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind
|
||||
* filter (cheap when warm), then reads only those few files for their frontmatter title/occurrences. */
|
||||
function listLessonFiles(brainDir: string): Array<{ filePath: string; rel: string; title: string; kind: string; occurrences: number }> {
|
||||
const out: Array<{ filePath: string; rel: string; title: string; kind: string; occurrences: number }> = [];
|
||||
let files: string[] = [];
|
||||
try { files = findBrainFiles(brainDir); } catch { return out; }
|
||||
for (const d of getBrainTokenIndex(brainDir, files)) {
|
||||
if (!d.kind) continue;
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(d.filePath, 'utf8').slice(0, 4000); } catch { continue; }
|
||||
const fm = parseLessonFrontmatter(content);
|
||||
out.push({ filePath: d.filePath, rel: d.relativePath, title: (fm.title || d.title).trim(), kind: d.kind, occurrences: fm.occurrences ?? 1 });
|
||||
}
|
||||
return out.sort((a, b) => a.rel.localeCompare(b.rel));
|
||||
}
|
||||
|
||||
/** Shared lesson-card creator used by the lesson commands. Dedup-merges into an existing lesson with the same title. */
|
||||
async function createLessonCard(situation?: string): Promise<void> {
|
||||
const brain = getActiveBrainProfile();
|
||||
const brainDir = brain?.localBrainPath;
|
||||
if (!brainDir || !path.isAbsolute(brainDir)) {
|
||||
vscode.window.showErrorMessage('활성 Second Brain 경로가 설정되지 않았습니다. Settings에서 localBrainPath / brainProfiles를 먼저 설정하세요.');
|
||||
return;
|
||||
}
|
||||
const title = (await vscode.window.showInputBox({
|
||||
title: 'New Lesson — Experience Memory',
|
||||
prompt: '이 교훈의 제목 (예: "Telegram 원격 실행은 allowlist 필수")',
|
||||
placeHolder: '한 줄 요약 — 다음에 같은 실수를 안 하려면 뭘 기억해야 하나',
|
||||
ignoreFocusOut: true,
|
||||
}))?.trim();
|
||||
if (!title) return;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Dedup-merge: a recurring mistake should get LOUDER (occurrences++), not spawn a duplicate card.
|
||||
const norm = normalizeLessonTitle(title);
|
||||
const existing = norm ? listLessonFiles(brainDir).find((l) => normalizeLessonTitle(l.title) === norm) : undefined;
|
||||
if (existing) {
|
||||
const pick = await vscode.window.showInformationMessage(
|
||||
`이미 같은 제목의 교훈이 있습니다: "${existing.title}" (occurrences: ${existing.occurrences}). 갱신할까요?`,
|
||||
{ modal: false },
|
||||
'갱신 (occurrences +1)', '새로 만들기',
|
||||
);
|
||||
if (!pick) return;
|
||||
if (pick === '갱신 (occurrences +1)') {
|
||||
try {
|
||||
const cur = fs.readFileSync(existing.filePath, 'utf8');
|
||||
fs.writeFileSync(existing.filePath, bumpLessonOccurrences(cur, today), 'utf8');
|
||||
} catch (e: any) {
|
||||
vscode.window.showErrorMessage(`교훈 갱신 실패: ${e?.message ?? e}`);
|
||||
return;
|
||||
}
|
||||
const doc = await vscode.workspace.openTextDocument(existing.filePath);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
vscode.window.showInformationMessage(`기존 교훈을 갱신했습니다 (occurrences: ${existing.occurrences + 1}). 필요하면 내용을 보강하세요.`);
|
||||
return;
|
||||
}
|
||||
// else fall through and create a new one
|
||||
}
|
||||
|
||||
const dir = path.join(brainDir, 'lessons');
|
||||
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* fall through to error below */ }
|
||||
let filePath = path.join(dir, `${today}-${lessonSlug(title)}.md`);
|
||||
let n = 2;
|
||||
while (fs.existsSync(filePath)) { filePath = path.join(dir, `${today}-${lessonSlug(title)}-${n++}.md`); }
|
||||
try {
|
||||
fs.writeFileSync(filePath, lessonTemplate(title, today, situation), 'utf8');
|
||||
} catch (e: any) {
|
||||
vscode.window.showErrorMessage(`교훈 파일 생성 실패: ${e?.message ?? e}`);
|
||||
return;
|
||||
}
|
||||
const doc = await vscode.workspace.openTextDocument(filePath);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
vscode.window.showInformationMessage('교훈 카드를 만들었습니다. 내용을 채워 저장하면 다음 유사 작업 시 자동으로 체크리스트에 들어갑니다.');
|
||||
}
|
||||
|
||||
/** Browse lesson cards: open one, or delete one (trash button). Also the "manage" surface for ignoring bad lessons. */
|
||||
async function manageLessons(): Promise<void> {
|
||||
const brain = getActiveBrainProfile();
|
||||
const brainDir = brain?.localBrainPath;
|
||||
if (!brainDir || !path.isAbsolute(brainDir)) {
|
||||
vscode.window.showErrorMessage('활성 Second Brain 경로가 설정되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
const lessons = listLessonFiles(brainDir);
|
||||
if (lessons.length === 0) {
|
||||
const make = await vscode.window.showInformationMessage('아직 교훈 카드가 없습니다.', '새 교훈 만들기');
|
||||
if (make) await createLessonCard();
|
||||
return;
|
||||
}
|
||||
const deleteBtn: vscode.QuickInputButton = { iconPath: new vscode.ThemeIcon('trash'), tooltip: '이 교훈 삭제' };
|
||||
const qp = vscode.window.createQuickPick<vscode.QuickPickItem & { _file: string }>();
|
||||
qp.title = 'Lessons — Experience Memory';
|
||||
qp.placeholder = '교훈을 선택하면 열립니다. 휴지통 아이콘으로 삭제. (삭제 = 더 이상 주입 안 됨)';
|
||||
qp.items = lessons.map((l) => ({
|
||||
label: `$(${l.kind === 'playbook' ? 'book' : l.kind === 'qa-finding' ? 'bug' : 'lightbulb'}) ${l.title}`,
|
||||
description: l.occurrences > 1 ? `×${l.occurrences}` : '',
|
||||
detail: l.rel,
|
||||
buttons: [deleteBtn],
|
||||
_file: l.filePath,
|
||||
}));
|
||||
qp.onDidTriggerItemButton(async (e) => {
|
||||
const file = e.item._file;
|
||||
const ok = await vscode.window.showWarningMessage(`교훈 "${e.item.label}" 을(를) 삭제할까요?`, { modal: true }, '삭제');
|
||||
if (ok === '삭제') {
|
||||
try { fs.unlinkSync(file); } catch (err: any) { vscode.window.showErrorMessage(`삭제 실패: ${err?.message ?? err}`); return; }
|
||||
qp.items = qp.items.filter((it) => it._file !== file);
|
||||
vscode.window.showInformationMessage('교훈을 삭제했습니다.');
|
||||
if (qp.items.length === 0) qp.hide();
|
||||
}
|
||||
});
|
||||
qp.onDidAccept(async () => {
|
||||
const sel = qp.selectedItems[0];
|
||||
qp.hide();
|
||||
if (sel) {
|
||||
const doc = await vscode.workspace.openTextDocument(sel._file);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
}
|
||||
});
|
||||
qp.onDidHide(() => qp.dispose());
|
||||
qp.show();
|
||||
}
|
||||
|
||||
// Astra Settings webview — single entry point for user-facing config (Phase 5-A: Telegram only).
|
||||
const settingsPanel = new SettingsPanelProvider({
|
||||
extensionUri: context.extensionUri,
|
||||
|
||||
@@ -252,3 +252,20 @@ export function truncationNotice(kind: GenerationStopKind): string {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some local engines report `maxPredictedTokensReached` even when the visible
|
||||
* answer is short (for example after an internal retry or SDK stats mismatch).
|
||||
* Only show the "answer was cut off" notice when the generated answer actually
|
||||
* consumed most of the output budget.
|
||||
*/
|
||||
export function shouldShowTruncationNotice(
|
||||
kind: GenerationStopKind,
|
||||
outputTokens: number,
|
||||
maxOutputTokens: number
|
||||
): boolean {
|
||||
if (kind === 'context-overflow' || kind === 'error') return true;
|
||||
if (kind !== 'output-limit') return false;
|
||||
const threshold = Math.max(128, Math.floor(maxOutputTokens * 0.85));
|
||||
return outputTokens >= threshold;
|
||||
}
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { tokenize, countConflictIndicators } from './scoring';
|
||||
import { detectLessonKind } from './lessonHelpers';
|
||||
import { logInfo } from '../utils';
|
||||
|
||||
const INDEX_VERSION = 2;
|
||||
const INDEX_VERSION = 3;
|
||||
const INDEX_DIR = '.astra';
|
||||
const INDEX_FILE = 'brain-index.json';
|
||||
/** 인덱스가 이 개수를 넘으면 이번 스캔에서 못 본 항목을 정리합니다 (삭제된 파일 누적 방지). */
|
||||
@@ -32,6 +33,7 @@ interface IndexEntry {
|
||||
tokens: string[]; // tokenize(`${title} ${content}`)
|
||||
titleTokens: string[]; // tokenize(title)
|
||||
conflictCount: number; // countConflictIndicators(`${title} ${content}`)
|
||||
kind: string; // '' for an ordinary note, else 'lesson' | 'playbook' | 'qa-finding'
|
||||
}
|
||||
|
||||
interface PersistedIndex {
|
||||
@@ -47,6 +49,8 @@ export interface IndexedBrainDoc {
|
||||
titleTokens: string[];
|
||||
conflictCount: number;
|
||||
mtimeMs: number;
|
||||
/** '' for an ordinary note; 'lesson' | 'playbook' | 'qa-finding' for an Experience-Memory card. */
|
||||
kind: string;
|
||||
}
|
||||
|
||||
interface BrainState {
|
||||
@@ -148,6 +152,7 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
|
||||
titleTokens: cached.titleTokens,
|
||||
conflictCount: cached.conflictCount || 0,
|
||||
mtimeMs: cached.mtimeMs,
|
||||
kind: cached.kind || '',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -169,6 +174,7 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
|
||||
tokens: tokenize(combined),
|
||||
titleTokens: tokenize(title),
|
||||
conflictCount: countConflictIndicators(combined),
|
||||
kind: detectLessonKind(relativePath, content),
|
||||
};
|
||||
st.index.entries[file] = entry;
|
||||
st.dirty = true;
|
||||
@@ -181,6 +187,7 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
|
||||
titleTokens: entry.titleTokens,
|
||||
conflictCount: entry.conflictCount,
|
||||
mtimeMs: entry.mtimeMs,
|
||||
kind: entry.kind,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+40
-7
@@ -96,13 +96,18 @@ export class RetrievalOrchestrator {
|
||||
allChunks,
|
||||
options.contextBudget
|
||||
);
|
||||
fusionLog.push(`Selected: ${selected.length}, Dropped: ${dropped.length}, Tokens: ${tokensUsed}`);
|
||||
// Pull lesson/playbook/qa-finding chunks out so callers can inject them as a prominent
|
||||
// "verify before finalizing" block rather than burying them in the brain-knowledge section.
|
||||
const lessonChunks = selected.filter((c) => c.metadata.isLesson);
|
||||
const selectedChunks = selected.filter((c) => !c.metadata.isLesson);
|
||||
fusionLog.push(`Selected: ${selectedChunks.length} (+${lessonChunks.length} lesson), Dropped: ${dropped.length}, Tokens: ${tokensUsed}`);
|
||||
|
||||
return {
|
||||
query,
|
||||
totalChunks: allChunks.length,
|
||||
selectedChunks: selected,
|
||||
selectedChunks,
|
||||
droppedChunks: dropped,
|
||||
lessonChunks,
|
||||
totalTokensUsed: tokensUsed,
|
||||
contextBudget: options.contextBudget?.totalBudget || 8000,
|
||||
fusionLog
|
||||
@@ -110,7 +115,7 @@ export class RetrievalOrchestrator {
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과를 최종 컨텍스트 문자열로 변환합니다.
|
||||
* 검색 결과를 최종 컨텍스트 문자열로 변환합니다 (레슨 청크는 제외 — 별도 블록으로 주입).
|
||||
*/
|
||||
public buildContextString(result: RetrievalResult): string {
|
||||
return assembleContext(result.selectedChunks);
|
||||
@@ -150,18 +155,42 @@ export class RetrievalOrchestrator {
|
||||
}))
|
||||
);
|
||||
|
||||
// Always consider lesson cards for the top slots even if they didn't crack the raw-score top-`limit`:
|
||||
// they're short, high-signal, and we want them surfaced when relevant. We keep the regular top-`limit`
|
||||
// and additively pull in up to a few lesson cards (deduped by index).
|
||||
const ranked = scored.filter((x) => x.score > 0).sort((a, b) => b.score - a.score);
|
||||
const pickedIdx = new Set<number>();
|
||||
for (const s of ranked.slice(0, limit)) pickedIdx.add(s.index);
|
||||
const LESSON_EXTRA = 3;
|
||||
let lessonExtra = 0;
|
||||
for (const s of ranked) {
|
||||
if (lessonExtra >= LESSON_EXTRA) break;
|
||||
if (pickedIdx.has(s.index)) continue;
|
||||
if ((indexed[s.index].kind || '') === '') continue;
|
||||
pickedIdx.add(s.index);
|
||||
lessonExtra++;
|
||||
}
|
||||
// Preserve rank order for the chosen set.
|
||||
const chosen = ranked.filter((s) => pickedIdx.has(s.index));
|
||||
|
||||
const topResults: RetrievalChunk[] = [];
|
||||
for (const s of scored.filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, limit)) {
|
||||
for (const s of chosen) {
|
||||
const doc = indexed[s.index];
|
||||
// Only the top `limit` files are actually read off disk (for excerpt extraction).
|
||||
const isLesson = (doc.kind || '') !== '';
|
||||
// Only the chosen files are actually read off disk (for excerpt extraction).
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(doc.filePath, 'utf8'); } catch { /* deleted just now — skip */ continue; }
|
||||
const excerpt = extractBestExcerpt(content, expandedTokens, 400);
|
||||
// Lesson cards: hand back the whole card (they're meant to be short) so the Prevention Checklist
|
||||
// survives; fall back to a generous excerpt for long ones. Regular notes: the usual 400-char excerpt.
|
||||
const excerpt = isLesson
|
||||
? (content.length <= 2500 ? content.trim() : extractBestExcerpt(content, expandedTokens, 1500))
|
||||
: extractBestExcerpt(content, expandedTokens, 400);
|
||||
const cap = isLesson ? 2500 : 400;
|
||||
topResults.push({
|
||||
id: `brain-${s.index}`,
|
||||
source: 'brain-memory' as const,
|
||||
title: doc.relativePath,
|
||||
content: summarizeText(excerpt, 400),
|
||||
content: summarizeText(excerpt, cap),
|
||||
score: s.score,
|
||||
tokenEstimate: estimateTokens(excerpt),
|
||||
metadata: {
|
||||
@@ -173,6 +202,7 @@ export class RetrievalOrchestrator {
|
||||
conflictDetected: s.conflictDetected,
|
||||
conflictSeverity: s.conflictSeverity,
|
||||
informationDensity: s.informationDensity,
|
||||
...(isLesson ? { isLesson: true, lessonKind: doc.kind } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -293,6 +323,9 @@ export class RetrievalOrchestrator {
|
||||
for (const chunk of chunks) {
|
||||
const boost = sourceBoost[chunk.source] || 0.5;
|
||||
chunk.score *= boost;
|
||||
// Lesson cards are short, high-signal guardrails — nudge relevant ones above ordinary brain notes
|
||||
// so they survive the budget. Modest (1.4×) so they don't crowd everything out when many match.
|
||||
if (chunk.metadata.isLesson) chunk.score *= 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Lesson / Experience Memory — pure helpers (no vscode dependency)
|
||||
*
|
||||
* "Lesson" = a markdown file in the active brain that captures a past mistake/risk and how to avoid
|
||||
* repeating it. Identified by a `lessons/` / `playbooks/` / `qa-findings/` path segment, or by
|
||||
* frontmatter `type: lesson|playbook|qa-finding`. These are retrieved like any other brain file but
|
||||
* boosted and injected as a prominent "verify before finalizing" checklist (see EXPERIENCE_MEMORY_PLAN.md).
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import { tokenize } from './scoring';
|
||||
|
||||
/** Path segments that mark a file as lesson-like. */
|
||||
export const LESSON_DIR_RE = /(^|[\\/])(lessons?|playbooks?|qa[-_]?findings?)([\\/]|$)/i;
|
||||
|
||||
export type LessonKind = 'lesson' | 'playbook' | 'qa-finding';
|
||||
|
||||
/**
|
||||
* Decide whether a brain file is a lesson (and which kind). Cheap — only looks at the relative path
|
||||
* and, if present, the YAML-ish frontmatter at the top of `content`.
|
||||
*
|
||||
* @returns the kind string, or '' for an ordinary note.
|
||||
*/
|
||||
export function detectLessonKind(relativePath: string, content: string): LessonKind | '' {
|
||||
// 1) Frontmatter `type:` wins if present.
|
||||
const fm = parseFrontmatterType(content);
|
||||
if (fm === 'lesson' || fm === 'playbook' || fm === 'qa-finding') return fm;
|
||||
// 2) Otherwise infer from the path.
|
||||
const m = LESSON_DIR_RE.exec(relativePath || '');
|
||||
if (!m) return '';
|
||||
const seg = m[2].toLowerCase();
|
||||
if (seg.startsWith('playbook')) return 'playbook';
|
||||
if (seg.startsWith('qa')) return 'qa-finding';
|
||||
return 'lesson';
|
||||
}
|
||||
|
||||
/** Pull the `type:` value out of a leading `--- ... ---` frontmatter block. Returns '' if absent. */
|
||||
function parseFrontmatterType(content: string): string {
|
||||
if (!content) return '';
|
||||
const head = content.slice(0, 800);
|
||||
if (!/^?---\s*\n/.test(head)) return '';
|
||||
const end = head.indexOf('\n---', 4);
|
||||
if (end < 0) return '';
|
||||
const block = head.slice(0, end);
|
||||
const m = block.match(/^\s*type\s*:\s*["']?([a-zA-Z-]+)["']?\s*$/m);
|
||||
return m ? m[1].trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
/** Extract the "## Prevention Checklist" bullet list from a lesson card, if present. */
|
||||
export function extractPreventionChecklist(content: string): string[] {
|
||||
if (!content) return [];
|
||||
const m = content.match(/^#{1,6}\s*(?:prevention\s*checklist|prevention|체크리스트|예방\s*체크리스트)\s*$/im);
|
||||
if (!m || m.index === undefined) return [];
|
||||
const after = content.slice(m.index + m[0].length);
|
||||
// Stop at the next heading.
|
||||
const stop = after.search(/\n#{1,6}\s/);
|
||||
const section = stop >= 0 ? after.slice(0, stop) : after;
|
||||
return section
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => /^[-*]\s+/.test(l))
|
||||
.map((l) => l.replace(/^[-*]\s+/, '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export interface LessonChunkLite {
|
||||
title: string; // relative path / display title
|
||||
content: string; // excerpt or full card text
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prompt block injected ahead of the regular RAG context. Kept compact; if a card has a
|
||||
* parseable Prevention Checklist we surface just that, otherwise the card text.
|
||||
*/
|
||||
export function buildLessonChecklistBlock(chunks: LessonChunkLite[]): string {
|
||||
if (!chunks || chunks.length === 0) return '';
|
||||
const sections: string[] = [];
|
||||
for (const c of chunks) {
|
||||
const checklist = extractPreventionChecklist(c.content);
|
||||
const body = checklist.length > 0
|
||||
? checklist.map((item) => `- [ ] ${item}`).join('\n')
|
||||
: c.content.trim();
|
||||
sections.push(`### ${c.title}\n${body}`);
|
||||
}
|
||||
return [
|
||||
'[⚠ ACTIVE LESSONS — verify these BEFORE finalizing your answer]',
|
||||
'These are recorded lessons from past work on this project. Read them first and make sure you are NOT',
|
||||
'about to repeat any of the mistakes / skip any of the precautions below. If a checklist item is relevant',
|
||||
'to the current request, explicitly confirm it in your answer. If a lesson conflicts with the user, prefer',
|
||||
'the user but flag the conflict.',
|
||||
'',
|
||||
sections.join('\n\n'),
|
||||
'',
|
||||
'[END ACTIVE LESSONS]',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* A starter lesson card written by the `g1nation.lesson.create` / `…fromConversation` commands for
|
||||
* the user to fill in. If `situation` is given (e.g. captured from the recent chat turn), it pre-fills
|
||||
* the Situation section.
|
||||
*/
|
||||
export function lessonTemplate(title: string, today: string, situation?: string): string {
|
||||
const safeTitle = (title || 'Untitled lesson').replace(/\n/g, ' ').trim();
|
||||
const situationBody = (situation && situation.trim()) ? situation.trim() : '<무슨 작업/맥락이었는지>';
|
||||
return [
|
||||
'---',
|
||||
'type: lesson',
|
||||
`title: ${safeTitle}`,
|
||||
'applies-to: []',
|
||||
'severity: medium',
|
||||
'source: curated',
|
||||
'occurrences: 1',
|
||||
`last-seen: ${today}`,
|
||||
'---',
|
||||
'',
|
||||
`# Lesson: ${safeTitle}`,
|
||||
'',
|
||||
'## Situation',
|
||||
situationBody,
|
||||
'',
|
||||
'## Mistake / Risk',
|
||||
'<무엇이 잘못됐거나 위험했는지>',
|
||||
'',
|
||||
'## Root Cause',
|
||||
'<왜 그렇게 됐는지 — 표면 증상이 아니라 근본 원인>',
|
||||
'',
|
||||
'## Fix',
|
||||
'<어떻게 고쳤는지>',
|
||||
'',
|
||||
'## Prevention Checklist',
|
||||
'- <다음에 비슷한 작업을 할 때 반드시 확인할 것>',
|
||||
'- ',
|
||||
'',
|
||||
'## Applies To',
|
||||
'- <태그: 기능/영역 이름>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Filesystem-safe slug for a lesson filename. */
|
||||
export function lessonSlug(title: string): string {
|
||||
const base = (title || 'lesson')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9가-힣]+/g, '-')
|
||||
.slice(0, 60)
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return base || 'lesson';
|
||||
}
|
||||
|
||||
// ── QA-feedback (regression complaint) detection ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Heuristic: does this user message look like "you broke something again / same mistake / why does
|
||||
* this keep happening"? If so, the host offers to record a lesson. Deliberately conservative — false
|
||||
* positives just show a dismissible prompt, but we'd rather not nag.
|
||||
*/
|
||||
const QA_REGRESSION_PATTERNS: RegExp[] = [
|
||||
/또\s*(안\s*돼|안되|이래|발생|터졌|깨졌|망가졌)/,
|
||||
/(다시|또)\s*같은\s*(실수|문제|버그|에러|오류)/,
|
||||
/(비슷한|똑같은)\s*(실수|문제|버그|이슈|패턴)/,
|
||||
/왜\s*(자꾸|계속|반복|또)/,
|
||||
/(고쳤는데|수정했는데|패치했는데|바꿨는데)\s*(또|다시|여전히|아직).{0,20}(안|깨|망|문제|에러|오류|실패|broke|broken)/i,
|
||||
/(여전히|아직도)\s*(안\s*돼|안되|버그|깨|문제|실패)/,
|
||||
/regress(ion|ed)?/i,
|
||||
/\b(broke|broken|failing|still\s+broken|same\s+(bug|mistake|issue|error)|again)\b.{0,40}\b(again|still|repeat|recurr)/i,
|
||||
/\bwhy\b.{0,30}\b(keep|again|repeatedly|recurr)/i,
|
||||
];
|
||||
export function isQaRegressionFeedback(prompt: string): boolean {
|
||||
if (!prompt) return false;
|
||||
const t = prompt.trim();
|
||||
if (t.length < 4 || t.length > 4000) return false;
|
||||
return QA_REGRESSION_PATTERNS.some((re) => re.test(t));
|
||||
}
|
||||
|
||||
// ── Lesson frontmatter parse / occurrences bump (for dedup-merge) ────────────
|
||||
|
||||
export interface LessonFrontmatter {
|
||||
type?: string;
|
||||
title?: string;
|
||||
occurrences?: number;
|
||||
appliesTo?: string[];
|
||||
}
|
||||
|
||||
/** Parse the leading `--- ... ---` block. Returns {} when there is no frontmatter. */
|
||||
export function parseLessonFrontmatter(content: string): LessonFrontmatter {
|
||||
if (!content) return {};
|
||||
const head = content.slice(0, 2000);
|
||||
if (!/^?---\s*\n/.test(head)) return {};
|
||||
const end = head.indexOf('\n---', 4);
|
||||
if (end < 0) return {};
|
||||
const block = head.slice(0, end);
|
||||
const get = (key: string) => {
|
||||
const m = block.match(new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, 'm'));
|
||||
return m ? m[1].replace(/^["']|["']$/g, '').trim() : undefined;
|
||||
};
|
||||
const occ = get('occurrences');
|
||||
const tags = get('applies-to');
|
||||
let appliesTo: string[] | undefined;
|
||||
if (tags) {
|
||||
const inner = tags.replace(/^\[|\]$/g, '').trim();
|
||||
appliesTo = inner ? inner.split(',').map((s) => s.trim().replace(/^["']|["']$/g, '').trim()).filter(Boolean) : [];
|
||||
}
|
||||
return {
|
||||
type: get('type')?.toLowerCase(),
|
||||
title: get('title'),
|
||||
occurrences: occ !== undefined && Number.isFinite(Number(occ)) ? Number(occ) : undefined,
|
||||
appliesTo,
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalize a lesson title for equality matching (lowercase, strip punctuation/whitespace). */
|
||||
export function normalizeLessonTitle(title: string): string {
|
||||
return (title || '').toLowerCase().replace(/[^a-z0-9가-힣]+/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `content` with the frontmatter's `occurrences:` incremented by 1 and `last-seen:` set to
|
||||
* `today`. If the keys are missing they're inserted just inside the frontmatter block. If there is
|
||||
* no frontmatter at all, `content` is returned unchanged (caller decides what to do).
|
||||
*/
|
||||
export function bumpLessonOccurrences(content: string, today: string): string {
|
||||
if (!/^?---\s*\n/.test(content)) return content;
|
||||
const end = content.indexOf('\n---', 4);
|
||||
if (end < 0) return content;
|
||||
let block = content.slice(0, end);
|
||||
const rest = content.slice(end);
|
||||
const cur = parseLessonFrontmatter(content).occurrences ?? 1;
|
||||
if (/^\s*occurrences\s*:/m.test(block)) {
|
||||
block = block.replace(/^(\s*occurrences\s*:\s*).*$/m, `$1${cur + 1}`);
|
||||
} else {
|
||||
block += `\noccurrences: ${cur + 1}`;
|
||||
}
|
||||
if (/^\s*last-seen\s*:/m.test(block)) {
|
||||
block = block.replace(/^(\s*last-seen\s*:\s*).*$/m, `$1${today}`);
|
||||
} else {
|
||||
block += `\nlast-seen: ${today}`;
|
||||
}
|
||||
return block + rest;
|
||||
}
|
||||
|
||||
// ── Post-answer checklist coverage (non-blocking flag) ──────────────────────
|
||||
|
||||
/** "Significant" words of a checklist item — drops placeholders, punctuation, very short tokens. */
|
||||
function checklistItemTerms(item: string): string[] {
|
||||
if (/^</.test(item.trim())) return []; // template placeholder like "<다음에 확인할 것>"
|
||||
return Array.from(new Set(tokenize(item))).filter((t) => t.length >= 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the assistant's answer and the lesson cards injected this turn, return Prevention-Checklist
|
||||
* items that the answer does not visibly address (zero of their significant terms appear). Conservative
|
||||
* by design — only flags items with at least 2 significant terms and a real, non-placeholder body.
|
||||
* Capped at `max` items so the footer doesn't get noisy.
|
||||
*/
|
||||
export function findUnaddressedChecklistItems(answer: string, lessonContents: string[], max = 3): string[] {
|
||||
if (!answer || !lessonContents || lessonContents.length === 0) return [];
|
||||
const answerTerms = new Set(tokenize(answer));
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const content of lessonContents) {
|
||||
for (const item of extractPreventionChecklist(content)) {
|
||||
const key = normalizeLessonTitle(item);
|
||||
if (!key || seen.has(key)) continue;
|
||||
const terms = checklistItemTerms(item);
|
||||
if (terms.length < 2) continue; // too vague to judge
|
||||
const covered = terms.some((t) => answerTerms.has(t));
|
||||
if (!covered) {
|
||||
out.push(item);
|
||||
seen.add(key);
|
||||
if (out.length >= max) return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -31,11 +31,17 @@ export interface RetrievalChunk {
|
||||
category?: string;
|
||||
isProjectEvidence?: boolean;
|
||||
lastUpdated?: number;
|
||||
|
||||
|
||||
// --- Scoring Intelligence (v2.75.0+) ---
|
||||
conflictDetected?: boolean;
|
||||
conflictSeverity?: ConflictSeverity;
|
||||
informationDensity?: number;
|
||||
|
||||
// --- Experience Memory ---
|
||||
/** True when this chunk comes from a lesson / playbook / qa-finding card in the brain. */
|
||||
isLesson?: boolean;
|
||||
/** 'lesson' | 'playbook' | 'qa-finding' when isLesson is true. */
|
||||
lessonKind?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +50,8 @@ export interface RetrievalResult {
|
||||
totalChunks: number;
|
||||
selectedChunks: RetrievalChunk[];
|
||||
droppedChunks: RetrievalChunk[];
|
||||
/** Lesson/playbook/qa-finding chunks that survived the budget — pulled out so callers can inject them prominently. */
|
||||
lessonChunks: RetrievalChunk[];
|
||||
totalTokensUsed: number;
|
||||
contextBudget: number;
|
||||
fusionLog: string[]; // 디버그용 융합 로그
|
||||
|
||||
@@ -37,6 +37,12 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
||||
case 'getReadyStatus':
|
||||
await provider._sendReadyStatus();
|
||||
return true;
|
||||
case 'createLessonFromConversation':
|
||||
await vscode.commands.executeCommand('g1nation.lesson.fromConversation');
|
||||
return true;
|
||||
case 'manageLessons':
|
||||
await vscode.commands.executeCommand('g1nation.lesson.manage');
|
||||
return true;
|
||||
case 'getModels':
|
||||
await provider._sendModels();
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user