diff --git a/.astra/tests/engine/.astra/missions/integration_002.json b/.astra/tests/engine/.astra/missions/integration_002.json new file mode 100644 index 0000000..9cfa058 --- /dev/null +++ b/.astra/tests/engine/.astra/missions/integration_002.json @@ -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 + } +} \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json deleted file mode 100644 index 73b9ac1..0000000 --- a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1778593954576, - "modelVersion": "unknown" -} \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/5f56d7c28eda4d8014dd780e2754d34e891fd609323064a5114d3b32b04f003b.json b/.astra/tests/stress/.astra/cache/5f56d7c28eda4d8014dd780e2754d34e891fd609323064a5114d3b32b04f003b.json new file mode 100644 index 0000000..187e398 --- /dev/null +++ b/.astra/tests/stress/.astra/cache/5f56d7c28eda4d8014dd780e2754d34e891fd609323064a5114d3b32b04f003b.json @@ -0,0 +1,5 @@ +{ + "result": "Plan OK passes validation and meets all length requirements.", + "createdAt": 1778595801894, + "modelVersion": "unknown" +} \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json deleted file mode 100644 index 5610ace..0000000 --- a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1778593954567, - "modelVersion": "unknown" -} \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json deleted file mode 100644 index cf70e53..0000000 --- a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", - "createdAt": 1778593954561, - "modelVersion": "unknown" -} \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json deleted file mode 100644 index 3adda41..0000000 --- a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json +++ /dev/null @@ -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" -} \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1778593954545.json b/.astra/tests/stress/.astra/missions/stress_conflict_1778593954545.json deleted file mode 100644 index bc94b2a..0000000 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1778593954545.json +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_fallback_1778595801883.json b/.astra/tests/stress/.astra/missions/stress_fallback_1778595801883.json new file mode 100644 index 0000000..1e8b35e --- /dev/null +++ b/.astra/tests/stress/.astra/missions/stress_fallback_1778595801883.json @@ -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 + } +} \ No newline at end of file diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 6702bfa..f09d51c 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -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`를 통해 대규모 파일 및 대화 내역의 우선순위를 지능적으로 관리하고 토큰 예산을 최적화하는 기능을 추가했습니다. diff --git a/docs/EXPERIENCE_MEMORY_PLAN.md b/docs/EXPERIENCE_MEMORY_PLAN.md new file mode 100644 index 0000000..fa27255 --- /dev/null +++ b/docs/EXPERIENCE_MEMORY_PLAN.md @@ -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 `/lessons/.md` + from the card template, opens it. This is also the seed for the future auto-generator. + +> With (1)–(5) you can hand-write 3–5 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**: `` 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. diff --git a/media/sidebar.css b/media/sidebar.css index 65c239b..d337956 100644 --- a/media/sidebar.css +++ b/media/sidebar.css @@ -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); } diff --git a/media/sidebar.js b/media/sidebar.js index 8675919..ae12c30 100644 --- a/media/sidebar.js +++ b/media/sidebar.js @@ -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 ? `
사유: ${escAttr(String(v.reason))}
` : ''; + // 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 = + `
${escAttr(titleText)}
` + + reasonLine + + `
`; + 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 = `🔎 참조 지식 없음 — 모델 자체 지식으로 답변`; } 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 ? ` · 파일 ${files.length}` : ''; const layerTag = layers.length ? ` · 메모리 ${escAttr(layers.join('·'))}` : ''; + const lessonTag = lessons.length ? ` · ⚠ 교훈 ${lessons.length}` : ''; const titleAttr = files.length ? `사용된 브레인 파일:\n${files.join('\n')}` : '에이전트↔지식 매핑 편집'; - footer.innerHTML = `🔎 참조: ${agentTag}${escAttr(scopeLabel)}${fileTag}${layerTag}`; + footer.innerHTML = `🔎 참조: ${agentTag}${escAttr(scopeLabel)}${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('
'); + const w = document.createElement('div'); + w.className = 'scope-unaddressed'; + w.innerHTML = `⚠ 답변에서 안 보이는 교훈 체크리스트 항목:
${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'); diff --git a/package.json b/package.json index 12f6ea7..9dbb518 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/src/agent.ts b/src/agent.ts index c8e8b4a..f30404c 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -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 | 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); } } -} \ No newline at end of file +} diff --git a/src/extension.ts b/src/extension.ts index 0cc8971..951cbb4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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 { + 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 { + 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(); + 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, diff --git a/src/lib/contextManager.ts b/src/lib/contextManager.ts index b2db96e..adfca1b 100644 --- a/src/lib/contextManager.ts +++ b/src/lib/contextManager.ts @@ -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; +} diff --git a/src/retrieval/brainIndex.ts b/src/retrieval/brainIndex.ts index 13556f6..d48d98b 100644 --- a/src/retrieval/brainIndex.ts +++ b/src/retrieval/brainIndex.ts @@ -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, }); } diff --git a/src/retrieval/index.ts b/src/retrieval/index.ts index 25df822..9c3b470 100644 --- a/src/retrieval/index.ts +++ b/src/retrieval/index.ts @@ -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(); + 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; } } diff --git a/src/retrieval/lessonHelpers.ts b/src/retrieval/lessonHelpers.ts new file mode 100644 index 0000000..0e7278a --- /dev/null +++ b/src/retrieval/lessonHelpers.ts @@ -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 (/^" + 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(); + 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; +} diff --git a/src/retrieval/types.ts b/src/retrieval/types.ts index 3daae03..e2b51de 100644 --- a/src/retrieval/types.ts +++ b/src/retrieval/types.ts @@ -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[]; // 디버그용 융합 로그 diff --git a/src/sidebar/chatHandlers.ts b/src/sidebar/chatHandlers.ts index dd14a81..d59dbc9 100644 --- a/src/sidebar/chatHandlers.ts +++ b/src/sidebar/chatHandlers.ts @@ -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; diff --git a/tests/brainIndex.test.ts b/tests/brainIndex.test.ts index 7bd7882..936470c 100644 --- a/tests/brainIndex.test.ts +++ b/tests/brainIndex.test.ts @@ -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', () => { diff --git a/tests/contextManager.test.ts b/tests/contextManager.test.ts index 1aa0ef2..762b45b 100644 --- a/tests/contextManager.test.ts +++ b/tests/contextManager.test.ts @@ -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); + }); +}); diff --git a/tests/lessonHelpers.test.ts b/tests/lessonHelpers.test.ts new file mode 100644 index 0000000..4ff1c31 --- /dev/null +++ b/tests/lessonHelpers.test.ts @@ -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([]); + }); +}); diff --git a/tests/localPathPreflight.test.ts b/tests/localPathPreflight.test.ts index ae91209..dbd39bc 100644 --- a/tests/localPathPreflight.test.ts +++ b/tests/localPathPreflight.test.ts @@ -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') },