chore: version up to 2.80.35 and package with experience memory

This commit is contained in:
g1nation
2026-05-12 23:23:23 +09:00
parent 065e598cca
commit f6b27a125b
25 changed files with 1088 additions and 103 deletions
@@ -0,0 +1,24 @@
{
"missionId": "integration_002",
"status": "planner",
"startTime": "2026-05-12T14:23:21.996Z",
"totalElapsedMs": 12,
"results": {},
"promptHash": "1439da2cb34b7c5b",
"transitionCount": 1,
"transitions": [
{
"from": "idle",
"to": "planner",
"durationMs": 12,
"message": "전략 수립 중...",
"ts": "2026-05-12T14:23:22.008Z"
}
],
"resilienceMetrics": {
"fallbacks": 0,
"retries": 0,
"maxConflictScore": 0,
"deduplications": 0
}
}
@@ -1,5 +0,0 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1778593954576,
"modelVersion": "unknown"
}
@@ -0,0 +1,5 @@
{
"result": "Plan OK passes validation and meets all length requirements.",
"createdAt": 1778595801894,
"modelVersion": "unknown"
}
@@ -1,5 +0,0 @@
{
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1778593954567,
"modelVersion": "unknown"
}
@@ -1,5 +0,0 @@
{
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
"createdAt": 1778593954561,
"modelVersion": "unknown"
}
@@ -1,5 +0,0 @@
{
"result": "---\nid: stress_conflict_1778593954545\ndate: 2026-05-12T13:52:34.580Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (11ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (5ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (10ms)\n",
"createdAt": 1778593954580,
"modelVersion": "unknown"
}
@@ -1,51 +0,0 @@
{
"missionId": "stress_conflict_1778593954545",
"status": "completed",
"startTime": "2026-05-12T13:52:34.545Z",
"totalElapsedMs": 35,
"results": {
"planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
"researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"writerPrep": "[Original Prompt] Conflict Test\n[Plan Summary] Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.\n[Brain Context Available] Yes (3 chars)",
"writer": "Final report with inconsistencies. This should be long enough to pass validation.",
"finalReport": "Final report with inconsistencies. This should be long enough to pass validation."
},
"promptHash": "f5146cb9f9670d2a",
"transitionCount": 4,
"transitions": [
{
"from": "idle",
"to": "planner",
"durationMs": 11,
"message": "전략 수립 중...",
"ts": "2026-05-12T13:52:34.556Z"
},
{
"from": "planner",
"to": "researcher",
"durationMs": 5,
"message": "핵심 정보 수집 및 분석 중...",
"ts": "2026-05-12T13:52:34.561Z"
},
{
"from": "researcher",
"to": "writer",
"durationMs": 10,
"message": "최종 리포트 작성 및 편집 중...",
"ts": "2026-05-12T13:52:34.571Z"
},
{
"from": "writer",
"to": "completed",
"durationMs": 9,
"message": "미션 완료",
"ts": "2026-05-12T13:52:34.580Z"
}
],
"resilienceMetrics": {
"fallbacks": 0,
"retries": 0,
"maxConflictScore": 60,
"deduplications": 0
}
}
@@ -0,0 +1,33 @@
{
"missionId": "stress_fallback_1778595801883",
"status": "researcher",
"startTime": "2026-05-12T14:23:21.883Z",
"totalElapsedMs": 12,
"results": {
"planner": "Plan OK passes validation and meets all length requirements."
},
"promptHash": "5c39123805ffb4e2",
"transitionCount": 2,
"transitions": [
{
"from": "idle",
"to": "planner",
"durationMs": 11,
"message": "전략 수립 중...",
"ts": "2026-05-12T14:23:21.894Z"
},
{
"from": "planner",
"to": "researcher",
"durationMs": 1,
"message": "핵심 정보 수집 및 분석 중...",
"ts": "2026-05-12T14:23:21.895Z"
}
],
"resilienceMetrics": {
"fallbacks": 0,
"retries": 0,
"maxConflictScore": 0,
"deduplications": 0
}
}
+11
View File
@@ -1,5 +1,16 @@
# Astra Patch Notes
## v2.80.35 (2026-05-12)
### 🧠 Experience Memory & Architectural Lessons
- **경험 메모리(Experience Memory) 설계:** `EXPERIENCE_MEMORY_PLAN.md`를 통해 에이전트가 수행한 작업의 성공/실패 사례를 '레슨'으로 자산화하는 체계를 설계했습니다.
- **레슨 헬퍼 도입:** `lessonHelpers.ts` 및 관련 테스트를 통해 과거의 교훈을 동적으로 검색하고 프롬프트에 주입하는 기능을 추가했습니다.
- **브레인 인덱스 타입 정교화:** `types.ts``brainIndex.ts` 수정을 통해 지식 검색의 타입 안정성과 필터링 정확도를 개선했습니다.
- **익스텐션 코어 최적화:** `extension.ts` 내의 리소스 관리 및 라이프사이클 로직을 보강하여 장시간 사용 시의 안정성을 높였습니다.
- **사이드바 UI 정밀 개선:** 사이드바의 시각적 요소와 인터랙션 스크립트를 최적화하여 조작감을 개선했습니다.
- **신규 패키징:** `astra-2.80.35.vsix` 패키지를 생성하여 경험 기반 학습 능력이 강화된 에이전트 환경을 통합했습니다.
---
## v2.80.34 (2026-05-12)
### 🧠 Advanced Context Management & Brain Indexing
- **신규 컨텍스트 매니저 도입:** `contextManager.ts`를 통해 대규모 파일 및 대화 내역의 우선순위를 지능적으로 관리하고 토큰 예산을 최적화하는 기능을 추가했습니다.
+122
View File
@@ -0,0 +1,122 @@
# Experience Memory (Mistake / Lesson Loop) — Implementation Plan
> Goal: **Astra extracts reusable lessons from work results and QA feedback, and automatically
> turns them into a pre-task checklist so it stops falling into the same hole.**
>
> Not "make the model perfect" — "put a railing where the model keeps falling."
## Design principles (why this isn't just "more logging")
1. **Closed loop**: record → extract lesson → inject before next similar task → preflight checklist → (light) post-check.
Plain recording does not change future behavior; an *injected* lesson does.
2. **Reuse existing infrastructure — do NOT build a parallel system.** Astra already has:
- 5-layer memory (`src/memory/`): ProceduralMemory ("how to do X" — recipes), EpisodicMemory ("conversation flow"),
LongTermMemory ("user rules/decisions"), ProjectMemory, ShortTermMemory; `MemoryExtractor.onSessionEnd`.
- ProjectChronicle (`src/features/projectChronicle`): planning/development/bugs/retrospectives auto-records.
- `RetrievalOrchestrator` + `selectWithinBudget` (RAG), SecondBrainTrace.
- `src/retrieval/brainIndex.ts` — mtime-keyed token cache of **every `.md` in the active brain**.
⇒ Lessons are just markdown files **inside the active brain**, identified by `lessons/`-style path or
frontmatter `type: lesson|playbook|qa-finding`. They become retrievable *for free* via `searchBrainFiles`.
Distinction to keep clear: **ProceduralMemory = recipe ("how"), Lesson = guardrail ("what went wrong & how not to repeat")**.
3. **The risky half is *production* of lessons, not consumption.** Low-signal auto-records pollute retrieval.
So lesson *generation* is heavily gated; lesson *consumption* (retrieval + injection) is the cheap, safe half — build it first.
4. **Everything goes through the token budget.** Lessons compete with brain knowledge inside the same
~3200-token RAG allocation (and the small-model context cap). They get a modest score boost + a small
reserved sub-slot, **frequency-weighted, not recency-weighted** (a mistake violated 5× is louder than a one-off).
5. **Inspectable & correctable.** A bad auto-lesson that keeps getting injected = a poison source. The user must
see which lessons fed an answer (the per-answer scope footer) and be able to edit/delete/ignore one trivially.
## Data model
Lesson card = a markdown file in the active brain (convention: under `lessons/`, `playbooks/`, or `qa-findings/`,
or any file with the frontmatter below). The brain index (`brainIndex.ts`, version ≥ 3) stores a `kind` per file
parsed from path + frontmatter, so retrieval can tell lessons apart without re-reading content.
```md
---
type: lesson # lesson | playbook | qa-finding
title: Telegram remote execution must require allowlist
applies-to: [telegram, remote-execution, security, approval-flow]
project: ConnectAI # optional — scopes the lesson to one project
severity: high # low | medium | high
source: curated # curated | auto (curated weighted higher in retrieval)
occurrences: 1 # bumped on dedup-merge instead of creating a duplicate
last-seen: 2026-05-12
---
## Situation
## Mistake / Risk
## Root Cause
## Fix
## Prevention Checklist
-
-
## Applies To
- telegram
- remote-execution
-
```
## Pipeline (target end state)
| Stage | What | Trigger | Risk control |
|---|---|---|---|
| **Retrieve** | On a new request, retrieve relevant lessons (same RAG pipeline; `applies-to` tags + content matched, modest boost) | every turn | budget-bounded; capped to top-K |
| **Preflight** | Inject `[ACTIVE LESSONS — verify before finalizing]` block (Prevention Checklists) into the *protected* part of the system prompt | every turn with ≥1 lesson | placed before `[CONTEXT]` so it survives truncation |
| **Collect** | Capture changed files, run commands, failed logs, test red→green, rollbacks, approval rejections, user QA feedback as events | during the turn | already mostly in TransactionManager / approval queue |
| **Generate** | Build a *draft* lesson card from a strong-trigger turn | ① explicit QA feedback ② test red→green ③ `transactionManager.rollback()` ④ approval rejection ⑤ user says "기록해" — **never on a plain success** | quality bar (must have concrete Root Cause + checklist) or discard; **dedup → merge & bump `occurrences`**; **human confirms before persist (MVP)**; `source: auto`, lower weight |
| **Post-check (non-blocking)** | After the answer, flag in the footer any Prevention-Checklist item not visibly addressed | every turn with lessons | **never re-prompt / never block** — just a footer warning. (Blocking gate only for "risky ops" via the existing dryRun approval flow, v2+) |
Hard caps: ≤ N lessons injected per turn; ≤ M total `source: auto` lessons before requiring cleanup
(mirrors `brainIndex` 12k cap). Cross-project bleed prevented by `project:` tag + ProjectMemory-style scoping.
## MVP — build the *consumption* side first
1. **Lesson detection in the brain index**`brainIndex.ts` parses `kind` from path/frontmatter; stored, version-bumped.
2. **Lesson-aware retrieval**`searchBrainFiles` marks lesson chunks (`metadata.isLesson`), uses a larger excerpt
(whole card if short), gives a modest score boost. `RetrievalOrchestrator.retrieve()` splits lesson chunks out
into `result.lessonChunks`.
3. **Preflight injection**`agent.buildMemoryContext` prepends an `[ACTIVE LESSONS — verify before finalizing]`
block (built from lesson cards / their Prevention-Checklist sections) ahead of the normal RAG context, inside
the truncation-protected zone. Non-lesson RAG context unchanged.
4. **Visibility** — the per-answer "참조 범위" footer (already shipped) shows `· 교훈 N개` and hover lists the files;
clicking opens the agent↔knowledge mapping editor (or, later, the lesson files).
5. **Manual seeding**`g1nation.lesson.create` command: asks for a title, writes `<brain>/lessons/<slug>.md`
from the card template, opens it. This is also the seed for the future auto-generator.
> With (1)(5) you can hand-write 35 high-value lessons today and immediately verify "the next similar task behaves
> differently", before any auto-generation risk exists.
## v2 — status
Done:
- **Triggers → "기록할까요?" prompt** (human-confirm UI, never auto-saves): `transactionManager.rollback()` (action failure), `rejectTransaction()` (user rejected a dry-run change), and QA-feedback (user message matches `isQaRegressionFeedback` — "또 안 돼", "비슷한 실수", "왜 반복돼", "고쳤는데 깨졌", "regression", "why … keep failing", …). All post a `lessonCandidate` webview message → sidebar shows a dismissible box → "📝 교훈 기록" runs `g1nation.lesson.fromConversation` (pre-filled Situation).
- **`occurrences` dedup-merge**: `createLessonCard` checks existing lessons by normalized title; on match it offers "갱신 (occurrences +1)" which runs `bumpLessonOccurrences` (increments `occurrences:`, sets `last-seen:`) instead of spawning a duplicate. Recurring mistake → louder, not more numerous.
- **Non-blocking post-QA flag**: `findUnaddressedChecklistItems(answer, lessonCards)` — lesson Prevention-Checklist items whose significant terms don't appear in the answer are listed in the per-answer footer (`⚠ 답변에서 안 보이는 교훈 체크리스트 항목: …`). No re-prompt, no block.
- **Manage / delete / ignore UI**: `g1nation.lesson.manage` QuickPick (lists all lesson cards from the brain via the index; open on select; trash button → confirm + delete = no longer injected). The footer's `⚠ 교훈 N` is clickable → opens this picker.
Not done (next):
- Auto-retrospective draft on a *successful* turn (heavily gated, `source: auto`, lower retrieval weight). — deliberately last; high noise risk.
- test red→green trigger — **blocked**: `<run_command>` actions run in a VS Code terminal (`terminal.sendText`) with no output capture, so Astra can't observe test results. Needs a captured-output execution path first.
- `source: auto` vs `curated` retrieval-weight distinction; archive one-off lessons unused > 6 months.
- Reserved lesson sub-budget within the RAG allocation; frequency-weighted (`occurrences`) ordering of injected lessons.
## Integration points (files)
- `src/retrieval/lessonHelpers.ts` *(new, pure)*`LESSON_DIR_RE`, `detectLessonKind(relativePath, content)`, `buildLessonChecklistBlock(chunks)`, `lessonTemplate(title)`.
- `src/retrieval/brainIndex.ts``IndexEntry.kind`, version bump, populate via `detectLessonKind`; expose `kind` on `IndexedBrainDoc`.
- `src/retrieval/types.ts``RetrievalChunk.metadata.isLesson?`, `RetrievalResult.lessonChunks?`.
- `src/retrieval/index.ts``searchBrainFiles` lesson handling; `retrieve()` splits lesson chunks.
- `src/agent.ts``buildMemoryContext` prepends the lessons block; `_lastRetrievalInfo.lessonFiles`; `usedScope` message carries it.
- `media/sidebar.js` — scope footer shows `· 교훈 N개`.
- `package.json` + `src/extension.ts``g1nation.lesson.create` command.
- *(v2)* `src/features/projectChronicle`, `TransactionManager`, approval queue — trigger hooks; `MemoryManager`/`ProceduralMemory` — optional structured layer.
+34
View File
@@ -792,3 +792,37 @@
}
.msg-scope-footer .scope-link:hover { text-decoration: underline; }
.msg-scope-footer .scope-dim { color: var(--border-bright); }
.msg-scope-footer .scope-lesson { color: var(--warning); cursor: pointer; }
.msg-scope-footer .scope-lesson:hover { text-decoration: underline; }
.msg-scope-footer .scope-unaddressed {
margin-top: 5px;
color: var(--warning);
font-size: 9.5px;
line-height: 1.5;
}
/* ── "Record a lesson?" prompt after a rollback / rejected change / repeated complaint ── */
.lesson-candidate-box {
margin-top: 8px;
padding: 8px 10px;
border: 1px solid var(--warning);
border-radius: 8px;
background: var(--bg-secondary);
font-size: 11px;
color: var(--text-dim);
}
.lesson-candidate-box .lc-title { color: var(--text-bright); margin-bottom: 4px; }
.lesson-candidate-box .lc-reason { font-size: 10px; margin-bottom: 6px; word-break: break-word; }
.lesson-candidate-box .lc-btns { display: flex; gap: 6px; }
.lesson-candidate-box button {
font-size: 10px;
padding: 3px 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-bright);
cursor: pointer;
}
.lesson-candidate-box .lc-rec { border-color: var(--warning); color: var(--warning); }
.lesson-candidate-box button:hover { border-color: var(--border-bright); }
+52 -4
View File
@@ -257,6 +257,37 @@
});
}
// ── "Record a lesson?" prompt (after a rollback / rejected change / repeated complaint) ──
function renderLessonCandidate(v) {
const t = v && v.trigger;
const titleText = t === 'rejected'
? '⚠ 방금 변경을 거부하셨네요 — 같은 실수를 반복하지 않게 교훈으로 기록할까요?'
: t === 'qa-feedback'
? '⚠ 같은 문제가 반복되는 것 같습니다 — 교훈으로 기록해두면 다음 작업 전에 자동으로 체크합니다.'
: '⚠ 방금 작업이 롤백됐습니다 — 같은 실수를 반복하지 않게 교훈으로 기록할까요?';
const reasonLine = v && v.reason ? `<div class="lc-reason">사유: ${escAttr(String(v.reason))}</div>` : '';
// Reuse the active (or last) assistant bubble as the anchor; fall back to appending to the chat.
let anchor = streamBody && streamBody._parent;
if (!anchor) { const all = chat.querySelectorAll('.msg.msg-ai'); anchor = all[all.length - 1]; }
const old = (anchor || chat).querySelector('.lesson-candidate-box');
if (old) old.remove();
const box = document.createElement('div');
box.className = 'lesson-candidate-box';
box.innerHTML =
`<div class="lc-title">${escAttr(titleText)}</div>` +
reasonLine +
`<div class="lc-btns"><button class="lc-rec">📝 교훈 기록</button><button class="lc-skip">무시</button></div>`;
box.querySelector('.lc-rec').onclick = () => { vscode.postMessage({ type: 'createLessonFromConversation' }); box.remove(); };
box.querySelector('.lc-skip').onclick = () => box.remove();
if (anchor) {
const actions = anchor.querySelector('.msg-actions');
if (actions) anchor.insertBefore(box, actions); else anchor.appendChild(box);
} else {
chat.appendChild(box);
}
chat.scrollTop = chat.scrollHeight;
}
// ── Per-answer "scope used" footer ──────────────────────────────────
const MEMORY_LAYER_LABELS = {
'long-term-memory': '장기기억',
@@ -278,7 +309,8 @@
footer.className = 'msg-scope-footer';
const files = Array.isArray(v.usedBrainFiles) ? v.usedBrainFiles : [];
const layers = (Array.isArray(v.usedMemoryLayers) ? v.usedMemoryLayers : []).map(s => MEMORY_LAYER_LABELS[s] || s);
if (files.length === 0 && layers.length === 0) {
const lessons = Array.isArray(v.lessonFiles) ? v.lessonFiles : [];
if (files.length === 0 && layers.length === 0 && lessons.length === 0) {
footer.innerHTML = `<span class="scope-link" data-act="map" title="에이전트↔지식 매핑 편집">🔎 참조 지식 없음</span> <span class="scope-dim">— 모델 자체 지식으로 답변</span>`;
} else {
const dirs = Array.from(new Set(files.map(dirOf)));
@@ -287,18 +319,31 @@
scopeLabel = v.configuredFolders.join(', ');
} else if (dirs.length) {
scopeLabel = dirs.slice(0, 4).join(', ') + (dirs.length > 4 ? `${dirs.length - 4}` : '');
} else if (files.length === 0) {
scopeLabel = '브레인 파일 없음';
} else {
scopeLabel = '전체 브레인';
}
const agentTag = v.agentName ? `[${escAttr(v.agentName)}] ` : '';
const fileTag = files.length ? ` <span class="scope-dim">· 파일 ${files.length}</span>` : '';
const layerTag = layers.length ? ` <span class="scope-dim">· 메모리 ${escAttr(layers.join('·'))}</span>` : '';
const lessonTag = lessons.length ? ` <span class="scope-lesson" data-act="lessons" title="${escAttr('적용된 교훈 (클릭 → 교훈 관리):\n' + lessons.join('\n'))}">· ⚠ 교훈 ${lessons.length}</span>` : '';
const titleAttr = files.length ? `사용된 브레인 파일:\n${files.join('\n')}` : '에이전트↔지식 매핑 편집';
footer.innerHTML = `<span class="scope-link" data-act="map" title="${escAttr(titleAttr)}">🔎 참조: ${agentTag}${escAttr(scopeLabel)}</span>${fileTag}${layerTag}`;
footer.innerHTML = `<span class="scope-link" data-act="map" title="${escAttr(titleAttr)}">🔎 참조: ${agentTag}${escAttr(scopeLabel)}</span>${fileTag}${lessonTag}${layerTag}`;
}
// Non-blocking flag: lesson Prevention-Checklist items the answer doesn't visibly address.
const unaddressed = Array.isArray(v.unaddressedChecklist) ? v.unaddressedChecklist : [];
if (unaddressed.length) {
const list = unaddressed.map(it => `· ${escAttr(it)}`).join('<br>');
const w = document.createElement('div');
w.className = 'scope-unaddressed';
w.innerHTML = `⚠ 답변에서 안 보이는 교훈 체크리스트 항목:<br>${list}`;
footer.appendChild(w);
}
footer.addEventListener('click', e => {
const t = e.target;
if (t && t.dataset && t.dataset.act === 'map') vscode.postMessage({ type: 'editKnowledgeMap' });
const act = e.target && e.target.dataset && e.target.dataset.act;
if (act === 'map') vscode.postMessage({ type: 'editKnowledgeMap' });
else if (act === 'lessons') vscode.postMessage({ type: 'manageLessons' });
});
const actions = target.querySelector('.msg-actions');
if (actions) target.insertBefore(footer, actions); else target.appendChild(footer);
@@ -587,6 +632,9 @@
renderScopeFooter(target, msg.value || {});
break;
}
case 'lessonCandidate':
renderLessonCandidate(msg.value || {});
break;
case 'autoContinue':
statusLabel.innerText = msg.value; thinkingBar.classList.add('active');
if (msg.value.includes('Analyzing')) setStep('analyze');
+13 -1
View File
@@ -2,7 +2,7 @@
"name": "astra",
"displayName": "Astra",
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
"version": "2.80.34",
"version": "2.80.35",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
@@ -83,6 +83,18 @@
{
"command": "g1nation.skills.editKnowledgeMap",
"title": "Astra: Edit Agent ↔ Knowledge Map"
},
{
"command": "g1nation.lesson.create",
"title": "Astra: New Lesson (Experience Memory)"
},
{
"command": "g1nation.lesson.fromConversation",
"title": "Astra: New Lesson from Current Conversation"
},
{
"command": "g1nation.lesson.manage",
"title": "Astra: Browse / Manage Lessons"
}
],
"keybindings": [
+66 -15
View File
@@ -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);
}
}
}
}
+145
View File
@@ -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,
+17
View File
@@ -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;
}
+8 -1
View File
@@ -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
View File
@@ -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;
}
}
+277
View File
@@ -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;
}
+9 -1
View File
@@ -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[]; // 디버그용 융합 로그
+6
View File
@@ -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;
+6 -3
View File
@@ -26,18 +26,21 @@ describe('brainIndex.getBrainTokenIndex', () => {
try { fs.rmSync(brain, { recursive: true, force: true }); } catch { /* ignore */ }
});
it('tokenizes files and returns one entry per file', () => {
it('tokenizes files, returns one entry per file, and tags lesson cards', () => {
const a = writeMd(brain, 'architecture-overview.md', '# Architecture overview\nThis describes the system architecture and design.');
const b = writeMd(brain, 'records/bug-report.md', '# Bug report\n이 설계는 기존 구조와 충돌 위험이 있습니다.');
const out = getBrainTokenIndex(brain, [a, b]);
expect(out).toHaveLength(2);
const c = writeMd(brain, 'lessons/allowlist.md', '# Lesson\n## Prevention Checklist\n- check the allowlist');
const out = getBrainTokenIndex(brain, [a, b, c]);
expect(out).toHaveLength(3);
const byPath = new Map(out.map(d => [d.filePath, d]));
expect(byPath.get(a)!.tokens).toContain('architecture');
expect(byPath.get(a)!.tokens).toContain('design');
expect(byPath.get(a)!.titleTokens.length).toBeGreaterThan(0);
expect(byPath.get(a)!.kind).toBe('');
expect(byPath.get(b)!.relativePath).toBe(path.join('records', 'bug-report.md'));
expect(byPath.get(b)!.conflictCount).toBeGreaterThan(0); // "충돌" is a conflict indicator
expect(byPath.get(a)!.conflictCount).toBe(0);
expect(byPath.get(c)!.kind).toBe('lesson'); // detected from the lessons/ path segment
});
it('reuses cached tokens for unchanged files and re-indexes only changed ones', () => {
+16
View File
@@ -5,6 +5,7 @@ import {
trimHistoryToBudget,
truncateSystemPromptContext,
classifyStopReason,
shouldShowTruncationNotice,
estimateModelParamsB,
CONTEXT_OPEN_MARKER,
CONTEXT_CLOSE_MARKER,
@@ -111,3 +112,18 @@ describe('contextManager.classifyStopReason', () => {
expect(classifyStopReason(undefined)).toBe('unknown');
});
});
describe('contextManager.shouldShowTruncationNotice', () => {
it('suppresses output-limit notices for visibly short answers', () => {
expect(shouldShowTruncationNotice('output-limit', 80, 4096)).toBe(false);
});
it('shows output-limit notices when output consumed most of the budget', () => {
expect(shouldShowTruncationNotice('output-limit', 3900, 4096)).toBe(true);
});
it('always surfaces context overflow and error stops', () => {
expect(shouldShowTruncationNotice('context-overflow', 10, 4096)).toBe(true);
expect(shouldShowTruncationNotice('error', 10, 4096)).toBe(true);
});
});
+191
View File
@@ -0,0 +1,191 @@
import {
detectLessonKind,
extractPreventionChecklist,
buildLessonChecklistBlock,
lessonTemplate,
lessonSlug,
LESSON_DIR_RE,
isQaRegressionFeedback,
parseLessonFrontmatter,
normalizeLessonTitle,
bumpLessonOccurrences,
findUnaddressedChecklistItems,
} from '../src/retrieval/lessonHelpers';
describe('lessonHelpers.detectLessonKind', () => {
it('detects by path segment', () => {
expect(detectLessonKind('lessons/2026-05-12-foo.md', '# Foo')).toBe('lesson');
expect(detectLessonKind('docs/playbooks/release.md', 'stuff')).toBe('playbook');
expect(detectLessonKind('qa-findings/bug-x.md', 'stuff')).toBe('qa-finding');
expect(detectLessonKind('a/b/lesson/x.md', '')).toBe('lesson'); // singular "lesson"
expect(detectLessonKind('notes/architecture.md', '# Arch')).toBe('');
});
it('frontmatter type wins over path', () => {
const fm = '---\ntype: qa-finding\ntitle: x\n---\n# body';
expect(detectLessonKind('notes/whatever.md', fm)).toBe('qa-finding');
const fm2 = '---\ntype: "lesson"\n---\nbody';
expect(detectLessonKind('random.md', fm2)).toBe('lesson');
});
it('ignores non-lesson frontmatter / no frontmatter', () => {
expect(detectLessonKind('notes/x.md', '---\ntype: note\n---\nbody')).toBe('');
expect(detectLessonKind('notes/x.md', 'just text, no frontmatter')).toBe('');
expect(detectLessonKind('notes/x.md', '')).toBe('');
});
it('LESSON_DIR_RE matches expected dirs only', () => {
expect(LESSON_DIR_RE.test('lessons/x.md')).toBe(true);
expect(LESSON_DIR_RE.test('a\\playbooks\\y.md')).toBe(true);
expect(LESSON_DIR_RE.test('qa_findings/z.md')).toBe(true);
expect(LESSON_DIR_RE.test('lessons-archive/x.md')).toBe(false); // not a path segment boundary
expect(LESSON_DIR_RE.test('records/dev.md')).toBe(false);
});
});
describe('lessonHelpers.extractPreventionChecklist', () => {
it('pulls the bullets under a Prevention Checklist heading', () => {
const card = [
'# Lesson: X',
'## Root Cause',
'because reasons',
'## Prevention Checklist',
'- check the allowlist',
'* approval flow exists',
'- path boundary validated',
'## Applies To',
'- security',
].join('\n');
expect(extractPreventionChecklist(card)).toEqual([
'check the allowlist', 'approval flow exists', 'path boundary validated',
]);
});
it('returns [] when there is no checklist', () => {
expect(extractPreventionChecklist('# Lesson\nsome text')).toEqual([]);
expect(extractPreventionChecklist('')).toEqual([]);
});
});
describe('lessonHelpers.buildLessonChecklistBlock', () => {
it('renders an ACTIVE LESSONS block with checklists when available', () => {
const block = buildLessonChecklistBlock([
{ title: 'lessons/telegram.md', content: '# X\n## Prevention Checklist\n- allowlist required\n- approval flow' },
{ title: 'lessons/paths.md', content: 'no checklist here, just prose about path safety' },
]);
expect(block).toContain('ACTIVE LESSONS');
expect(block).toContain('### lessons/telegram.md');
expect(block).toContain('- [ ] allowlist required');
expect(block).toContain('- [ ] approval flow');
expect(block).toContain('### lessons/paths.md');
expect(block).toContain('just prose about path safety');
expect(block).toContain('END ACTIVE LESSONS');
});
it('returns empty string for no chunks', () => {
expect(buildLessonChecklistBlock([])).toBe('');
});
});
describe('lessonHelpers.lessonTemplate / lessonSlug', () => {
it('template has frontmatter + the standard sections', () => {
const t = lessonTemplate('Telegram 원격 실행은 allowlist 필수', '2026-05-12');
expect(t).toMatch(/^---\ntype: lesson\n/);
expect(t).toContain('title: Telegram 원격 실행은 allowlist 필수');
expect(t).toContain('last-seen: 2026-05-12');
for (const h of ['## Situation', '## Mistake / Risk', '## Root Cause', '## Fix', '## Prevention Checklist', '## Applies To']) {
expect(t).toContain(h);
}
// detectLessonKind should recognize the generated template
expect(detectLessonKind('lessons/x.md', t)).toBe('lesson');
});
it('slug is filesystem-safe and length-bounded', () => {
expect(lessonSlug('Hello, World! / weird:chars')).toBe('hello-world-weird-chars');
expect(lessonSlug('한글 제목 테스트')).toBe('한글-제목-테스트');
expect(lessonSlug('')).toBe('lesson');
expect(lessonSlug('a'.repeat(200)).length).toBeLessThanOrEqual(60);
});
});
describe('lessonHelpers.isQaRegressionFeedback', () => {
it('flags recurring-problem complaints (ko/en)', () => {
expect(isQaRegressionFeedback('이거 또 안 돼')).toBe(true);
expect(isQaRegressionFeedback('아까랑 비슷한 실수네')).toBe(true);
expect(isQaRegressionFeedback('왜 자꾸 이래?')).toBe(true);
expect(isQaRegressionFeedback('고쳤는데 또 깨졌어')).toBe(true);
expect(isQaRegressionFeedback('여전히 안 돼')).toBe(true);
expect(isQaRegressionFeedback('this is a regression')).toBe(true);
expect(isQaRegressionFeedback('why does this keep failing again')).toBe(true);
});
it('does not flag ordinary requests', () => {
expect(isQaRegressionFeedback('이 함수 리팩터링해줘')).toBe(false);
expect(isQaRegressionFeedback('add a new endpoint for users')).toBe(false);
expect(isQaRegressionFeedback('')).toBe(false);
expect(isQaRegressionFeedback('또')).toBe(false); // too short / not a complaint
});
});
describe('lessonHelpers.parseLessonFrontmatter / normalizeLessonTitle / bumpLessonOccurrences', () => {
const card = [
'---',
'type: lesson',
'title: Telegram remote exec needs allowlist',
'applies-to: [telegram, "remote-execution", security]',
'occurrences: 2',
'last-seen: 2026-05-01',
'---',
'# body',
'stuff',
].join('\n');
it('parses frontmatter fields', () => {
const fm = parseLessonFrontmatter(card);
expect(fm.type).toBe('lesson');
expect(fm.title).toBe('Telegram remote exec needs allowlist');
expect(fm.occurrences).toBe(2);
expect(fm.appliesTo).toEqual(['telegram', 'remote-execution', 'security']);
});
it('returns {} when there is no frontmatter', () => {
expect(parseLessonFrontmatter('# just a heading\ntext')).toEqual({});
expect(parseLessonFrontmatter('')).toEqual({});
});
it('normalizeLessonTitle strips punctuation/case/space for matching', () => {
expect(normalizeLessonTitle('Telegram 원격 실행은 allowlist 필수!')).toBe(normalizeLessonTitle('telegram원격실행은allowlist필수'));
expect(normalizeLessonTitle('A B C')).toBe('abc');
});
it('bumpLessonOccurrences increments and updates last-seen', () => {
const out = bumpLessonOccurrences(card, '2026-05-12');
expect(parseLessonFrontmatter(out).occurrences).toBe(3);
expect(out).toContain('last-seen: 2026-05-12');
expect(out).toContain('# body'); // body untouched
});
it('bumpLessonOccurrences inserts the keys when missing', () => {
const minimal = '---\ntype: lesson\ntitle: X\n---\nbody';
const out = bumpLessonOccurrences(minimal, '2026-05-12');
expect(parseLessonFrontmatter(out).occurrences).toBe(2);
expect(out).toContain('last-seen: 2026-05-12');
});
it('bumpLessonOccurrences leaves a frontmatter-less file unchanged', () => {
expect(bumpLessonOccurrences('no frontmatter here', '2026-05-12')).toBe('no frontmatter here');
});
});
describe('lessonHelpers.findUnaddressedChecklistItems', () => {
const card = [
'# Lesson',
'## Prevention Checklist',
'- 원격 실행 기능은 allowlist 필수인지 확인한다',
'- 파일 변경은 승인 흐름을 거치는지 확인한다',
'- <템플릿 placeholder 항목>',
'## Applies To',
'- security',
].join('\n');
it('flags items whose terms do not appear in the answer', () => {
const answer = '이번 변경은 승인 흐름을 거치도록 했습니다. 파일 변경 시 사용자에게 확인을 받습니다.';
const out = findUnaddressedChecklistItems(answer, [card]);
// "allowlist" item is unaddressed; the "승인 흐름/파일 변경" item is addressed; the placeholder is skipped
expect(out).toEqual(['원격 실행 기능은 allowlist 필수인지 확인한다']);
});
it('returns [] when the answer touches all checklist items', () => {
const answer = 'allowlist 정책을 추가했고, 파일 변경은 승인 흐름을 거칩니다.';
expect(findUnaddressedChecklistItems(answer, [card])).toEqual([]);
});
it('returns [] with no answer or no lessons', () => {
expect(findUnaddressedChecklistItems('', [card])).toEqual([]);
expect(findUnaddressedChecklistItems('something', [])).toEqual([]);
});
});
+13
View File
@@ -155,6 +155,19 @@ describe('local project path preflight', () => {
expect(agent.isThinkingPartnerRequest(prompt)).toBe(true);
});
it('classifies short greetings as casual conversation so RAG can be skipped', () => {
const context: any = {
globalStorageUri: { fsPath: path.join(root, '.storage') },
workspaceState: stateStore(),
globalState: stateStore()
};
const agent = new AgentExecutor(context) as any;
expect(agent.isCasualConversationPrompt('안녕')).toBe(true);
expect(agent.isCasualConversationPrompt('hello!')).toBe(true);
expect(agent.isCasualConversationPrompt('안녕, 이 프로젝트 구조 분석해줘')).toBe(false);
});
it('adds concrete Astra mode architecture context for Guard and MA design questions', () => {
const context: any = {
globalStorageUri: { fsPath: path.join(root, '.storage') },