release: v2.0.1 - Advanced Knowledge Mix & Architectural Intelligence
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||||
"createdAt": 1778600627518,
|
"createdAt": 1778677516269,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||||
"createdAt": 1778600627507,
|
"createdAt": 1778677516268,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
||||||
"createdAt": 1778600627501,
|
"createdAt": 1778677516268,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "---\nid: stress_conflict_1778600627483\ndate: 2026-05-12T15:43:47.518Z\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]** 핵심 정보 수집 및 분석 중... (6ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (12ms)\n",
|
"result": "---\nid: stress_conflict_1778677516257\ndate: 2026-05-13T13:05:16.269Z\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]** 전략 수립 중... (10ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (1ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (1ms)\n",
|
||||||
"createdAt": 1778600627518,
|
"createdAt": 1778677516269,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+11
-11
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"missionId": "stress_conflict_1778600627483",
|
"missionId": "stress_conflict_1778677516257",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"startTime": "2026-05-12T15:43:47.484Z",
|
"startTime": "2026-05-13T13:05:16.257Z",
|
||||||
"totalElapsedMs": 34,
|
"totalElapsedMs": 12,
|
||||||
"results": {
|
"results": {
|
||||||
"planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
"planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
||||||
"researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
"researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||||
@@ -16,30 +16,30 @@
|
|||||||
{
|
{
|
||||||
"from": "idle",
|
"from": "idle",
|
||||||
"to": "planner",
|
"to": "planner",
|
||||||
"durationMs": 11,
|
"durationMs": 10,
|
||||||
"message": "전략 수립 중...",
|
"message": "전략 수립 중...",
|
||||||
"ts": "2026-05-12T15:43:47.495Z"
|
"ts": "2026-05-13T13:05:16.267Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "planner",
|
"from": "planner",
|
||||||
"to": "researcher",
|
"to": "researcher",
|
||||||
"durationMs": 6,
|
"durationMs": 1,
|
||||||
"message": "핵심 정보 수집 및 분석 중...",
|
"message": "핵심 정보 수집 및 분석 중...",
|
||||||
"ts": "2026-05-12T15:43:47.501Z"
|
"ts": "2026-05-13T13:05:16.268Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "researcher",
|
"from": "researcher",
|
||||||
"to": "writer",
|
"to": "writer",
|
||||||
"durationMs": 12,
|
"durationMs": 1,
|
||||||
"message": "최종 리포트 작성 및 편집 중...",
|
"message": "최종 리포트 작성 및 편집 중...",
|
||||||
"ts": "2026-05-12T15:43:47.513Z"
|
"ts": "2026-05-13T13:05:16.269Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "writer",
|
"from": "writer",
|
||||||
"to": "completed",
|
"to": "completed",
|
||||||
"durationMs": 5,
|
"durationMs": 0,
|
||||||
"message": "미션 완료",
|
"message": "미션 완료",
|
||||||
"ts": "2026-05-12T15:43:47.518Z"
|
"ts": "2026-05-13T13:05:16.269Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resilienceMetrics": {
|
"resilienceMetrics": {
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# Astra Patch Notes
|
# Astra Patch Notes
|
||||||
|
|
||||||
|
## v2.0.1 (2026-05-13)
|
||||||
|
### 🧠 Advanced Knowledge Mix & Architectural Intelligence
|
||||||
|
- **지식 믹스(Knowledge Mix) 엔진 도입:** 에이전트가 답변 시 '세컨드 브레인' 지식과 자체 학습 지식을 사용하는 비중을 정교하게 조절할 수 있는 `knowledgeMix.ts`를 구현했습니다.
|
||||||
|
- **프로젝트 아키텍처 인텐트 감지:** 프로젝트의 구조적 질문을 자동으로 식별하고 대응하는 `projectArchitecture` 기능을 추가하여 심층 분석 능력을 강화했습니다.
|
||||||
|
- **사이드바 UI 및 인터랙션 최적화:** 사이드바의 시각적 요소와 스크립트를 개선하여 대화 흐름의 매끄러움을 더했습니다.
|
||||||
|
- **신규 패키징:** `astra-2.0.1.vsix` 패키지를 통해 최신 지능 최적화와 아키텍처 분석 기능이 통합된 버전을 배포합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v2.0.0 (2026-05-13)
|
## v2.0.0 (2026-05-13)
|
||||||
### 🚀 Major Milestone & Intelligence Evolution
|
### 🚀 Major Milestone & Intelligence Evolution
|
||||||
- **지식 검색 엔진 고도화:** `embeddings.ts` 및 `scoring.ts`를 통해 시맨틱 검색과 키워드 검색이 결합된 하이브리드 검색 기능을 강화했습니다.
|
- **지식 검색 엔진 고도화:** `embeddings.ts` 및 `scoring.ts`를 통해 시맨틱 검색과 키워드 검색이 결합된 하이브리드 검색 기능을 강화했습니다.
|
||||||
|
|||||||
@@ -321,6 +321,61 @@
|
|||||||
.input-footer { display: flex; align-items: center; justify-content: space-between; }
|
.input-footer { display: flex; align-items: center; justify-content: space-between; }
|
||||||
.footer-left { display: flex; align-items: center; gap: 8px; }
|
.footer-left { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
/* Project Architecture chip — sits just above the input when project mode is on. */
|
||||||
|
.arch-chip {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.arch-chip[data-active="true"] { display: flex; }
|
||||||
|
.arch-chip-icon { font-size: 14px; flex-shrink: 0; }
|
||||||
|
.arch-chip-info { flex: 1; min-width: 0; line-height: 1.3; }
|
||||||
|
.arch-chip-title {
|
||||||
|
color: var(--text-bright); font-weight: 600;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.arch-chip-meta { color: var(--text-dim); font-size: 10px; }
|
||||||
|
.arch-chip-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||||
|
.arch-chip-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s ease;
|
||||||
|
}
|
||||||
|
.arch-chip-btn:hover {
|
||||||
|
background: var(--control-bg-hover);
|
||||||
|
border-color: var(--border-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact model picker placed directly below the input box. */
|
||||||
|
.input-model-row {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
margin-top: 6px; padding: 4px 8px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
}
|
||||||
|
.input-model-label {
|
||||||
|
font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
color: var(--text-dim); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.input-model-select-wrap { flex: 1; min-width: 0; }
|
||||||
|
.input-model-select-wrap select {
|
||||||
|
width: 100%; min-width: 0;
|
||||||
|
background: transparent; color: var(--text-primary);
|
||||||
|
border: none; outline: none; padding: 4px 6px;
|
||||||
|
font-size: 11px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.input-model-select-wrap select:focus { box-shadow: 0 0 0 2px var(--accent-glow); border-radius: 4px; }
|
||||||
|
|
||||||
.send-btn {
|
.send-btn {
|
||||||
background: var(--accent); color: #fff; border: none; padding: 6px 14px; border-radius: 6px;
|
background: var(--accent); color: #fff; border: none; padding: 6px 14px; border-radius: 6px;
|
||||||
font-weight: 600; font-size: 12px; cursor: pointer;
|
font-weight: 600; font-size: 12px; cursor: pointer;
|
||||||
@@ -755,6 +810,11 @@
|
|||||||
font-size: 9.5px;
|
font-size: 9.5px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.msg-scope-footer .scope-mix {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 9.5px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── "Record a lesson?" prompt after a rollback / rejected change / repeated complaint ── */
|
/* ── "Record a lesson?" prompt after a rollback / rejected change / repeated complaint ── */
|
||||||
.lesson-candidate-box {
|
.lesson-candidate-box {
|
||||||
@@ -818,6 +878,40 @@
|
|||||||
padding: 5px 8px 2px;
|
padding: 5px 8px 2px;
|
||||||
}
|
}
|
||||||
.hdr-menu-label:first-child { padding-top: 2px; }
|
.hdr-menu-label:first-child { padding-top: 2px; }
|
||||||
|
.hdr-menu-hint {
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 9.5px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact bipolar slider used both in the header menu and inside the agent-map modal. */
|
||||||
|
.knowledge-mix-control {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 4px 8px 6px;
|
||||||
|
}
|
||||||
|
.knowledge-mix-control .km-end-label {
|
||||||
|
font-size: 9.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 30px;
|
||||||
|
}
|
||||||
|
.knowledge-mix-control .km-end-label:last-of-type { text-align: right; }
|
||||||
|
.knowledge-mix-control .km-slider {
|
||||||
|
flex: 1; min-width: 0; cursor: pointer; accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.knowledge-mix-control .km-slider:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.map-mix-control { padding: 0; }
|
||||||
|
.map-checkbox-row {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 11px; color: var(--text-primary); cursor: pointer;
|
||||||
|
}
|
||||||
|
.map-checkbox-row input[type="checkbox"] { accent-color: var(--accent); }
|
||||||
.hdr-menu-item {
|
.hdr-menu-item {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
+64
-3
@@ -50,6 +50,16 @@
|
|||||||
<div class="hdr-menu-label">Model</div>
|
<div class="hdr-menu-label">Model</div>
|
||||||
<div class="select-wrap"><select id="modelSel" title="Select Model"></select></div>
|
<div class="select-wrap"><select id="modelSel" title="Select Model"></select></div>
|
||||||
|
|
||||||
|
<div class="hdr-menu-label">
|
||||||
|
Knowledge Mix
|
||||||
|
<span class="hdr-menu-hint" id="knowledgeMixHint">Model 50% · Brain 50%</span>
|
||||||
|
</div>
|
||||||
|
<div class="knowledge-mix-control" title="Slide left for more model knowledge, right for more Second Brain reliance">
|
||||||
|
<span class="km-end-label">Model</span>
|
||||||
|
<input type="range" id="knowledgeMixSlider" min="0" max="100" step="5" value="50" class="km-slider">
|
||||||
|
<span class="km-end-label">Brain</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="hdr-menu-label">Brain</div>
|
<div class="hdr-menu-label">Brain</div>
|
||||||
<div class="control-row">
|
<div class="control-row">
|
||||||
<div class="select-wrap"><select id="brainSel" title="Select Brain"></select></div>
|
<div class="select-wrap"><select id="brainSel" title="Select Brain"></select></div>
|
||||||
@@ -132,6 +142,37 @@
|
|||||||
<div id="agentMapAgentName" class="map-agent-name">(선택된 에이전트가 없습니다)</div>
|
<div id="agentMapAgentName" class="map-agent-name">(선택된 에이전트가 없습니다)</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="map-section">
|
||||||
|
<div class="map-section-head">
|
||||||
|
<div>
|
||||||
|
<div class="map-section-title">🤖 Model for this agent</div>
|
||||||
|
<div class="map-section-hint">이 에이전트를 선택했을 때 사용할 모델을 지정합니다. <strong>Use current model</strong>을 선택하면 상단에서 고른 기본 모델을 그대로 사용합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="select-wrap" style="margin-top:8px;">
|
||||||
|
<select id="agentMapModelSel" title="Model override for this agent"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="map-section">
|
||||||
|
<div class="map-section-head">
|
||||||
|
<div>
|
||||||
|
<div class="map-section-title">🎚 Knowledge Mix for this agent</div>
|
||||||
|
<div class="map-section-hint">에이전트별 의존도. <strong>Use global setting</strong>을 켜두면 상단 슬라이더 값을 그대로 따릅니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="map-checkbox-row" style="margin-top:8px;">
|
||||||
|
<input type="checkbox" id="agentMapMixUseGlobal" checked>
|
||||||
|
<span>Use global setting</span>
|
||||||
|
</label>
|
||||||
|
<div class="knowledge-mix-control map-mix-control" style="margin-top:6px;">
|
||||||
|
<span class="km-end-label">Model</span>
|
||||||
|
<input type="range" id="agentMapMixSlider" min="0" max="100" step="5" value="50" class="km-slider" disabled>
|
||||||
|
<span class="km-end-label">Brain</span>
|
||||||
|
</div>
|
||||||
|
<div class="map-section-hint" id="agentMapMixHint" style="margin-top:4px;">Model 50% · Brain 50%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="map-section">
|
<div class="map-section">
|
||||||
<div class="map-section-head">
|
<div class="map-section-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -187,6 +228,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-wrap">
|
<div class="input-wrap">
|
||||||
|
<!--
|
||||||
|
Project Architecture chip. Hidden by default; the JS handler flips
|
||||||
|
`data-active` when the extension host sends an `architectureStatus`
|
||||||
|
message with active=true. Click "Open" / "Refresh" / "Detach" to
|
||||||
|
route back to the chatHandlers cases.
|
||||||
|
-->
|
||||||
|
<div id="archChip" class="arch-chip" data-active="false">
|
||||||
|
<span class="arch-chip-icon">📋</span>
|
||||||
|
<div class="arch-chip-info">
|
||||||
|
<div class="arch-chip-title" id="archChipTitle">—</div>
|
||||||
|
<div class="arch-chip-meta" id="archChipMeta">Auto-load Off</div>
|
||||||
|
</div>
|
||||||
|
<div class="arch-chip-actions">
|
||||||
|
<button class="arch-chip-btn" id="archOpenBtn" title="Architecture 문서 열기">Open</button>
|
||||||
|
<button class="arch-chip-btn" id="archRefreshBtn" title="지금 다시 스캔">Refresh</button>
|
||||||
|
<button class="arch-chip-btn" id="archDetachBtn" title="자동 첨부 끄기">Detach</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="agentConfigPanel" class="panel">
|
<div id="agentConfigPanel" class="panel">
|
||||||
<div class="field-label">Agent Persona/Instructions</div>
|
<div class="field-label">Agent Persona/Instructions</div>
|
||||||
<textarea id="agentPrompt" rows="5" placeholder="Agent Persona & Instructions..."></textarea>
|
<textarea id="agentPrompt" rows="5" placeholder="Agent Persona & Instructions..."></textarea>
|
||||||
@@ -213,9 +272,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="toastNotif" class="toast-notif"></div>
|
<div id="toastNotif" class="toast-notif"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
<div class="input-model-row" id="inlineModelRow">
|
||||||
<button class="action-btn" style="flex:1" id="inputNewChatBtn">New Chat</button>
|
<label for="inlineModelSel" class="input-model-label">Model</label>
|
||||||
<button class="action-btn" style="flex:1" id="inputSyncBtn">Sync Knowledge</button>
|
<div class="select-wrap input-model-select-wrap">
|
||||||
|
<select id="inlineModelSel" title="Switch model for this conversation"></select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input type="file" id="fileInput" multiple hidden accept="image/*,.txt,.md,.pdf,.csv,.json,.js,.ts,.py,.java,.rs,.go">
|
<input type="file" id="fileInput" multiple hidden accept="image/*,.txt,.md,.pdf,.csv,.json,.js,.ts,.py,.java,.rs,.go">
|
||||||
|
|||||||
+264
-16
@@ -179,6 +179,23 @@
|
|||||||
const recordsLatest = document.getElementById('recordsLatest');
|
const recordsLatest = document.getElementById('recordsLatest');
|
||||||
|
|
||||||
function escAttr(t) { return String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
function escAttr(t) { return String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||||||
|
function fmtMixHint(w) { return `Model ${100 - w}% · Brain ${w}%`; }
|
||||||
|
/** Compact relative-time formatter for chips (e.g. "2m ago", "just now"). */
|
||||||
|
function formatRelativeTime(iso) {
|
||||||
|
try {
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
if (!Number.isFinite(then)) return iso;
|
||||||
|
const diffSec = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
||||||
|
if (diffSec < 45) return 'just now';
|
||||||
|
if (diffSec < 90) return '1m ago';
|
||||||
|
const m = Math.floor(diffSec / 60);
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
return `${d}d ago`;
|
||||||
|
} catch { return iso; }
|
||||||
|
}
|
||||||
function fmtK(n) {
|
function fmtK(n) {
|
||||||
if (typeof n !== 'number' || !isFinite(n)) return '?';
|
if (typeof n !== 'number' || !isFinite(n)) return '?';
|
||||||
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k';
|
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k';
|
||||||
@@ -335,6 +352,19 @@
|
|||||||
const titleAttr = files.length ? `사용된 브레인 파일:\n${files.join('\n')}` : '에이전트↔지식 매핑 편집';
|
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}${lessonTag}${layerTag}`;
|
footer.innerHTML = `<span class="scope-link" data-act="map" title="${escAttr(titleAttr)}">🔎 참조: ${agentTag}${escAttr(scopeLabel)}</span>${fileTag}${lessonTag}${layerTag}`;
|
||||||
}
|
}
|
||||||
|
// Knowledge Mix indicator — shows the policy that actually drove this turn so the
|
||||||
|
// user can see *why* the answer leaned the way it did.
|
||||||
|
if (v.knowledgeMix && typeof v.knowledgeMix.weight === 'number') {
|
||||||
|
const w = Math.max(0, Math.min(100, v.knowledgeMix.weight));
|
||||||
|
const src = v.knowledgeMix.source;
|
||||||
|
const srcLabel = src === 'agent'
|
||||||
|
? `agent: ${v.knowledgeMix.agent || v.agentName || ''}`
|
||||||
|
: src === 'global' ? 'global' : 'default';
|
||||||
|
const mix = document.createElement('div');
|
||||||
|
mix.className = 'scope-mix';
|
||||||
|
mix.innerHTML = `🎚 Knowledge Mix · Model ${100 - w}% / Brain ${w}% <span class="scope-dim">(${escAttr(srcLabel)})</span>`;
|
||||||
|
footer.appendChild(mix);
|
||||||
|
}
|
||||||
// Non-blocking flag: lesson Prevention-Checklist items the answer doesn't visibly address.
|
// Non-blocking flag: lesson Prevention-Checklist items the answer doesn't visibly address.
|
||||||
const unaddressed = Array.isArray(v.unaddressedChecklist) ? v.unaddressedChecklist : [];
|
const unaddressed = Array.isArray(v.unaddressedChecklist) ? v.unaddressedChecklist : [];
|
||||||
if (unaddressed.length) {
|
if (unaddressed.length) {
|
||||||
@@ -353,7 +383,66 @@
|
|||||||
if (actions) target.insertBefore(footer, actions); else target.appendChild(footer);
|
if (actions) target.insertBefore(footer, actions); else target.appendChild(footer);
|
||||||
}
|
}
|
||||||
|
|
||||||
let agentMapDraft = { agentPath: '', name: '', knowledgeFolders: [], skillFolders: [] };
|
// `model: ''` means "Use current model" (i.e. no per-agent override).
|
||||||
|
// `secondBrainWeight: null` means "Use global setting"; a number 0–100 overrides it.
|
||||||
|
let agentMapDraft = { agentPath: '', name: '', knowledgeFolders: [], skillFolders: [], model: '', secondBrainWeight: null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync the per-agent Knowledge Mix UI (checkbox + slider + hint) to whatever
|
||||||
|
* is currently in `agentMapDraft.secondBrainWeight`. Called whenever the
|
||||||
|
* modal opens or the backend ships fresh data.
|
||||||
|
*/
|
||||||
|
function syncAgentMapMixUi() {
|
||||||
|
const cb = document.getElementById('agentMapMixUseGlobal');
|
||||||
|
const slider = document.getElementById('agentMapMixSlider');
|
||||||
|
const hint = document.getElementById('agentMapMixHint');
|
||||||
|
if (!cb || !slider || !hint) return;
|
||||||
|
const useGlobal = agentMapDraft.secondBrainWeight === null || agentMapDraft.secondBrainWeight === undefined;
|
||||||
|
cb.checked = useGlobal;
|
||||||
|
slider.disabled = useGlobal;
|
||||||
|
const value = useGlobal
|
||||||
|
? (parseInt((document.getElementById('knowledgeMixSlider') || {}).value, 10) || 50)
|
||||||
|
: Math.max(0, Math.min(100, agentMapDraft.secondBrainWeight | 0));
|
||||||
|
slider.value = String(value);
|
||||||
|
hint.textContent = useGlobal
|
||||||
|
? `Use global · ${fmtMixHint(value)}`
|
||||||
|
: fmtMixHint(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the per-agent model dropdown using whatever model list the top-bar
|
||||||
|
* #modelSel currently has. Called whenever the modal opens OR the model list
|
||||||
|
* is refreshed by the extension host. Preserves the current draft selection.
|
||||||
|
*/
|
||||||
|
function refreshAgentMapModelOptions() {
|
||||||
|
const sel = document.getElementById('agentMapModelSel');
|
||||||
|
if (!sel) return;
|
||||||
|
const desired = agentMapDraft.model || '';
|
||||||
|
sel.innerHTML = '';
|
||||||
|
const useDefault = document.createElement('option');
|
||||||
|
useDefault.value = '';
|
||||||
|
useDefault.innerText = 'Use current model';
|
||||||
|
sel.appendChild(useDefault);
|
||||||
|
const seen = new Set();
|
||||||
|
// Source the available models from the populated top-bar dropdown so we don't
|
||||||
|
// need an additional round-trip; if a model is selected for this agent but
|
||||||
|
// is no longer in the list, we still surface it so the user sees the value.
|
||||||
|
for (const opt of modelSel.options) {
|
||||||
|
if (!opt.value || seen.has(opt.value)) continue;
|
||||||
|
seen.add(opt.value);
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = opt.value;
|
||||||
|
o.innerText = opt.innerText;
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
if (desired && !seen.has(desired)) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = desired;
|
||||||
|
o.innerText = `${desired} (saved)`;
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
sel.value = desired;
|
||||||
|
}
|
||||||
|
|
||||||
function renderAgentMapLists() {
|
function renderAgentMapLists() {
|
||||||
const renderList = (listEl, items, kind) => {
|
const renderList = (listEl, items, kind) => {
|
||||||
@@ -393,10 +482,12 @@
|
|||||||
}
|
}
|
||||||
agentMapStatus.className = 'map-status';
|
agentMapStatus.className = 'map-status';
|
||||||
agentMapStatus.textContent = '불러오는 중...';
|
agentMapStatus.textContent = '불러오는 중...';
|
||||||
agentMapDraft = { agentPath: agentSel.value, name: agentSel.options[agentSel.selectedIndex]?.text || '', knowledgeFolders: [], skillFolders: [] };
|
agentMapDraft = { agentPath: agentSel.value, name: agentSel.options[agentSel.selectedIndex]?.text || '', knowledgeFolders: [], skillFolders: [], model: '', secondBrainWeight: null };
|
||||||
agentMapAgentName.textContent = agentMapDraft.name;
|
agentMapAgentName.textContent = agentMapDraft.name;
|
||||||
knowledgeFolderList.innerHTML = '';
|
knowledgeFolderList.innerHTML = '';
|
||||||
skillFolderList.innerHTML = '';
|
skillFolderList.innerHTML = '';
|
||||||
|
refreshAgentMapModelOptions();
|
||||||
|
syncAgentMapMixUi();
|
||||||
agentMapOverlay.classList.add('visible');
|
agentMapOverlay.classList.add('visible');
|
||||||
vscode.postMessage({ type: 'getAgentMap', agentPath: agentSel.value });
|
vscode.postMessage({ type: 'getAgentMap', agentPath: agentSel.value });
|
||||||
}
|
}
|
||||||
@@ -569,6 +660,8 @@
|
|||||||
break;
|
break;
|
||||||
case 'modelsList': {
|
case 'modelsList': {
|
||||||
modelSel.innerHTML = '';
|
modelSel.innerHTML = '';
|
||||||
|
const inlineModelSel = document.getElementById('inlineModelSel');
|
||||||
|
if (inlineModelSel) inlineModelSel.innerHTML = '';
|
||||||
// [State Persistence - Tier 2] LocalStorage에서 마지막 선택 모델 복원 시도
|
// [State Persistence - Tier 2] LocalStorage에서 마지막 선택 모델 복원 시도
|
||||||
const _savedModel = localStorage.getItem('g1nation_last_model');
|
const _savedModel = localStorage.getItem('g1nation_last_model');
|
||||||
// 서버 추천 모델 vs 로컬 저장 모델 중 우선순위 결정
|
// 서버 추천 모델 vs 로컬 저장 모델 중 우선순위 결정
|
||||||
@@ -577,13 +670,21 @@
|
|||||||
? _savedModel
|
? _savedModel
|
||||||
: msg.value.selected;
|
: msg.value.selected;
|
||||||
const _loadedSet = new Set(Array.isArray(msg.value.loadedModels) ? msg.value.loadedModels : []);
|
const _loadedSet = new Set(Array.isArray(msg.value.loadedModels) ? msg.value.loadedModels : []);
|
||||||
msg.value.models.forEach(m => {
|
const _models = Array.isArray(msg.value.models) ? msg.value.models.slice() : [];
|
||||||
const o = document.createElement('option');
|
// Fallback: server returned nothing but we still know the configured model.
|
||||||
o.value = m;
|
if (_models.length === 0 && _preferredModel) _models.push(_preferredModel);
|
||||||
// ● = 현재 LM Studio 메모리에 로드된 모델 / ○ = 다운로드만 됨
|
_models.forEach(m => {
|
||||||
o.innerText = _loadedSet.has(m) ? `● ${m}` : m;
|
const label = _loadedSet.has(m) ? `● ${m}` : m;
|
||||||
if (m === _preferredModel) o.selected = true;
|
const o1 = document.createElement('option');
|
||||||
modelSel.appendChild(o);
|
o1.value = m; o1.innerText = label;
|
||||||
|
if (m === _preferredModel) o1.selected = true;
|
||||||
|
modelSel.appendChild(o1);
|
||||||
|
if (inlineModelSel) {
|
||||||
|
const o2 = document.createElement('option');
|
||||||
|
o2.value = m; o2.innerText = label;
|
||||||
|
if (m === _preferredModel) o2.selected = true;
|
||||||
|
inlineModelSel.appendChild(o2);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// LocalStorage에 저장된 모델이 실제로 적용된 경우, 백엔드 설정도 동기화
|
// LocalStorage에 저장된 모델이 실제로 적용된 경우, 백엔드 설정도 동기화
|
||||||
if (_savedModel && _savedModel !== msg.value.selected && msg.value.models.includes(_savedModel)) {
|
if (_savedModel && _savedModel !== msg.value.selected && msg.value.models.includes(_savedModel)) {
|
||||||
@@ -591,6 +692,8 @@
|
|||||||
}
|
}
|
||||||
if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder();
|
if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder();
|
||||||
statusLabel.innerText = `Model: ${_preferredModel}`;
|
statusLabel.innerText = `Model: ${_preferredModel}`;
|
||||||
|
// Refresh per-agent model dropdown options (if currently visible) so it stays in sync.
|
||||||
|
if (typeof refreshAgentMapModelOptions === 'function') refreshAgentMapModelOptions();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'brainProfiles':
|
case 'brainProfiles':
|
||||||
@@ -672,6 +775,71 @@
|
|||||||
vscode.postMessage({ type: 'getKnowledgeScope', agentPath: msg.selected });
|
vscode.postMessage({ type: 'getKnowledgeScope', agentPath: msg.selected });
|
||||||
syncContextBar();
|
syncContextBar();
|
||||||
break;
|
break;
|
||||||
|
case 'architectureStatus': {
|
||||||
|
// Show / hide the chip + reflect current state.
|
||||||
|
const chip = document.getElementById('archChip');
|
||||||
|
const title = document.getElementById('archChipTitle');
|
||||||
|
const meta = document.getElementById('archChipMeta');
|
||||||
|
if (!chip || !title || !meta) break;
|
||||||
|
const v = msg.value || {};
|
||||||
|
if (!v.active) {
|
||||||
|
chip.setAttribute('data-active', 'false');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chip.setAttribute('data-active', 'true');
|
||||||
|
title.textContent = `${v.projectName || 'Project'} architecture`;
|
||||||
|
const updatedLabel = v.lastUpdated ? `updated ${formatRelativeTime(v.lastUpdated)}` : 'just attached';
|
||||||
|
const autoLabel = v.autoUpdate === false ? 'Auto-update Off' : 'Auto-update On';
|
||||||
|
meta.textContent = `${updatedLabel} · ${autoLabel}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'architectureRefreshFailed': {
|
||||||
|
const reason = msg.value && msg.value.reason;
|
||||||
|
if (reason === 'no-active-project') {
|
||||||
|
showToast('활성 프로젝트가 없습니다. 먼저 프로젝트를 선택하세요.', 'warn');
|
||||||
|
} else {
|
||||||
|
showToast('Architecture 갱신 실패', 'warn');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'knowledgeMix': {
|
||||||
|
// Initial sync: reflect whatever weight is currently in settings.
|
||||||
|
if (msg.value && typeof msg.value.weight === 'number') {
|
||||||
|
const w = Math.max(0, Math.min(100, msg.value.weight));
|
||||||
|
const slider = document.getElementById('knowledgeMixSlider');
|
||||||
|
if (slider) slider.value = String(w);
|
||||||
|
const hint = document.getElementById('knowledgeMixHint');
|
||||||
|
if (hint) hint.textContent = fmtMixHint(w);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'agentModelOverride': {
|
||||||
|
// The extension chose a different model than what the dropdowns show
|
||||||
|
// (per-agent pinned model). Reflect that in the UI without persisting
|
||||||
|
// it as the new global default — selecting a different agent or
|
||||||
|
// clearing the override should restore the previous selection.
|
||||||
|
const pinned = msg.value && msg.value.model;
|
||||||
|
if (pinned) {
|
||||||
|
const inlineSel = document.getElementById('inlineModelSel');
|
||||||
|
// Add an option if it isn't already known so the value can stick.
|
||||||
|
const ensureOption = (sel) => {
|
||||||
|
if (!sel) return;
|
||||||
|
const has = Array.from(sel.options).some(o => o.value === pinned);
|
||||||
|
if (!has) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = pinned;
|
||||||
|
o.innerText = `${pinned} (agent)`;
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
sel.value = pinned;
|
||||||
|
};
|
||||||
|
ensureOption(modelSel);
|
||||||
|
ensureOption(inlineSel);
|
||||||
|
statusLabel.innerText = `Model: ${pinned} (agent override)`;
|
||||||
|
if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'agentMapData':
|
case 'agentMapData':
|
||||||
if (msg.value) {
|
if (msg.value) {
|
||||||
agentMapDraft = {
|
agentMapDraft = {
|
||||||
@@ -679,8 +847,15 @@
|
|||||||
name: agentMapDraft.name,
|
name: agentMapDraft.name,
|
||||||
knowledgeFolders: Array.isArray(msg.value.knowledgeFolders) ? msg.value.knowledgeFolders.slice() : [],
|
knowledgeFolders: Array.isArray(msg.value.knowledgeFolders) ? msg.value.knowledgeFolders.slice() : [],
|
||||||
skillFolders: Array.isArray(msg.value.skillFolders) ? msg.value.skillFolders.slice() : [],
|
skillFolders: Array.isArray(msg.value.skillFolders) ? msg.value.skillFolders.slice() : [],
|
||||||
|
model: typeof msg.value.model === 'string' ? msg.value.model : '',
|
||||||
|
secondBrainWeight: (typeof msg.value.secondBrainWeight === 'number'
|
||||||
|
&& Number.isFinite(msg.value.secondBrainWeight))
|
||||||
|
? Math.max(0, Math.min(100, Math.round(msg.value.secondBrainWeight)))
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
renderAgentMapLists();
|
renderAgentMapLists();
|
||||||
|
refreshAgentMapModelOptions();
|
||||||
|
syncAgentMapMixUi();
|
||||||
agentMapStatus.textContent = msg.value.exists ? '' : '새 매핑입니다. 저장하면 생성됩니다.';
|
agentMapStatus.textContent = msg.value.exists ? '' : '새 매핑입니다. 저장하면 생성됩니다.';
|
||||||
agentMapStatus.className = 'map-status';
|
agentMapStatus.className = 'map-status';
|
||||||
}
|
}
|
||||||
@@ -936,7 +1111,8 @@
|
|||||||
|
|
||||||
const startNewChat = () => { Sound.play(660, 'sine', 0.1); vscode.postMessage({ type: 'newChat' }); };
|
const startNewChat = () => { Sound.play(660, 'sine', 0.1); vscode.postMessage({ type: 'newChat' }); };
|
||||||
document.getElementById('newChatBtn').onclick = startNewChat;
|
document.getElementById('newChatBtn').onclick = startNewChat;
|
||||||
document.getElementById('inputNewChatBtn').onclick = startNewChat;
|
// Note: input-footer "New Chat" / "Sync Knowledge" buttons were removed.
|
||||||
|
// Both actions remain available in the top toolbar (newChatBtn / brainBtn / Tools menu).
|
||||||
|
|
||||||
document.getElementById('settingsBtn').onclick = () => vscode.postMessage({ type: 'openSettings' });
|
document.getElementById('settingsBtn').onclick = () => vscode.postMessage({ type: 'openSettings' });
|
||||||
document.getElementById('internetBtn').onclick = () => {
|
document.getElementById('internetBtn').onclick = () => {
|
||||||
@@ -969,7 +1145,7 @@
|
|||||||
if (!brainSel.value || brainSel.value === 'new') return;
|
if (!brainSel.value || brainSel.value === 'new') return;
|
||||||
vscode.postMessage({ type: 'deleteBrain', id: brainSel.value });
|
vscode.postMessage({ type: 'deleteBrain', id: brainSel.value });
|
||||||
};
|
};
|
||||||
document.getElementById('inputSyncBtn').onclick = syncBrain;
|
// (inputSyncBtn removed — Sync Knowledge is reachable via the top brainBtn / Tools menu.)
|
||||||
document.getElementById('historyBtn').onclick = () => vscode.postMessage({ type: 'getSessions' });
|
document.getElementById('historyBtn').onclick = () => vscode.postMessage({ type: 'getSessions' });
|
||||||
document.getElementById('historyBtn').addEventListener('click', () => historyOverlay.classList.add('visible'));
|
document.getElementById('historyBtn').addEventListener('click', () => historyOverlay.classList.add('visible'));
|
||||||
document.getElementById('closeHistoryBtn').onclick = () => historyOverlay.classList.remove('visible');
|
document.getElementById('closeHistoryBtn').onclick = () => historyOverlay.classList.remove('visible');
|
||||||
@@ -979,20 +1155,31 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
modelSel.onchange = () => {
|
// Shared handler so the top-bar dropdown and the inline-below-input dropdown
|
||||||
const _selectedModel = modelSel.value;
|
// always commit the same way and stay visually synced.
|
||||||
|
const applyModelSelection = (selectedModel, originEl) => {
|
||||||
|
if (!selectedModel) return;
|
||||||
// [State Persistence - Tier 2] 모델 변경 시 LocalStorage에 즉시 저장 (클라이언트 측 지속성)
|
// [State Persistence - Tier 2] 모델 변경 시 LocalStorage에 즉시 저장 (클라이언트 측 지속성)
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('g1nation_last_model', _selectedModel);
|
localStorage.setItem('g1nation_last_model', selectedModel);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.warn('[Astra] LocalStorage 저장 실패:', e);
|
console.warn('[Astra] LocalStorage 저장 실패:', e);
|
||||||
}
|
}
|
||||||
// [State Persistence - Tier 1] VS Code 전역 설정에 동기화 (영구 저장)
|
// [State Persistence - Tier 1] VS Code 전역 설정에 동기화 (영구 저장)
|
||||||
vscode.postMessage({ type: 'model', value: _selectedModel });
|
vscode.postMessage({ type: 'model', value: selectedModel });
|
||||||
|
// Mirror the value to the *other* dropdown so both pickers reflect reality.
|
||||||
|
const inlineSel = document.getElementById('inlineModelSel');
|
||||||
|
if (originEl !== modelSel && modelSel.value !== selectedModel) modelSel.value = selectedModel;
|
||||||
|
if (inlineSel && originEl !== inlineSel && inlineSel.value !== selectedModel) inlineSel.value = selectedModel;
|
||||||
updateInputPlaceholder();
|
updateInputPlaceholder();
|
||||||
// 상태 레이블 즉시 업데이트
|
// 상태 레이블 즉시 업데이트
|
||||||
statusLabel.innerText = `Model: ${_selectedModel}`;
|
statusLabel.innerText = `Model: ${selectedModel}`;
|
||||||
};
|
};
|
||||||
|
modelSel.onchange = () => applyModelSelection(modelSel.value, modelSel);
|
||||||
|
const _inlineModelSelEl = document.getElementById('inlineModelSel');
|
||||||
|
if (_inlineModelSelEl) {
|
||||||
|
_inlineModelSelEl.onchange = () => applyModelSelection(_inlineModelSelEl.value, _inlineModelSelEl);
|
||||||
|
}
|
||||||
brainSel.onchange = () => {
|
brainSel.onchange = () => {
|
||||||
if (brainSel.value === 'new') {
|
if (brainSel.value === 'new') {
|
||||||
vscode.postMessage({ type: 'addBrain' });
|
vscode.postMessage({ type: 'addBrain' });
|
||||||
@@ -1057,9 +1244,41 @@
|
|||||||
agentPath: agentMapDraft.agentPath,
|
agentPath: agentMapDraft.agentPath,
|
||||||
knowledgeFolders: agentMapDraft.knowledgeFolders,
|
knowledgeFolders: agentMapDraft.knowledgeFolders,
|
||||||
skillFolders: agentMapDraft.skillFolders,
|
skillFolders: agentMapDraft.skillFolders,
|
||||||
|
// Empty string = "Use current model" (override removed).
|
||||||
|
model: agentMapDraft.model || '',
|
||||||
|
// null = "Use global setting" (override removed); number 0–100 = pinned.
|
||||||
|
secondBrainWeight: agentMapDraft.secondBrainWeight,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Track changes to the per-agent model dropdown so the draft stays in sync.
|
||||||
|
const _agentMapModelSelEl = document.getElementById('agentMapModelSel');
|
||||||
|
if (_agentMapModelSelEl) {
|
||||||
|
_agentMapModelSelEl.onchange = () => {
|
||||||
|
agentMapDraft.model = _agentMapModelSelEl.value || '';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ── Per-agent Knowledge Mix slider + "Use global" checkbox ────────────
|
||||||
|
const _agentMapMixCb = document.getElementById('agentMapMixUseGlobal');
|
||||||
|
const _agentMapMixSlider = document.getElementById('agentMapMixSlider');
|
||||||
|
if (_agentMapMixCb && _agentMapMixSlider) {
|
||||||
|
_agentMapMixCb.addEventListener('change', () => {
|
||||||
|
if (_agentMapMixCb.checked) {
|
||||||
|
agentMapDraft.secondBrainWeight = null;
|
||||||
|
} else {
|
||||||
|
// Snap to whatever the slider currently shows so the user has a starting point.
|
||||||
|
agentMapDraft.secondBrainWeight = parseInt(_agentMapMixSlider.value, 10) || 50;
|
||||||
|
}
|
||||||
|
syncAgentMapMixUi();
|
||||||
|
});
|
||||||
|
_agentMapMixSlider.addEventListener('input', () => {
|
||||||
|
if (_agentMapMixCb.checked) return; // disabled state, but guard anyway
|
||||||
|
const w = Math.max(0, Math.min(100, parseInt(_agentMapMixSlider.value, 10) || 50));
|
||||||
|
agentMapDraft.secondBrainWeight = w;
|
||||||
|
const hint = document.getElementById('agentMapMixHint');
|
||||||
|
if (hint) hint.textContent = fmtMixHint(w);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
editAgentBtn.onclick = () => {
|
editAgentBtn.onclick = () => {
|
||||||
if (agentSel.value === 'none') return;
|
if (agentSel.value === 'none') return;
|
||||||
@@ -1129,8 +1348,37 @@
|
|||||||
vscode.postMessage({ type: 'getAgents' });
|
vscode.postMessage({ type: 'getAgents' });
|
||||||
vscode.postMessage({ type: 'getChronicleProjects' });
|
vscode.postMessage({ type: 'getChronicleProjects' });
|
||||||
vscode.postMessage({ type: 'getChronicleRecords' });
|
vscode.postMessage({ type: 'getChronicleRecords' });
|
||||||
|
vscode.postMessage({ type: 'getKnowledgeMix' });
|
||||||
|
vscode.postMessage({ type: 'getArchitectureStatus' });
|
||||||
vscode.postMessage({ type: 'ready' });
|
vscode.postMessage({ type: 'ready' });
|
||||||
|
|
||||||
|
// ── Project Architecture chip buttons ─────────────────────────────────
|
||||||
|
const _archOpenBtn = document.getElementById('archOpenBtn');
|
||||||
|
const _archRefreshBtn = document.getElementById('archRefreshBtn');
|
||||||
|
const _archDetachBtn = document.getElementById('archDetachBtn');
|
||||||
|
if (_archOpenBtn) _archOpenBtn.onclick = () => vscode.postMessage({ type: 'openArchitectureDoc' });
|
||||||
|
if (_archRefreshBtn) _archRefreshBtn.onclick = () => vscode.postMessage({ type: 'refreshArchitecture' });
|
||||||
|
if (_archDetachBtn) _archDetachBtn.onclick = () => vscode.postMessage({ type: 'detachArchitecture' });
|
||||||
|
|
||||||
|
// ── Knowledge Mix: global slider ──────────────────────────────────────
|
||||||
|
// Mirrors `g1nation.knowledgeMix.secondBrainWeight`. The hint label updates
|
||||||
|
// live as the user drags; the value is committed (postMessage) on `change`
|
||||||
|
// so we don't spam settings updates while scrubbing.
|
||||||
|
const knowledgeMixSlider = document.getElementById('knowledgeMixSlider');
|
||||||
|
const knowledgeMixHint = document.getElementById('knowledgeMixHint');
|
||||||
|
const renderGlobalMixHint = () => {
|
||||||
|
if (!knowledgeMixSlider || !knowledgeMixHint) return;
|
||||||
|
knowledgeMixHint.textContent = fmtMixHint(parseInt(knowledgeMixSlider.value, 10) || 50);
|
||||||
|
};
|
||||||
|
if (knowledgeMixSlider) {
|
||||||
|
knowledgeMixSlider.addEventListener('input', renderGlobalMixHint);
|
||||||
|
knowledgeMixSlider.addEventListener('change', () => {
|
||||||
|
const w = Math.max(0, Math.min(100, parseInt(knowledgeMixSlider.value, 10) || 50));
|
||||||
|
vscode.postMessage({ type: 'setKnowledgeMix', value: w });
|
||||||
|
});
|
||||||
|
renderGlobalMixHint();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Proactive Behavioral Tracking ---
|
// --- Proactive Behavioral Tracking ---
|
||||||
let hoverTimer = null;
|
let hoverTimer = null;
|
||||||
const trackBehavior = (elementId, context) => {
|
const trackBehavior = (elementId, context) => {
|
||||||
|
|||||||
+13
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "astra",
|
"name": "astra",
|
||||||
"displayName": "Astra",
|
"displayName": "Astra",
|
||||||
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"publisher": "g1nation",
|
"publisher": "g1nation",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"icon": "assets/icon.png",
|
"icon": "assets/icon.png",
|
||||||
@@ -102,6 +102,18 @@
|
|||||||
{
|
{
|
||||||
"command": "g1nation.lesson.manage",
|
"command": "g1nation.lesson.manage",
|
||||||
"title": "Astra: Browse / Manage Lessons"
|
"title": "Astra: Browse / Manage Lessons"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "g1nation.architecture.refresh",
|
||||||
|
"title": "Astra: Refresh Project Architecture Context"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "g1nation.architecture.detach",
|
||||||
|
"title": "Astra: Detach Project Architecture Context"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "g1nation.architecture.open",
|
||||||
|
"title": "Astra: Open Project Architecture Doc"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"keybindings": [
|
"keybindings": [
|
||||||
|
|||||||
+65
-5
@@ -44,6 +44,13 @@ import { buildLessonChecklistBlock, isQaRegressionFeedback, findUnaddressedCheck
|
|||||||
import { embedQuery, embedTexts } from './retrieval/embeddings';
|
import { embedQuery, embedTexts } from './retrieval/embeddings';
|
||||||
import { backfillBrainEmbeddings } from './retrieval/brainIndex';
|
import { backfillBrainEmbeddings } from './retrieval/brainIndex';
|
||||||
import { resolveScopeForAgent } from './skills/agentKnowledgeMap';
|
import { resolveScopeForAgent } from './skills/agentKnowledgeMap';
|
||||||
|
import {
|
||||||
|
resolveKnowledgeMix,
|
||||||
|
mapWeightToBrainFileLimit,
|
||||||
|
mapWeightToRetrievalRatio,
|
||||||
|
buildKnowledgeMixPolicy,
|
||||||
|
ResolvedKnowledgeMix,
|
||||||
|
} from './retrieval/knowledgeMix';
|
||||||
import {
|
import {
|
||||||
extractVisibleFinal,
|
extractVisibleFinal,
|
||||||
shouldFinalOnlyRetry,
|
shouldFinalOnlyRetry,
|
||||||
@@ -202,6 +209,8 @@ export class AgentExecutor {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
/** Lesson card *contents* injected this turn — kept to check the answer against their Prevention Checklists. */
|
/** Lesson card *contents* injected this turn — kept to check the answer against their Prevention Checklists. */
|
||||||
private _lastLessonContents: string[] = [];
|
private _lastLessonContents: string[] = [];
|
||||||
|
/** Resolved Knowledge Mix for the most recent retrieval — surfaced in the scope footer. */
|
||||||
|
private _lastKnowledgeMix: ResolvedKnowledgeMix | null = null;
|
||||||
|
|
||||||
private readonly options: AgentExecutorOptions;
|
private readonly options: AgentExecutorOptions;
|
||||||
|
|
||||||
@@ -346,6 +355,12 @@ export class AgentExecutor {
|
|||||||
agentSkillFile?: string,
|
agentSkillFile?: string,
|
||||||
negativePrompt?: string,
|
negativePrompt?: string,
|
||||||
designerContext?: string,
|
designerContext?: string,
|
||||||
|
/**
|
||||||
|
* Pre-formatted architecture-context block (`[ACTIVE PROJECT ARCHITECTURE CONTEXT]…`)
|
||||||
|
* built by sidebarProvider from the active project's architecture doc.
|
||||||
|
* Empty/undefined when project mode is off or auto-attach is disabled.
|
||||||
|
*/
|
||||||
|
projectArchitectureContext?: string,
|
||||||
secondBrainTraceEnabled?: boolean,
|
secondBrainTraceEnabled?: boolean,
|
||||||
secondBrainTraceDebug?: boolean,
|
secondBrainTraceDebug?: boolean,
|
||||||
brainProfileId?: string
|
brainProfileId?: string
|
||||||
@@ -401,6 +416,7 @@ export class AgentExecutor {
|
|||||||
// "참조 범위" footer (the exact "안녕 → 🔎 참조: 에피소드기억" bug).
|
// "참조 범위" footer (the exact "안녕 → 🔎 참조: 에피소드기억" bug).
|
||||||
this._lastRetrievalInfo = null;
|
this._lastRetrievalInfo = null;
|
||||||
this._lastLessonContents = [];
|
this._lastLessonContents = [];
|
||||||
|
this._lastKnowledgeMix = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Prepare Context
|
// 1. Prepare Context
|
||||||
@@ -520,6 +536,13 @@ export class AgentExecutor {
|
|||||||
const designerCtx = options.designerContext
|
const designerCtx = options.designerContext
|
||||||
? `\n\n[PROJECT CHRONICLE GUARD]\n${options.designerContext}`
|
? `\n\n[PROJECT CHRONICLE GUARD]\n${options.designerContext}`
|
||||||
: '';
|
: '';
|
||||||
|
// Project Architecture context (Feature 2): durable per-project ground truth.
|
||||||
|
// Already pre-formatted by sidebarProvider with header + markers, so we just
|
||||||
|
// sandwich it with newlines. Suppressed implicitly because the field is empty
|
||||||
|
// when project mode is off — no extra check needed here.
|
||||||
|
const projectArchitectureCtx = options.projectArchitectureContext
|
||||||
|
? `\n\n${options.projectArchitectureContext}`
|
||||||
|
: '';
|
||||||
const secondBrainTraceCtx = secondBrainTrace
|
const secondBrainTraceCtx = secondBrainTrace
|
||||||
? `\n\n${renderSecondBrainTraceContext(secondBrainTrace)}`
|
? `\n\n${renderSecondBrainTraceContext(secondBrainTrace)}`
|
||||||
: '';
|
: '';
|
||||||
@@ -602,8 +625,17 @@ export class AgentExecutor {
|
|||||||
const casualCtx = isCasualConversation
|
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.'
|
? '\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.'
|
||||||
: '';
|
: '';
|
||||||
|
// Knowledge Mix policy: tells the model how strongly to lean on Second Brain
|
||||||
|
// evidence vs. its own general knowledge for this turn. Suppressed for casual
|
||||||
|
// chat — pure greetings don't need to be told anything about RAG balance.
|
||||||
|
const knowledgeMixCtx = (!isCasualConversation && this._lastKnowledgeMix)
|
||||||
|
? (() => {
|
||||||
|
const block = buildKnowledgeMixPolicy(this._lastKnowledgeMix);
|
||||||
|
return block ? `\n\n${block}` : '';
|
||||||
|
})()
|
||||||
|
: '';
|
||||||
// memoryCtx(RAG/메모리/lessons)는 [CONTEXT] 안에 — 토큰이 빡빡하면 대화 기록보다 먼저 잘림.
|
// memoryCtx(RAG/메모리/lessons)는 [CONTEXT] 안에 — 토큰이 빡빡하면 대화 기록보다 먼저 잘림.
|
||||||
fullSystemPrompt = `${systemPrompt}${internetCtx}${designerCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${casualCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
|
fullSystemPrompt = `${systemPrompt}${internetCtx}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
|
||||||
}
|
}
|
||||||
// ──────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────
|
||||||
// [Context Limit Manager] context length 는 "답변을 그만큼 길게 써도 된다"
|
// [Context Limit Manager] context length 는 "답변을 그만큼 길게 써도 된다"
|
||||||
@@ -1199,7 +1231,19 @@ export class AgentExecutor {
|
|||||||
const unaddressedChecklist = findUnaddressedChecklistItems(finalAssistantContent, this._lastLessonContents);
|
const unaddressedChecklist = findUnaddressedChecklistItems(finalAssistantContent, this._lastLessonContents);
|
||||||
this.webview.postMessage({
|
this.webview.postMessage({
|
||||||
type: 'usedScope',
|
type: 'usedScope',
|
||||||
value: { ...this._lastRetrievalInfo, hasAgentSelected: !!options.agentSkillFile, unaddressedChecklist },
|
value: {
|
||||||
|
...this._lastRetrievalInfo,
|
||||||
|
hasAgentSelected: !!options.agentSkillFile,
|
||||||
|
unaddressedChecklist,
|
||||||
|
// Knowledge Mix surfaced under the answer so the user can see what policy ran.
|
||||||
|
knowledgeMix: this._lastKnowledgeMix
|
||||||
|
? {
|
||||||
|
weight: this._lastKnowledgeMix.weight,
|
||||||
|
source: this._lastKnowledgeMix.source,
|
||||||
|
agent: this._lastKnowledgeMix.agent,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Progressive answering: the bubble was filled live with raw tokens
|
// Progressive answering: the bubble was filled live with raw tokens
|
||||||
@@ -2441,6 +2485,7 @@ export class AgentExecutor {
|
|||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
this._lastRetrievalInfo = null;
|
this._lastRetrievalInfo = null;
|
||||||
this._lastLessonContents = [];
|
this._lastLessonContents = [];
|
||||||
|
this._lastKnowledgeMix = null;
|
||||||
if (!config.memoryEnabled) return '';
|
if (!config.memoryEnabled) return '';
|
||||||
|
|
||||||
// Update memory manager config in case settings changed
|
// Update memory manager config in case settings changed
|
||||||
@@ -2497,6 +2542,15 @@ export class AgentExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the Knowledge Mix weight for this turn (per-agent → global → default).
|
||||||
|
// The weight scales how many brain files we feed the retriever and how big a
|
||||||
|
// slice of the context budget RAG can claim. At weight=50 the numbers below
|
||||||
|
// equal the legacy defaults, so users who never touch the slider see no change.
|
||||||
|
const knowledgeMix = resolveKnowledgeMix(agentSkillFile);
|
||||||
|
this._lastKnowledgeMix = knowledgeMix;
|
||||||
|
const mixedBrainFileLimit = mapWeightToBrainFileLimit(knowledgeMix.weight, config.memoryLongTermFiles);
|
||||||
|
const mixedRetrievalRatio = mapWeightToRetrievalRatio(knowledgeMix.weight);
|
||||||
|
|
||||||
// Use the Unified RAG Pipeline
|
// Use the Unified RAG Pipeline
|
||||||
const result = this.retrievalOrchestrator.retrieve(currentPrompt, {
|
const result = this.retrievalOrchestrator.retrieve(currentPrompt, {
|
||||||
brain: activeBrain,
|
brain: activeBrain,
|
||||||
@@ -2505,9 +2559,9 @@ export class AgentExecutor {
|
|||||||
chatHistory: visibleHistory,
|
chatHistory: visibleHistory,
|
||||||
contextBudget: {
|
contextBudget: {
|
||||||
totalBudget: scaledTotalBudget,
|
totalBudget: scaledTotalBudget,
|
||||||
retrievalRatio: 0.4
|
retrievalRatio: mixedRetrievalRatio,
|
||||||
},
|
},
|
||||||
brainFileLimit: config.memoryLongTermFiles,
|
brainFileLimit: mixedBrainFileLimit,
|
||||||
scopeFolders: scope.folders,
|
scopeFolders: scope.folders,
|
||||||
recentSessions,
|
recentSessions,
|
||||||
mediumTermLimit: config.memoryMediumTermSessions ?? 0,
|
mediumTermLimit: config.memoryMediumTermSessions ?? 0,
|
||||||
@@ -3196,7 +3250,13 @@ export class AgentExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (firstCreatedFile) {
|
if (firstCreatedFile) {
|
||||||
vscode.window.showTextDocument(vscode.Uri.file(firstCreatedFile), { preview: false });
|
// Always open file results in the editor group (column 2) — the ConnectAI
|
||||||
|
// sidebar lives in column 3 and we don't want freshly-written files to
|
||||||
|
// hijack the chat panel.
|
||||||
|
vscode.window.showTextDocument(vscode.Uri.file(firstCreatedFile), {
|
||||||
|
preview: false,
|
||||||
|
viewColumn: vscode.ViewColumn.Two,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Brain Sync Logic
|
// Brain Sync Logic
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ export interface IAgentConfig {
|
|||||||
* Default 0.5 = equal weight, a reasonable starting point.
|
* Default 0.5 = equal weight, a reasonable starting point.
|
||||||
*/
|
*/
|
||||||
embeddingBlendAlpha: number;
|
embeddingBlendAlpha: number;
|
||||||
|
/**
|
||||||
|
* Global Knowledge Mix weight (0–100). Controls how much the assistant leans on
|
||||||
|
* Second Brain evidence vs. model general knowledge when answering.
|
||||||
|
* 0 → Second Brain disabled; model knowledge only.
|
||||||
|
* 50 → Balanced (default).
|
||||||
|
* 100 → Second Brain is primary evidence; model knowledge only fills gaps.
|
||||||
|
* Per-agent overrides live in AgentKnowledgeEntry.secondBrainWeight and win.
|
||||||
|
*/
|
||||||
|
knowledgeMixSecondBrainWeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 경로 정규화 유틸리티 ───
|
// ─── 경로 정규화 유틸리티 ───
|
||||||
@@ -141,6 +150,9 @@ export function getConfig(): IAgentConfig {
|
|||||||
finalOnlyRetryOnThoughtLeak: cfg.get<boolean>('finalOnlyRetryOnThoughtLeak', true),
|
finalOnlyRetryOnThoughtLeak: cfg.get<boolean>('finalOnlyRetryOnThoughtLeak', true),
|
||||||
embeddingModel: (cfg.get<string>('embeddingModel', '') || '').trim(),
|
embeddingModel: (cfg.get<string>('embeddingModel', '') || '').trim(),
|
||||||
embeddingBlendAlpha: Math.max(0, Math.min(1, cfg.get<number>('embeddingBlendAlpha', 0.5))),
|
embeddingBlendAlpha: Math.max(0, Math.min(1, cfg.get<number>('embeddingBlendAlpha', 0.5))),
|
||||||
|
knowledgeMixSecondBrainWeight: Math.max(0, Math.min(100, Math.round(
|
||||||
|
cfg.get<number>('knowledgeMix.secondBrainWeight', 50)
|
||||||
|
))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-7
@@ -11,7 +11,8 @@ import {
|
|||||||
logError,
|
logError,
|
||||||
logInfo,
|
logInfo,
|
||||||
resolveEngine,
|
resolveEngine,
|
||||||
getActiveBrainProfile
|
getActiveBrainProfile,
|
||||||
|
openInEditorGroup
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import { getConfig, validateConfig } from './config';
|
import { getConfig, validateConfig } from './config';
|
||||||
import { AgentExecutor } from './agent';
|
import { AgentExecutor } from './agent';
|
||||||
@@ -431,6 +432,23 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
return createLessonCard(situation);
|
return createLessonCard(situation);
|
||||||
}),
|
}),
|
||||||
vscode.commands.registerCommand('g1nation.lesson.manage', () => manageLessons()),
|
vscode.commands.registerCommand('g1nation.lesson.manage', () => manageLessons()),
|
||||||
|
// ── Project Architecture commands (Feature 2) ─────────────────────────
|
||||||
|
// Thin shells that defer to the sidebar provider so all state mutations
|
||||||
|
// go through one code path (chip state, watcher lifecycle, etc.).
|
||||||
|
vscode.commands.registerCommand('g1nation.architecture.refresh', async () => {
|
||||||
|
if (!provider) return;
|
||||||
|
await provider._refreshArchitecture();
|
||||||
|
vscode.window.showInformationMessage('Astra: Project architecture context refreshed.');
|
||||||
|
}),
|
||||||
|
vscode.commands.registerCommand('g1nation.architecture.detach', async () => {
|
||||||
|
if (!provider) return;
|
||||||
|
await provider._detachArchitecture();
|
||||||
|
vscode.window.showInformationMessage('Astra: Project architecture auto-attach turned off.');
|
||||||
|
}),
|
||||||
|
vscode.commands.registerCommand('g1nation.architecture.open', async () => {
|
||||||
|
if (!provider) return;
|
||||||
|
await provider._openArchitectureDoc();
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind
|
/** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind
|
||||||
@@ -484,8 +502,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
vscode.window.showErrorMessage(`교훈 갱신 실패: ${e?.message ?? e}`);
|
vscode.window.showErrorMessage(`교훈 갱신 실패: ${e?.message ?? e}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const doc = await vscode.workspace.openTextDocument(existing.filePath);
|
await openInEditorGroup(existing.filePath);
|
||||||
await vscode.window.showTextDocument(doc);
|
|
||||||
vscode.window.showInformationMessage(`기존 교훈을 갱신했습니다 (occurrences: ${existing.occurrences + 1}). 필요하면 내용을 보강하세요.`);
|
vscode.window.showInformationMessage(`기존 교훈을 갱신했습니다 (occurrences: ${existing.occurrences + 1}). 필요하면 내용을 보강하세요.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -503,8 +520,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
vscode.window.showErrorMessage(`교훈 파일 생성 실패: ${e?.message ?? e}`);
|
vscode.window.showErrorMessage(`교훈 파일 생성 실패: ${e?.message ?? e}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const doc = await vscode.workspace.openTextDocument(filePath);
|
await openInEditorGroup(filePath);
|
||||||
await vscode.window.showTextDocument(doc);
|
|
||||||
vscode.window.showInformationMessage('교훈 카드를 만들었습니다. 내용을 채워 저장하면 다음 유사 작업 시 자동으로 체크리스트에 들어갑니다.');
|
vscode.window.showInformationMessage('교훈 카드를 만들었습니다. 내용을 채워 저장하면 다음 유사 작업 시 자동으로 체크리스트에 들어갑니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,8 +563,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
const sel = qp.selectedItems[0];
|
const sel = qp.selectedItems[0];
|
||||||
qp.hide();
|
qp.hide();
|
||||||
if (sel) {
|
if (sel) {
|
||||||
const doc = await vscode.workspace.openTextDocument(sel._file);
|
await openInEditorGroup(sel._file);
|
||||||
await vscode.window.showTextDocument(doc);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
qp.onDidHide(() => qp.dispose());
|
qp.onDidHide(() => qp.dispose());
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* Project Architecture Context (Feature 2)
|
||||||
|
*
|
||||||
|
* Builds a markdown document that captures the *durable* facts about a project
|
||||||
|
* — its purpose, modules, key files, constraints, decisions — so Astra can
|
||||||
|
* attach it to every prompt instead of re-discovering the project on each
|
||||||
|
* turn.
|
||||||
|
*
|
||||||
|
* Two-layer design so we get the best of both deterministic generation and
|
||||||
|
* user-curated knowledge:
|
||||||
|
*
|
||||||
|
* AUTO-MANAGED sections – regenerated on every refresh from static
|
||||||
|
* analysis (package.json, top-level tree, etc.).
|
||||||
|
* Bracketed by `<!-- ASTRA:AUTO-START --> …
|
||||||
|
* <!-- ASTRA:AUTO-END -->` markers so the file
|
||||||
|
* watcher can rewrite them without trampling
|
||||||
|
* anything the user wrote.
|
||||||
|
* USER-OWNED sections – created with TODO placeholders on first build,
|
||||||
|
* never overwritten thereafter. Users (or the
|
||||||
|
* assistant, when asked) fill in Purpose,
|
||||||
|
* Key Workflows, Constraints, Risks, Decisions.
|
||||||
|
*
|
||||||
|
* The generator is purely synchronous, never makes network calls, and never
|
||||||
|
* touches the model — by design. Refresh runs are cheap (single-digit ms on
|
||||||
|
* a project this size) so they can fire after every file change without
|
||||||
|
* starving the rest of the extension.
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { logError, logInfo } from '../../utils';
|
||||||
|
|
||||||
|
/** Sub-folder under the project root where the architecture doc lives. */
|
||||||
|
const ARCH_DIR_REL = path.join('.astra', 'project-context');
|
||||||
|
const ARCH_FILE = 'architecture.md';
|
||||||
|
|
||||||
|
/** Top-level directories we consider "code" worth listing under Main Modules. */
|
||||||
|
const CODE_DIRS = ['src', 'media', 'core_py', 'lib', 'app', 'apps', 'packages', 'tests'];
|
||||||
|
|
||||||
|
/** Files at the project root worth highlighting under "Important Files". */
|
||||||
|
const ROOT_IMPORTANT = [
|
||||||
|
'package.json', 'pnpm-workspace.yaml', 'tsconfig.json',
|
||||||
|
'README.md', 'CHANGELOG.md', 'ARCHITECTURE.md',
|
||||||
|
'pyproject.toml', 'requirements.txt', 'Cargo.toml', 'go.mod',
|
||||||
|
'Dockerfile', 'docker-compose.yml',
|
||||||
|
];
|
||||||
|
|
||||||
|
const AUTO_START = '<!-- ASTRA:AUTO-START -->';
|
||||||
|
const AUTO_END = '<!-- ASTRA:AUTO-END -->';
|
||||||
|
|
||||||
|
export interface ArchitectureScanResult {
|
||||||
|
projectName: string;
|
||||||
|
projectRoot: string;
|
||||||
|
description: string;
|
||||||
|
runtimes: string[]; // e.g. ["TypeScript", "Node", "VS Code Extension"]
|
||||||
|
mainModules: { dir: string; description: string }[];
|
||||||
|
importantFiles: string[]; // root-relative
|
||||||
|
/** Cheap hash of the scan inputs — used by the watcher to skip no-ops. */
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildResult {
|
||||||
|
/** Absolute path to the architecture markdown. */
|
||||||
|
docPath: string;
|
||||||
|
/** True if the file was newly created (vs. an in-place auto-block refresh). */
|
||||||
|
created: boolean;
|
||||||
|
/** Result of the scan that fed this build. */
|
||||||
|
scan: ArchitectureScanResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the architecture doc path for a given project root. */
|
||||||
|
export function architectureDocPathFor(projectRoot: string): string {
|
||||||
|
return path.join(projectRoot, ARCH_DIR_REL, ARCH_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a project root and return a structured summary. Pure, side-effect free
|
||||||
|
* (apart from reading the file system) so we can unit-test the signature/diff
|
||||||
|
* logic without writing any files.
|
||||||
|
*/
|
||||||
|
export function scanProject(projectRoot: string, projectName?: string): ArchitectureScanResult {
|
||||||
|
const safeRoot = projectRoot && fs.existsSync(projectRoot) ? projectRoot : '';
|
||||||
|
const name = (projectName?.trim()) || (safeRoot ? path.basename(safeRoot) : 'Unknown Project');
|
||||||
|
|
||||||
|
// ── package.json ─────────────────────────────────────────────────────────
|
||||||
|
let description = '';
|
||||||
|
let pkgJson: any = null;
|
||||||
|
const pkgPath = safeRoot ? path.join(safeRoot, 'package.json') : '';
|
||||||
|
if (pkgPath && fs.existsSync(pkgPath)) {
|
||||||
|
try {
|
||||||
|
pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||||
|
if (typeof pkgJson?.description === 'string') description = pkgJson.description.trim();
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('projectArchitecture: package.json parse failed.', { error: e?.message ?? String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Runtime / framework fingerprint ─────────────────────────────────────
|
||||||
|
const runtimes: string[] = [];
|
||||||
|
if (safeRoot && fs.existsSync(path.join(safeRoot, 'tsconfig.json'))) runtimes.push('TypeScript');
|
||||||
|
if (pkgJson) {
|
||||||
|
runtimes.push('Node.js');
|
||||||
|
const deps = { ...(pkgJson.dependencies || {}), ...(pkgJson.devDependencies || {}) } as Record<string, string>;
|
||||||
|
if (deps['@types/vscode'] || pkgJson.engines?.vscode) runtimes.push('VS Code Extension');
|
||||||
|
if (deps['react']) runtimes.push('React');
|
||||||
|
if (deps['next']) runtimes.push('Next.js');
|
||||||
|
if (deps['express'] || deps['fastify']) runtimes.push('HTTP server');
|
||||||
|
if (deps['@anthropic-ai/sdk']) runtimes.push('Anthropic SDK');
|
||||||
|
if (deps['openai']) runtimes.push('OpenAI SDK');
|
||||||
|
if (deps['@lmstudio/sdk']) runtimes.push('LM Studio SDK');
|
||||||
|
}
|
||||||
|
if (safeRoot && fs.existsSync(path.join(safeRoot, 'pyproject.toml'))) runtimes.push('Python');
|
||||||
|
if (safeRoot && fs.existsSync(path.join(safeRoot, 'Cargo.toml'))) runtimes.push('Rust');
|
||||||
|
if (safeRoot && fs.existsSync(path.join(safeRoot, 'go.mod'))) runtimes.push('Go');
|
||||||
|
|
||||||
|
// ── Main modules (top-level code directories) ───────────────────────────
|
||||||
|
const mainModules: ArchitectureScanResult['mainModules'] = [];
|
||||||
|
if (safeRoot) {
|
||||||
|
for (const candidate of CODE_DIRS) {
|
||||||
|
const dirAbs = path.join(safeRoot, candidate);
|
||||||
|
if (!_isDir(dirAbs)) continue;
|
||||||
|
const entries = _readDirSafe(dirAbs);
|
||||||
|
const fileCount = entries.filter((e) => _isFileLike(path.join(dirAbs, e))).length;
|
||||||
|
const subDirs = entries.filter((e) => _isDir(path.join(dirAbs, e)));
|
||||||
|
const desc = _describeModule(candidate, fileCount, subDirs);
|
||||||
|
mainModules.push({ dir: candidate, description: desc });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Important files at the root ─────────────────────────────────────────
|
||||||
|
const importantFiles: string[] = [];
|
||||||
|
if (safeRoot) {
|
||||||
|
for (const f of ROOT_IMPORTANT) {
|
||||||
|
if (fs.existsSync(path.join(safeRoot, f))) importantFiles.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signature: hash of the structural inputs only. We do NOT hash file
|
||||||
|
// *contents* — the goal is "did the shape of the project change" so the
|
||||||
|
// watcher doesn't re-render the doc for every keystroke in a TS file.
|
||||||
|
const signature = _hashSignature({
|
||||||
|
name,
|
||||||
|
runtimes,
|
||||||
|
mainModules: mainModules.map((m) => `${m.dir}|${m.description}`),
|
||||||
|
importantFiles,
|
||||||
|
pkgVersion: pkgJson?.version || '',
|
||||||
|
pkgDeps: pkgJson ? Object.keys({ ...(pkgJson.dependencies || {}), ...(pkgJson.devDependencies || {}) }).sort().join(',') : '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectName: name,
|
||||||
|
projectRoot: safeRoot,
|
||||||
|
description,
|
||||||
|
runtimes,
|
||||||
|
mainModules,
|
||||||
|
importantFiles,
|
||||||
|
signature,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _describeModule(dir: string, fileCount: number, subDirs: string[]): string {
|
||||||
|
const subSummary = subDirs.length > 0
|
||||||
|
? ` — ${subDirs.slice(0, 6).join(', ')}${subDirs.length > 6 ? `, +${subDirs.length - 6} more` : ''}`
|
||||||
|
: '';
|
||||||
|
const known: Record<string, string> = {
|
||||||
|
src: 'Source code',
|
||||||
|
media: 'Webview assets (HTML/CSS/JS)',
|
||||||
|
core_py: 'Python utilities',
|
||||||
|
tests: 'Test suite',
|
||||||
|
lib: 'Library code',
|
||||||
|
app: 'Application entry',
|
||||||
|
apps: 'Application bundles',
|
||||||
|
packages: 'Monorepo packages',
|
||||||
|
};
|
||||||
|
const label = known[dir] || 'Module';
|
||||||
|
return `${label} (${fileCount} files${subSummary})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isDir(p: string): boolean {
|
||||||
|
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
||||||
|
}
|
||||||
|
function _isFileLike(p: string): boolean {
|
||||||
|
try { return fs.statSync(p).isFile(); } catch { return false; }
|
||||||
|
}
|
||||||
|
function _readDirSafe(p: string): string[] {
|
||||||
|
try {
|
||||||
|
// Skip hidden + heavy noise dirs so the listing reads usefully.
|
||||||
|
return fs.readdirSync(p).filter((e) => !e.startsWith('.') && e !== 'node_modules' && e !== 'out' && e !== 'dist' && e !== '__pycache__');
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hashSignature(obj: unknown): string {
|
||||||
|
return crypto.createHash('sha1').update(JSON.stringify(obj)).digest('hex').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build or refresh the architecture doc. Idempotent:
|
||||||
|
* • If the file doesn't exist: scaffold full doc with auto + user-owned blocks.
|
||||||
|
* • If it exists: rewrite only the auto-managed block; preserve everything else.
|
||||||
|
*/
|
||||||
|
export function buildOrRefreshArchitectureDoc(
|
||||||
|
projectRoot: string,
|
||||||
|
projectName?: string,
|
||||||
|
nowIso: string = new Date().toISOString()
|
||||||
|
): BuildResult {
|
||||||
|
const scan = scanProject(projectRoot, projectName);
|
||||||
|
const docPath = architectureDocPathFor(projectRoot);
|
||||||
|
const docDir = path.dirname(docPath);
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(docDir, { recursive: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('projectArchitecture: mkdir failed.', { docDir, error: e?.message ?? String(e) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoBlock = _renderAutoBlock(scan, nowIso);
|
||||||
|
|
||||||
|
if (!fs.existsSync(docPath)) {
|
||||||
|
const full = _renderFullDoc(scan, autoBlock);
|
||||||
|
fs.writeFileSync(docPath, full, 'utf8');
|
||||||
|
logInfo('projectArchitecture: created.', { docPath, signature: scan.signature });
|
||||||
|
return { docPath, created: true, scan };
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-place refresh: rewrite the auto-managed block, keep user-owned sections.
|
||||||
|
const existing = fs.readFileSync(docPath, 'utf8');
|
||||||
|
const replaced = _replaceAutoBlock(existing, autoBlock);
|
||||||
|
if (replaced !== existing) {
|
||||||
|
fs.writeFileSync(docPath, replaced, 'utf8');
|
||||||
|
logInfo('projectArchitecture: refreshed.', { docPath, signature: scan.signature });
|
||||||
|
}
|
||||||
|
return { docPath, created: false, scan };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderAutoBlock(scan: ArchitectureScanResult, nowIso: string): string {
|
||||||
|
const modules = scan.mainModules.length > 0
|
||||||
|
? scan.mainModules.map((m) => `- \`${m.dir}/\` — ${m.description}`).join('\n')
|
||||||
|
: '_(no top-level code directories detected)_';
|
||||||
|
const importantFiles = scan.importantFiles.length > 0
|
||||||
|
? scan.importantFiles.map((f) => `- \`${f}\``).join('\n')
|
||||||
|
: '_(none detected)_';
|
||||||
|
const runtimes = scan.runtimes.length > 0 ? scan.runtimes.join(', ') : '_(unknown)_';
|
||||||
|
return [
|
||||||
|
AUTO_START,
|
||||||
|
'## Project Name',
|
||||||
|
scan.projectName,
|
||||||
|
'',
|
||||||
|
'## Project Root',
|
||||||
|
scan.projectRoot || '_(not set)_',
|
||||||
|
'',
|
||||||
|
'## Description',
|
||||||
|
scan.description || '_(no package.json description)_',
|
||||||
|
'',
|
||||||
|
'## Runtime / Stack',
|
||||||
|
runtimes,
|
||||||
|
'',
|
||||||
|
'## Main Modules',
|
||||||
|
modules,
|
||||||
|
'',
|
||||||
|
'## Important Files',
|
||||||
|
importantFiles,
|
||||||
|
'',
|
||||||
|
`_Last auto-scan: ${nowIso}_`,
|
||||||
|
AUTO_END,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderFullDoc(scan: ArchitectureScanResult, autoBlock: string): string {
|
||||||
|
// User-owned sections start as placeholders so first-time activation gives
|
||||||
|
// the user a clear "fill these in" surface without confusing the model.
|
||||||
|
return [
|
||||||
|
`# ${scan.projectName} — Project Architecture Context`,
|
||||||
|
'',
|
||||||
|
'> Auto-managed sections (between the AUTO markers) are rewritten by Astra on every refresh.',
|
||||||
|
'> The rest is yours — Astra never touches it once this file exists.',
|
||||||
|
'',
|
||||||
|
autoBlock,
|
||||||
|
'',
|
||||||
|
'## Purpose',
|
||||||
|
'_TODO: 이 프로젝트가 해결하려는 문제를 1–3문장으로._',
|
||||||
|
'',
|
||||||
|
'## Key Workflows',
|
||||||
|
'_TODO: 사용자/시스템의 주요 흐름 (예: 입력 → context assembly → model 호출 → action)._',
|
||||||
|
'',
|
||||||
|
'## Current Constraints',
|
||||||
|
'_TODO: 의도된 제약 (local-first, offline, 특정 API 의존 등)._',
|
||||||
|
'',
|
||||||
|
'## Known Risks',
|
||||||
|
'_TODO: 알려진 위험/디버깅 함정._',
|
||||||
|
'',
|
||||||
|
'## Active Decisions',
|
||||||
|
'_TODO: 살아 있는 ADR/원칙 (e.g. "기록은 markdown으로", "agent별 model override 우선")._',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _replaceAutoBlock(existing: string, autoBlock: string): string {
|
||||||
|
const startIdx = existing.indexOf(AUTO_START);
|
||||||
|
const endIdx = existing.indexOf(AUTO_END);
|
||||||
|
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
|
||||||
|
// No marker pair (likely an older file or hand-edited). Prepend the new
|
||||||
|
// auto block at the top so refreshes never silently lose the scan.
|
||||||
|
return `${autoBlock}\n\n${existing}`;
|
||||||
|
}
|
||||||
|
const before = existing.slice(0, startIdx);
|
||||||
|
const after = existing.slice(endIdx + AUTO_END.length);
|
||||||
|
return `${before}${autoBlock}${after}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the architecture doc, returning the trimmed content suitable for
|
||||||
|
* injection into a prompt. Returns empty string if the file can't be read.
|
||||||
|
*
|
||||||
|
* Truncation strategy: try to keep the most decision-relevant sections —
|
||||||
|
* Purpose, Main Modules, Key Workflows, Current Constraints, Known Risks,
|
||||||
|
* Active Decisions — and drop the long auto-listing of files first.
|
||||||
|
*/
|
||||||
|
export function readArchitectureForPrompt(docPath: string, maxChars: number = 8000): string {
|
||||||
|
if (!docPath || !fs.existsSync(docPath)) return '';
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(docPath, 'utf8');
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('projectArchitecture: read failed.', { docPath, error: e?.message ?? String(e) });
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (raw.length <= maxChars) return raw;
|
||||||
|
|
||||||
|
// Section-aware trim: parse `## ` headers, prioritise the high-signal
|
||||||
|
// sections, drop the rest until we fit. Important Files is the longest
|
||||||
|
// auto section so it gets dropped first.
|
||||||
|
const sections = _splitSections(raw);
|
||||||
|
const priority = [
|
||||||
|
'Purpose',
|
||||||
|
'Project Name',
|
||||||
|
'Description',
|
||||||
|
'Active Decisions',
|
||||||
|
'Current Constraints',
|
||||||
|
'Known Risks',
|
||||||
|
'Key Workflows',
|
||||||
|
'Main Modules',
|
||||||
|
'Runtime / Stack',
|
||||||
|
'Project Root',
|
||||||
|
'Important Files', // drop first
|
||||||
|
];
|
||||||
|
sections.sort((a, b) => {
|
||||||
|
const ai = priority.indexOf(a.title); const bi = priority.indexOf(b.title);
|
||||||
|
const aw = ai === -1 ? 999 : ai;
|
||||||
|
const bw = bi === -1 ? 999 : bi;
|
||||||
|
return aw - bw;
|
||||||
|
});
|
||||||
|
const out: string[] = [sections.find((s) => s.title === '__HEADER__')?.body || ''];
|
||||||
|
let used = out[0].length;
|
||||||
|
for (const sec of sections) {
|
||||||
|
if (sec.title === '__HEADER__') continue;
|
||||||
|
const block = `\n\n## ${sec.title}\n${sec.body}`;
|
||||||
|
if (used + block.length > maxChars) continue;
|
||||||
|
out.push(block);
|
||||||
|
used += block.length;
|
||||||
|
}
|
||||||
|
const trimmed = out.join('');
|
||||||
|
return trimmed.length < raw.length
|
||||||
|
? `${trimmed}\n\n_(architecture doc truncated to fit context budget)_`
|
||||||
|
: trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _splitSections(raw: string): { title: string; body: string }[] {
|
||||||
|
const lines = raw.split('\n');
|
||||||
|
const sections: { title: string; body: string }[] = [];
|
||||||
|
let currentTitle = '__HEADER__';
|
||||||
|
let currentBody: string[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = /^##\s+(.+)$/.exec(line);
|
||||||
|
if (m) {
|
||||||
|
sections.push({ title: currentTitle, body: currentBody.join('\n').trim() });
|
||||||
|
currentTitle = m[1].trim();
|
||||||
|
currentBody = [];
|
||||||
|
} else {
|
||||||
|
currentBody.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sections.push({ title: currentTitle, body: currentBody.join('\n').trim() });
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the doc content for injection into the system prompt. Includes a
|
||||||
|
* minimal preamble so the model knows what the block is and treats it as
|
||||||
|
* authoritative project ground truth (not just background reading).
|
||||||
|
*/
|
||||||
|
export function formatArchitectureContextForPrompt(opts: {
|
||||||
|
projectName: string;
|
||||||
|
docPath: string;
|
||||||
|
lastUpdated?: string;
|
||||||
|
maxChars?: number;
|
||||||
|
}): string {
|
||||||
|
const content = readArchitectureForPrompt(opts.docPath, opts.maxChars ?? 8000);
|
||||||
|
if (!content) return '';
|
||||||
|
const stamp = opts.lastUpdated ? `\nLast updated: ${opts.lastUpdated}` : '';
|
||||||
|
return [
|
||||||
|
'[ACTIVE PROJECT ARCHITECTURE CONTEXT]',
|
||||||
|
`Source: ${opts.docPath}`,
|
||||||
|
`Project: ${opts.projectName}${stamp}`,
|
||||||
|
'Use this as authoritative ground truth about the project structure, constraints, and active decisions. Do not contradict it without flagging the conflict.',
|
||||||
|
'---',
|
||||||
|
content,
|
||||||
|
'---',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Project-intent detection from a chat message.
|
||||||
|
*
|
||||||
|
* Goal: when the user says "나 ConnectAI 프로젝트 진행할 거야" (or similar),
|
||||||
|
* spot the intent + project handle so the sidebar can activate Project Mode
|
||||||
|
* and auto-attach the architecture doc.
|
||||||
|
*
|
||||||
|
* Design philosophy:
|
||||||
|
* - Heuristic only. No LLM call. This is a routing decision, not a chat
|
||||||
|
* reply — false positives are cheap to correct (a chip the user can detach)
|
||||||
|
* but a 200 ms latency on every message would be unacceptable.
|
||||||
|
* - Multi-modal: pick up either an absolute project path OR a project NAME
|
||||||
|
* that matches an already-registered project. We avoid inventing brand
|
||||||
|
* new projects from arbitrary noun extraction — that produces too much
|
||||||
|
* noise.
|
||||||
|
* - Bilingual: Korean phrasing is the primary surface, English second.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A registered project the detector can match a name against. */
|
||||||
|
export interface KnownProject {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
/** Optional aliases (lowercased) the user might say instead of the name. */
|
||||||
|
aliases?: string[];
|
||||||
|
projectRoot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetectionResult {
|
||||||
|
/** Project entry the message refers to. */
|
||||||
|
project: KnownProject;
|
||||||
|
/** How we matched it — surfaced in logs so we can tune the regexes. */
|
||||||
|
via: 'path' | 'name' | 'alias';
|
||||||
|
/** The text fragment that triggered the match (for debugging). */
|
||||||
|
matchedText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Korean activation verbs that strongly imply "start / continue working on X".
|
||||||
|
// We keep this small and high-precision rather than trying to enumerate every
|
||||||
|
// phrasing — a missed match just means the user has to click the activate chip.
|
||||||
|
const KO_INTENT_PATTERNS: RegExp[] = [
|
||||||
|
/(?:나|이제|오늘은|이번엔)?\s*([\p{L}\p{N}_\-\.]+)\s*(?:프로젝트)?\s*(?:진행|작업|시작|할\s*거야|볼\s*거야|볼게|하자|시작하자)/u,
|
||||||
|
/([\p{L}\p{N}_\-\.]+)\s*(?:프로젝트)\s*(?:열어|열자|확인)/u,
|
||||||
|
];
|
||||||
|
|
||||||
|
// English equivalents — same precision-first stance.
|
||||||
|
const EN_INTENT_PATTERNS: RegExp[] = [
|
||||||
|
/\b(?:i'?m\s+working\s+on|let'?s\s+work\s+on|switching\s+to|i\s+want\s+to\s+work\s+on)\s+(?:the\s+)?([\w\-\.]+)\s*(?:project)?/i,
|
||||||
|
/\bopen\s+(?:the\s+)?([\w\-\.]+)\s+project\b/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const STOPWORDS = new Set([
|
||||||
|
// High-frequency Korean particles/verbs that show up where the regex
|
||||||
|
// greedily captures a "noun" but shouldn't be treated as a project handle.
|
||||||
|
'나', '이제', '오늘은', '이번엔', '나도', '나는',
|
||||||
|
// English filler.
|
||||||
|
'the', 'this', 'that', 'my', 'your', 'a',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to detect a project handle in `text` and resolve it against the list of
|
||||||
|
* `known` projects. Returns `null` when no high-confidence match is found.
|
||||||
|
*/
|
||||||
|
export function detectProjectIntent(text: string, known: KnownProject[]): DetectionResult | null {
|
||||||
|
const trimmed = (text || '').trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
// 1) Direct absolute path (highest confidence). We also accept ~-prefixed
|
||||||
|
// paths because the chat history is full of them. The path doesn't have
|
||||||
|
// to match an existing project — sidebarProvider handles ephemeral
|
||||||
|
// project creation when needed.
|
||||||
|
const pathMatch = _matchPath(trimmed);
|
||||||
|
if (pathMatch) {
|
||||||
|
const exact = known.find((k) => k.projectRoot && _samePath(k.projectRoot, pathMatch));
|
||||||
|
if (exact) return { project: exact, via: 'path', matchedText: pathMatch };
|
||||||
|
// Synthesise an ephemeral entry — caller decides whether to materialise it.
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
projectId: _slugify(pathMatch.split(/[\\/]/).filter(Boolean).pop() || pathMatch),
|
||||||
|
projectName: pathMatch.split(/[\\/]/).filter(Boolean).pop() || pathMatch,
|
||||||
|
projectRoot: pathMatch,
|
||||||
|
},
|
||||||
|
via: 'path',
|
||||||
|
matchedText: pathMatch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Phrase-based extraction → match against known project names/aliases.
|
||||||
|
// We require an intent pattern AND a known-name match: this rules out
|
||||||
|
// "나는 글을 쓸 거야" → it has the verb but no project handle.
|
||||||
|
const candidates: string[] = [];
|
||||||
|
for (const re of KO_INTENT_PATTERNS) {
|
||||||
|
const m = re.exec(trimmed);
|
||||||
|
if (m && m[1] && !STOPWORDS.has(m[1].toLowerCase())) candidates.push(m[1]);
|
||||||
|
}
|
||||||
|
for (const re of EN_INTENT_PATTERNS) {
|
||||||
|
const m = re.exec(trimmed);
|
||||||
|
if (m && m[1] && !STOPWORDS.has(m[1].toLowerCase())) candidates.push(m[1]);
|
||||||
|
}
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const hit = _findKnown(known, candidate);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _matchPath(text: string): string | null {
|
||||||
|
// Absolute POSIX path (including macOS volumes) or Windows drive path.
|
||||||
|
// We're permissive on what characters can appear — anything quoted or
|
||||||
|
// surrounded by whitespace counts.
|
||||||
|
const macVol = /\/Volumes\/[^\s`'"<>]+/;
|
||||||
|
const posix = /(?:^|\s)(\/[^\s`'"<>]+)/;
|
||||||
|
const win = /[A-Za-z]:[\\/][^\s`'"<>]+/;
|
||||||
|
return (text.match(macVol) || [])[0]
|
||||||
|
|| (text.match(win) || [])[0]
|
||||||
|
|| ((): string | null => {
|
||||||
|
const m = posix.exec(text);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _samePath(a: string, b: string): boolean {
|
||||||
|
return a.replace(/[\\/]+$/, '').toLowerCase() === b.replace(/[\\/]+$/, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _findKnown(known: KnownProject[], handle: string): DetectionResult | null {
|
||||||
|
const needle = _slugify(handle);
|
||||||
|
if (!needle) return null;
|
||||||
|
for (const k of known) {
|
||||||
|
if (_slugify(k.projectName) === needle) {
|
||||||
|
return { project: k, via: 'name', matchedText: handle };
|
||||||
|
}
|
||||||
|
for (const alias of k.aliases ?? []) {
|
||||||
|
if (_slugify(alias) === needle) {
|
||||||
|
return { project: k, via: 'alias', matchedText: handle };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same slug logic the chronicle module uses — lowercase, non-word→hyphen. */
|
||||||
|
function _slugify(s: string): string {
|
||||||
|
return (s || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9가-힣]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 60);
|
||||||
|
}
|
||||||
@@ -12,6 +12,20 @@ export interface ProjectProfile {
|
|||||||
detailLevel: ChronicleDetailLevel;
|
detailLevel: ChronicleDetailLevel;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
// ── Project Architecture Context (Feature 2) ───────────────────────────────
|
||||||
|
/** Absolute path to the auto-generated architecture markdown. */
|
||||||
|
architectureDocPath?: string;
|
||||||
|
/** When true, the architecture doc is auto-attached to every prompt. */
|
||||||
|
architectureAutoAttach?: boolean;
|
||||||
|
/** When true, file changes under projectRoot trigger a debounced refresh. */
|
||||||
|
architectureAutoUpdate?: boolean;
|
||||||
|
/** ISO timestamp of the last (auto or manual) refresh. */
|
||||||
|
architectureLastUpdated?: string;
|
||||||
|
/**
|
||||||
|
* Cheap hash of the inputs used by the last scan (package.json + top-level tree).
|
||||||
|
* Used by the file watcher to skip no-op regenerations.
|
||||||
|
*/
|
||||||
|
architectureLastScanSignature?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuestionRecord {
|
export interface QuestionRecord {
|
||||||
|
|||||||
+22
-14
@@ -98,23 +98,31 @@ export class RetrievalOrchestrator {
|
|||||||
fusionLog.push(`Expanded tokens: [${expandedTokens.slice(0, 15).join(', ')}]`);
|
fusionLog.push(`Expanded tokens: [${expandedTokens.slice(0, 15).join(', ')}]`);
|
||||||
|
|
||||||
// ── ① Brain File Search (TF-IDF enhanced, optionally hybrid with embeddings) ──
|
// ── ① Brain File Search (TF-IDF enhanced, optionally hybrid with embeddings) ──
|
||||||
|
// `brainFileLimit === 0` is meaningful (Knowledge Mix "model knowledge only"
|
||||||
|
// mode), so use `??` rather than `||`. When the caller explicitly passes 0,
|
||||||
|
// we skip retrieval entirely instead of falling back to the default of 8.
|
||||||
const scopeFolders = options.scopeFolders ?? [];
|
const scopeFolders = options.scopeFolders ?? [];
|
||||||
const brainChunks = this.searchBrainFiles(
|
const brainFileLimit = options.brainFileLimit ?? 8;
|
||||||
query,
|
const brainChunks = brainFileLimit > 0
|
||||||
expandedTokens,
|
? this.searchBrainFiles(
|
||||||
options.brain,
|
query,
|
||||||
options.brainFileLimit || 8,
|
expandedTokens,
|
||||||
options.includeRawConversations || false,
|
options.brain,
|
||||||
scopeFolders,
|
brainFileLimit,
|
||||||
options.queryEmbedding,
|
options.includeRawConversations || false,
|
||||||
options.embeddingModel,
|
scopeFolders,
|
||||||
options.embeddingBlendAlpha
|
options.queryEmbedding,
|
||||||
);
|
options.embeddingModel,
|
||||||
|
options.embeddingBlendAlpha
|
||||||
|
)
|
||||||
|
: [];
|
||||||
allChunks.push(...brainChunks);
|
allChunks.push(...brainChunks);
|
||||||
fusionLog.push(
|
fusionLog.push(
|
||||||
scopeFolders.length > 0
|
brainFileLimit === 0
|
||||||
? `Brain search (scoped to ${scopeFolders.length} folder(s)): ${brainChunks.length} chunks`
|
? 'Brain search: skipped (Knowledge Mix weight = 0)'
|
||||||
: `Brain search: ${brainChunks.length} chunks found`
|
: scopeFolders.length > 0
|
||||||
|
? `Brain search (scoped to ${scopeFolders.length} folder(s)): ${brainChunks.length} chunks`
|
||||||
|
: `Brain search: ${brainChunks.length} chunks found`
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── ② Memory Layers ──
|
// ── ② Memory Layers ──
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Knowledge Mix — controls how much the assistant leans on Second Brain
|
||||||
|
* evidence vs. the model's own general knowledge for a given query.
|
||||||
|
*
|
||||||
|
* The single integer "secondBrainWeight" (0–100) drives three things:
|
||||||
|
*
|
||||||
|
* 1. RAG chunk budget — how many brain files we feed the model.
|
||||||
|
* 2. Retrieval ratio — what fraction of the context budget RAG can claim.
|
||||||
|
* 3. Prompt policy — natural-language instruction injected into the
|
||||||
|
* system prompt telling the model how to balance
|
||||||
|
* its own knowledge against the evidence shown.
|
||||||
|
*
|
||||||
|
* Per-agent overrides (AgentKnowledgeEntry.secondBrainWeight) win over the
|
||||||
|
* global config (g1nation.knowledgeMix.secondBrainWeight). Both fall back to
|
||||||
|
* the default `DEFAULT_WEIGHT` (balanced) when nothing is set.
|
||||||
|
*
|
||||||
|
* Keeping this module isolated and pure makes it trivial to unit-test the
|
||||||
|
* mapping curve and to extend it later (e.g. add a "creative" axis) without
|
||||||
|
* touching retrieval or prompt assembly.
|
||||||
|
*/
|
||||||
|
import { getConfig } from '../config';
|
||||||
|
import { getOrCreateAgentEntry } from '../skills/agentKnowledgeMap';
|
||||||
|
|
||||||
|
export const DEFAULT_WEIGHT = 50;
|
||||||
|
|
||||||
|
/** Where the resolved weight came from — surfaced in `_lastRetrievalInfo` for UX. */
|
||||||
|
export type KnowledgeMixSource = 'agent' | 'global' | 'default';
|
||||||
|
|
||||||
|
export interface ResolvedKnowledgeMix {
|
||||||
|
/** Integer in [0, 100]. */
|
||||||
|
weight: number;
|
||||||
|
source: KnowledgeMixSource;
|
||||||
|
/** Agent name when `source === 'agent'`, else undefined. */
|
||||||
|
agent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective weight for the active turn.
|
||||||
|
*
|
||||||
|
* Precedence: per-agent → global config → default. Out-of-range values from
|
||||||
|
* either source are clamped, never silently zeroed (a typo in JSON should not
|
||||||
|
* disable retrieval entirely).
|
||||||
|
*/
|
||||||
|
export function resolveKnowledgeMix(agentFileOrName?: string): ResolvedKnowledgeMix {
|
||||||
|
if (agentFileOrName && agentFileOrName !== 'none') {
|
||||||
|
try {
|
||||||
|
const entry = getOrCreateAgentEntry(agentFileOrName);
|
||||||
|
if (typeof entry.secondBrainWeight === 'number' && Number.isFinite(entry.secondBrainWeight)) {
|
||||||
|
return {
|
||||||
|
weight: _clamp(entry.secondBrainWeight),
|
||||||
|
source: 'agent',
|
||||||
|
agent: entry.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Map missing or unreadable — fall through to global.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cfg = getConfig();
|
||||||
|
if (typeof cfg.knowledgeMixSecondBrainWeight === 'number') {
|
||||||
|
return { weight: _clamp(cfg.knowledgeMixSecondBrainWeight), source: 'global' };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// getConfig should never throw in practice, but keep this safe in tests.
|
||||||
|
}
|
||||||
|
return { weight: DEFAULT_WEIGHT, source: 'default' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clamp(n: number): number {
|
||||||
|
if (!Number.isFinite(n)) return DEFAULT_WEIGHT;
|
||||||
|
return Math.max(0, Math.min(100, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a weight to the maximum number of brain files (long-term memory) the
|
||||||
|
* retriever is allowed to consider for this turn.
|
||||||
|
*
|
||||||
|
* Curve was chosen so that:
|
||||||
|
* - 0 fully disables brain-file retrieval (model-only mode).
|
||||||
|
* - 50 maps to roughly the existing default (`memoryLongTermFiles`), so
|
||||||
|
* behaviour without any per-agent setting matches the status quo.
|
||||||
|
* - 100 pushes the cap toward `selectWithinBudget`'s hard ceiling (12).
|
||||||
|
*
|
||||||
|
* The configured `memoryLongTermFiles` is treated as the "balanced" baseline:
|
||||||
|
* it's scaled up at high weights and damped at low weights.
|
||||||
|
*/
|
||||||
|
export function mapWeightToBrainFileLimit(weight: number, configuredLimit: number): number {
|
||||||
|
const w = _clamp(weight);
|
||||||
|
if (w === 0) return 0;
|
||||||
|
const baseline = Math.max(1, configuredLimit || 6);
|
||||||
|
// Linear interpolation:
|
||||||
|
// w=0 → 0
|
||||||
|
// w=25 → baseline * 0.5
|
||||||
|
// w=50 → baseline
|
||||||
|
// w=75 → baseline * 1.5
|
||||||
|
// w=100 → baseline * 2 (capped at 12 elsewhere)
|
||||||
|
const scaled = Math.round((w / 50) * baseline);
|
||||||
|
// Honour the orchestrator's hard cap (12) so we never blow the budget.
|
||||||
|
return Math.max(0, Math.min(12, scaled));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a weight to the retrieval ratio (fraction of the context-budget that
|
||||||
|
* RAG can claim). Lower weights mean RAG gets a smaller slice and leaves more
|
||||||
|
* room for conversation history / system prompt.
|
||||||
|
*/
|
||||||
|
export function mapWeightToRetrievalRatio(weight: number): number {
|
||||||
|
const w = _clamp(weight);
|
||||||
|
// 0 → 0.05 (still room for tiny lessons block), 50 → 0.40 (status quo), 100 → 0.60.
|
||||||
|
return Math.max(0.05, Math.min(0.6, 0.05 + (w / 100) * 0.55));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the natural-language policy block injected into the system prompt.
|
||||||
|
* Returns `''` when the weight is exactly the default (50) — at the midpoint
|
||||||
|
* there's nothing useful to tell the model, and quieter prompts behave better.
|
||||||
|
*/
|
||||||
|
export function buildKnowledgeMixPolicy(mix: ResolvedKnowledgeMix): string {
|
||||||
|
const w = _clamp(mix.weight);
|
||||||
|
if (w === DEFAULT_WEIGHT) return '';
|
||||||
|
const header = `[KNOWLEDGE MIX POLICY]\nSecond Brain reliance: ${w}% (model knowledge: ${100 - w}%).`;
|
||||||
|
let body: string;
|
||||||
|
if (w === 0) {
|
||||||
|
body = [
|
||||||
|
'Second Brain retrieval is disabled for this turn.',
|
||||||
|
'Answer from your general knowledge and the conversation history alone.',
|
||||||
|
'Do not invent file citations.',
|
||||||
|
].join('\n');
|
||||||
|
} else if (w < 25) {
|
||||||
|
body = [
|
||||||
|
'Rely primarily on your own general knowledge.',
|
||||||
|
'Treat any Second Brain notes shown below as light reference material only.',
|
||||||
|
'Brainstorming, broad explanations and creative synthesis are encouraged.',
|
||||||
|
].join('\n');
|
||||||
|
} else if (w < 50) {
|
||||||
|
body = [
|
||||||
|
'Lean on your general knowledge; use Second Brain notes as supporting context.',
|
||||||
|
'Cite Brain files only when they materially shape the answer.',
|
||||||
|
].join('\n');
|
||||||
|
} else if (w < 75) {
|
||||||
|
body = [
|
||||||
|
'Prefer Second Brain evidence when it is present.',
|
||||||
|
'Use your general knowledge to connect, explain, and fill harmless background.',
|
||||||
|
'Do not override explicit Second Brain evidence with model assumptions.',
|
||||||
|
].join('\n');
|
||||||
|
} else if (w < 100) {
|
||||||
|
body = [
|
||||||
|
'Treat Second Brain notes as the primary evidence for this answer.',
|
||||||
|
'Cite Brain files for any non-trivial claim and quote relevant lines when needed.',
|
||||||
|
'If the notes do not cover a point, say so explicitly instead of guessing.',
|
||||||
|
].join('\n');
|
||||||
|
} else {
|
||||||
|
body = [
|
||||||
|
'Second Brain notes are the only authoritative source for this answer.',
|
||||||
|
'Cite Brain files for every substantive claim.',
|
||||||
|
'If a point is not in the notes, reply that it is outside the recorded knowledge — do not fall back to general knowledge.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
return `${header}\n${body}`;
|
||||||
|
}
|
||||||
@@ -66,13 +66,21 @@ export async function handleAgentMessage(provider: SidebarChatProvider, data: an
|
|||||||
if (!view) return true;
|
if (!view) return true;
|
||||||
try {
|
try {
|
||||||
const entry = getOrCreateAgentEntry(data.agentPath || '');
|
const entry = getOrCreateAgentEntry(data.agentPath || '');
|
||||||
const knowledgeMapHasEntry = entry.knowledgeFolders.length > 0 || (entry.skillFolders?.length ?? 0) > 0;
|
const hasWeightOverride = typeof entry.secondBrainWeight === 'number';
|
||||||
|
const knowledgeMapHasEntry = entry.knowledgeFolders.length > 0
|
||||||
|
|| (entry.skillFolders?.length ?? 0) > 0
|
||||||
|
|| !!(entry.model && entry.model.trim())
|
||||||
|
|| hasWeightOverride;
|
||||||
view.webview.postMessage({
|
view.webview.postMessage({
|
||||||
type: 'agentMapData',
|
type: 'agentMapData',
|
||||||
value: {
|
value: {
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
knowledgeFolders: entry.knowledgeFolders,
|
knowledgeFolders: entry.knowledgeFolders,
|
||||||
skillFolders: entry.skillFolders || [],
|
skillFolders: entry.skillFolders || [],
|
||||||
|
// Per-agent model override — empty string means "use current default model".
|
||||||
|
model: entry.model || '',
|
||||||
|
// null = no override (fall back to global slider); number = pinned 0–100.
|
||||||
|
secondBrainWeight: hasWeightOverride ? entry.secondBrainWeight : null,
|
||||||
exists: knowledgeMapHasEntry,
|
exists: knowledgeMapHasEntry,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -80,7 +88,7 @@ export async function handleAgentMessage(provider: SidebarChatProvider, data: an
|
|||||||
logError('agent-map: load failed.', { error: e?.message ?? String(e) });
|
logError('agent-map: load failed.', { error: e?.message ?? String(e) });
|
||||||
view.webview.postMessage({
|
view.webview.postMessage({
|
||||||
type: 'agentMapData',
|
type: 'agentMapData',
|
||||||
value: { name: '', knowledgeFolders: [], skillFolders: [], exists: false },
|
value: { name: '', knowledgeFolders: [], skillFolders: [], model: '', secondBrainWeight: null, exists: false },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -96,10 +104,20 @@ export async function handleAgentMessage(provider: SidebarChatProvider, data: an
|
|||||||
const skillFolders = Array.isArray(data.skillFolders)
|
const skillFolders = Array.isArray(data.skillFolders)
|
||||||
? data.skillFolders.filter((f: unknown) => typeof f === 'string')
|
? data.skillFolders.filter((f: unknown) => typeof f === 'string')
|
||||||
: [];
|
: [];
|
||||||
|
// Treat blank / "Use current model" as no override — drop the field entirely
|
||||||
|
// so the JSON stays clean and the resolver falls back to the global default.
|
||||||
|
const modelOverride = typeof data.model === 'string' ? data.model.trim() : '';
|
||||||
|
// null / undefined / non-finite = "Use global setting" → drop the field.
|
||||||
|
let weightOverride: number | undefined;
|
||||||
|
if (typeof data.secondBrainWeight === 'number' && Number.isFinite(data.secondBrainWeight)) {
|
||||||
|
weightOverride = Math.max(0, Math.min(100, Math.round(data.secondBrainWeight)));
|
||||||
|
}
|
||||||
const result = upsertAgentEntry({
|
const result = upsertAgentEntry({
|
||||||
name,
|
name,
|
||||||
knowledgeFolders,
|
knowledgeFolders,
|
||||||
skillFolders,
|
skillFolders,
|
||||||
|
model: modelOverride || undefined,
|
||||||
|
secondBrainWeight: weightOverride,
|
||||||
});
|
});
|
||||||
view.webview.postMessage({
|
view.webview.postMessage({
|
||||||
type: 'agentMapSaved',
|
type: 'agentMapSaved',
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
|||||||
await provider._sendChronicleProjects();
|
await provider._sendChronicleProjects();
|
||||||
await provider._restoreActiveSessionIntoView();
|
await provider._restoreActiveSessionIntoView();
|
||||||
await provider._sendReadyStatus();
|
await provider._sendReadyStatus();
|
||||||
|
// Restore the Project Architecture chip + watcher if the active project
|
||||||
|
// was already running in architecture mode in a previous VS Code session.
|
||||||
|
await provider._sendArchitectureStatus();
|
||||||
return true;
|
return true;
|
||||||
case 'getReadyStatus':
|
case 'getReadyStatus':
|
||||||
await provider._sendReadyStatus();
|
await provider._sendReadyStatus();
|
||||||
@@ -100,6 +103,50 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
|||||||
provider._lmStudio?.lifecycle.onModelSelected(data.value);
|
provider._lmStudio?.lifecycle.onModelSelected(data.value);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
case 'getKnowledgeMix': {
|
||||||
|
// Ship the current global Knowledge Mix to the webview so the slider can
|
||||||
|
// initialize. Per-agent overrides ride along with the agent map data.
|
||||||
|
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||||
|
const w = cfg.get<number>('knowledgeMix.secondBrainWeight', 50);
|
||||||
|
const clamped = Math.max(0, Math.min(100, Math.round(typeof w === 'number' ? w : 50)));
|
||||||
|
provider._view?.webview.postMessage({
|
||||||
|
type: 'knowledgeMix',
|
||||||
|
value: { weight: clamped, source: 'global' },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'setKnowledgeMix': {
|
||||||
|
const raw = typeof data.value === 'number' ? data.value : NaN;
|
||||||
|
if (!Number.isFinite(raw)) return true;
|
||||||
|
const clamped = Math.max(0, Math.min(100, Math.round(raw)));
|
||||||
|
// Use whichever scope already holds the value to avoid the same "Workspace
|
||||||
|
// override shadows Global update" desync that the `model` case guards against.
|
||||||
|
const { target } = pickConfigTarget('g1nation', 'knowledgeMix.secondBrainWeight');
|
||||||
|
await vscode.workspace.getConfiguration('g1nation').update('knowledgeMix.secondBrainWeight', clamped, target);
|
||||||
|
logInfo(`Knowledge Mix (global) updated to: ${clamped}`, { target });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// ── Project Architecture (Feature 2) ──────────────────────────────────
|
||||||
|
case 'getArchitectureStatus':
|
||||||
|
await provider._sendArchitectureStatus();
|
||||||
|
return true;
|
||||||
|
case 'openArchitectureDoc':
|
||||||
|
await provider._openArchitectureDoc();
|
||||||
|
return true;
|
||||||
|
case 'refreshArchitecture':
|
||||||
|
await provider._refreshArchitecture();
|
||||||
|
return true;
|
||||||
|
case 'detachArchitecture':
|
||||||
|
await provider._detachArchitecture();
|
||||||
|
return true;
|
||||||
|
case 'activateArchitectureFromText': {
|
||||||
|
// Optional explicit-toggle path: webview can pass arbitrary text
|
||||||
|
// (e.g. the current input draft) for one-shot intent detection.
|
||||||
|
if (typeof data.text === 'string') {
|
||||||
|
await provider._tryActivateArchitectureFromText(data.text);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
case 'proactiveTrigger':
|
case 'proactiveTrigger':
|
||||||
await provider._handleProactiveSuggestion(data.context);
|
await provider._handleProactiveSuggestion(data.context);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+323
-6
@@ -11,7 +11,8 @@ import {
|
|||||||
logError,
|
logError,
|
||||||
logInfo,
|
logInfo,
|
||||||
resolveEngine,
|
resolveEngine,
|
||||||
summarizeText
|
summarizeText,
|
||||||
|
openInEditorGroup
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import { getConfig } from './config';
|
import { getConfig } from './config';
|
||||||
import { AgentExecutor, ChatMessage } from './agent';
|
import { AgentExecutor, ChatMessage } from './agent';
|
||||||
@@ -26,6 +27,13 @@ import { handleAgentMessage } from './sidebar/agentHandlers';
|
|||||||
import { getOrCreateAgentEntry, resolveScopeForAgent } from './skills/agentKnowledgeMap';
|
import { getOrCreateAgentEntry, resolveScopeForAgent } from './skills/agentKnowledgeMap';
|
||||||
import { estimateModelParamsB } from './lib/contextManager';
|
import { estimateModelParamsB } from './lib/contextManager';
|
||||||
import { loadExternalSkills, formatSkillsAsPromptBlock } from './skills/externalSkillLoader';
|
import { loadExternalSkills, formatSkillsAsPromptBlock } from './skills/externalSkillLoader';
|
||||||
|
import {
|
||||||
|
buildOrRefreshArchitectureDoc,
|
||||||
|
architectureDocPathFor,
|
||||||
|
formatArchitectureContextForPrompt,
|
||||||
|
scanProject,
|
||||||
|
} from './features/projectArchitecture';
|
||||||
|
import { detectProjectIntent, KnownProject } from './features/projectArchitecture/intentDetector';
|
||||||
|
|
||||||
export interface SidebarLmStudioDeps {
|
export interface SidebarLmStudioDeps {
|
||||||
lifecycle: ModelLifecycleManager;
|
lifecycle: ModelLifecycleManager;
|
||||||
@@ -73,6 +81,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
_modelsCache: { url: string; models: string[]; online: boolean; fetchedAt: number } | null = null;
|
_modelsCache: { url: string; models: string[]; online: boolean; fetchedAt: number } | null = null;
|
||||||
static readonly MODELS_CACHE_TTL_MS = 30000;
|
static readonly MODELS_CACHE_TTL_MS = 30000;
|
||||||
|
|
||||||
|
/** Active file-system watcher for the project-architecture auto-update. Disposed on switch/detach. */
|
||||||
|
private _archWatcher?: vscode.FileSystemWatcher;
|
||||||
|
/** Debounce timer for the architecture watcher. */
|
||||||
|
private _archWatchDebounce?: NodeJS.Timeout;
|
||||||
|
/** Project ID the current watcher is watching — kept so we don't double-register. */
|
||||||
|
private _archWatchedProjectId?: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly _extensionUri: vscode.Uri,
|
readonly _extensionUri: vscode.Uri,
|
||||||
readonly _context: vscode.ExtensionContext,
|
readonly _context: vscode.ExtensionContext,
|
||||||
@@ -957,6 +972,277 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
await this._context.globalState.update(SidebarChatProvider.chronicleProjectsStateKey, projects);
|
await this._context.globalState.update(SidebarChatProvider.chronicleProjectsStateKey, projects);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Project Architecture Context (Feature 2) ──────────────────────────────
|
||||||
|
//
|
||||||
|
// Activation flow:
|
||||||
|
// 1. Chat preprocessor (or an explicit "Activate" button) calls
|
||||||
|
// _tryActivateArchitectureFromText(latestUserMessage).
|
||||||
|
// 2. If the text yields a known/inferable project, we set it active,
|
||||||
|
// ensure the architecture doc exists, register the file watcher,
|
||||||
|
// and broadcast the state to the webview as a chip.
|
||||||
|
// 3. On every subsequent prompt, _handlePrompt reads
|
||||||
|
// _buildProjectArchitectureContext() and injects it into the model
|
||||||
|
// call. Detach → empty context + watcher disposed.
|
||||||
|
|
||||||
|
/** True if the active project has its architecture doc auto-attached. */
|
||||||
|
_isArchitectureAutoAttached(): boolean {
|
||||||
|
const p = this._getActiveChronicleProject();
|
||||||
|
return !!(p && p.architectureDocPath && p.architectureAutoAttach !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to resolve a project handle from arbitrary user text. Combines:
|
||||||
|
* • Korean / English natural-language activation phrasing.
|
||||||
|
* • Absolute filesystem paths.
|
||||||
|
* • The existing Chronicle project list as ground truth for name matches.
|
||||||
|
*/
|
||||||
|
_detectProjectFromText(text: string): KnownProject | null {
|
||||||
|
const known = this._getChronicleProjects().map<KnownProject>((p) => ({
|
||||||
|
projectId: p.projectId,
|
||||||
|
projectName: p.projectName,
|
||||||
|
projectRoot: p.projectRoot,
|
||||||
|
}));
|
||||||
|
const hit = detectProjectIntent(text || '', known);
|
||||||
|
return hit?.project ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate (or refresh) architecture context for the project resolved from
|
||||||
|
* `text`. No-op when no project is detected. Returns the activated profile
|
||||||
|
* id, or `null` if nothing changed. Side-effects: writes the architecture
|
||||||
|
* doc, marks the project active, broadcasts the chip state.
|
||||||
|
*/
|
||||||
|
async _tryActivateArchitectureFromText(text: string): Promise<string | null> {
|
||||||
|
const detected = this._detectProjectFromText(text);
|
||||||
|
if (!detected) return null;
|
||||||
|
return this._activateArchitectureForProject(detected.projectId, {
|
||||||
|
fallbackName: detected.projectName,
|
||||||
|
fallbackRoot: detected.projectRoot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make `projectId` the active project, ensure its architecture doc exists,
|
||||||
|
* and register the file watcher. If the project isn't in the chronicle
|
||||||
|
* store yet (path-only match), materialise a minimal profile so subsequent
|
||||||
|
* turns can find it.
|
||||||
|
*/
|
||||||
|
async _activateArchitectureForProject(
|
||||||
|
projectId: string,
|
||||||
|
opts: { fallbackName?: string; fallbackRoot?: string } = {}
|
||||||
|
): Promise<string | null> {
|
||||||
|
const projects = this._getChronicleProjects();
|
||||||
|
let profile = projects.find((p) => p.projectId === projectId);
|
||||||
|
|
||||||
|
// Materialise a stub when the user references a project by path that
|
||||||
|
// isn't yet registered. We use the path's basename as the name and the
|
||||||
|
// standard records location as recordRoot so existing Chronicle code
|
||||||
|
// keeps working.
|
||||||
|
if (!profile) {
|
||||||
|
const root = opts.fallbackRoot || '';
|
||||||
|
if (!root) {
|
||||||
|
logError('architecture: cannot activate without project root.', { projectId });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const name = opts.fallbackName || path.basename(root) || projectId;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
profile = {
|
||||||
|
projectId,
|
||||||
|
projectName: name,
|
||||||
|
projectRoot: root,
|
||||||
|
recordRoot: path.join(root, 'docs', 'records', name),
|
||||||
|
description: 'Auto-created by Project Architecture activation.',
|
||||||
|
corePurpose: '',
|
||||||
|
detailLevel: 'standard',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
projects.push(profile);
|
||||||
|
await this._putChronicleProjects(projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile.projectRoot) {
|
||||||
|
logError('architecture: profile has no projectRoot; cannot scan.', { projectId });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate or refresh the doc. Always idempotent — the generator
|
||||||
|
// preserves user-owned sections.
|
||||||
|
const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const updated: ProjectProfile = {
|
||||||
|
...profile,
|
||||||
|
architectureDocPath: result.docPath,
|
||||||
|
architectureAutoAttach: profile.architectureAutoAttach ?? true,
|
||||||
|
architectureAutoUpdate: profile.architectureAutoUpdate ?? true,
|
||||||
|
architectureLastUpdated: now,
|
||||||
|
architectureLastScanSignature: result.scan.signature,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
const next = projects.map((p) => (p.projectId === updated.projectId ? updated : p));
|
||||||
|
await this._putChronicleProjects(next);
|
||||||
|
await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, projectId);
|
||||||
|
|
||||||
|
// (Re)register the watcher for this project.
|
||||||
|
this._registerArchitectureWatcher(updated);
|
||||||
|
|
||||||
|
// Tell the webview to show / refresh the chip.
|
||||||
|
await this._sendArchitectureStatus();
|
||||||
|
logInfo('architecture: activated.', {
|
||||||
|
projectId, docPath: result.docPath, created: result.created,
|
||||||
|
});
|
||||||
|
return projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detach project mode: stop auto-attaching the doc and dispose the watcher. */
|
||||||
|
async _detachArchitecture(): Promise<void> {
|
||||||
|
const profile = this._getActiveChronicleProject();
|
||||||
|
if (!profile) {
|
||||||
|
this._disposeArchitectureWatcher();
|
||||||
|
await this._sendArchitectureStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const projects = this._getChronicleProjects();
|
||||||
|
const next = projects.map((p) => p.projectId === profile.projectId
|
||||||
|
? { ...p, architectureAutoAttach: false }
|
||||||
|
: p);
|
||||||
|
await this._putChronicleProjects(next);
|
||||||
|
this._disposeArchitectureWatcher();
|
||||||
|
await this._sendArchitectureStatus();
|
||||||
|
logInfo('architecture: detached.', { projectId: profile.projectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force a refresh of the architecture doc for the active project. */
|
||||||
|
async _refreshArchitecture(): Promise<void> {
|
||||||
|
const profile = this._getActiveChronicleProject();
|
||||||
|
if (!profile || !profile.projectRoot) {
|
||||||
|
this._view?.webview.postMessage({
|
||||||
|
type: 'architectureRefreshFailed',
|
||||||
|
value: { reason: 'no-active-project' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const projects = this._getChronicleProjects();
|
||||||
|
const next = projects.map((p) => p.projectId === profile.projectId
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
architectureDocPath: result.docPath,
|
||||||
|
architectureLastUpdated: now,
|
||||||
|
architectureLastScanSignature: result.scan.signature,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
: p);
|
||||||
|
await this._putChronicleProjects(next);
|
||||||
|
await this._sendArchitectureStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `projectArchitectureContext` string for the active prompt.
|
||||||
|
* Returns empty string when auto-attach is off or the doc is missing —
|
||||||
|
* agent.ts then treats it as "no block" and emits nothing extra.
|
||||||
|
*/
|
||||||
|
_buildProjectArchitectureContext(): string {
|
||||||
|
const p = this._getActiveChronicleProject();
|
||||||
|
if (!p || p.architectureAutoAttach === false || !p.architectureDocPath) return '';
|
||||||
|
if (!fs.existsSync(p.architectureDocPath)) return '';
|
||||||
|
return formatArchitectureContextForPrompt({
|
||||||
|
projectName: p.projectName,
|
||||||
|
docPath: p.architectureDocPath,
|
||||||
|
lastUpdated: p.architectureLastUpdated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Webview chip data — shown above the input box when active. */
|
||||||
|
async _sendArchitectureStatus(): Promise<void> {
|
||||||
|
if (!this._view) return;
|
||||||
|
const p = this._getActiveChronicleProject();
|
||||||
|
const active = !!(p && p.architectureDocPath && p.architectureAutoAttach !== false);
|
||||||
|
this._view.webview.postMessage({
|
||||||
|
type: 'architectureStatus',
|
||||||
|
value: active && p
|
||||||
|
? {
|
||||||
|
active: true,
|
||||||
|
projectId: p.projectId,
|
||||||
|
projectName: p.projectName,
|
||||||
|
docPath: p.architectureDocPath,
|
||||||
|
lastUpdated: p.architectureLastUpdated || '',
|
||||||
|
autoUpdate: p.architectureAutoUpdate !== false,
|
||||||
|
}
|
||||||
|
: { active: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the architecture doc in editor group 2. */
|
||||||
|
async _openArchitectureDoc(): Promise<void> {
|
||||||
|
const p = this._getActiveChronicleProject();
|
||||||
|
if (!p || !p.architectureDocPath) return;
|
||||||
|
try {
|
||||||
|
const doc = await vscode.workspace.openTextDocument(p.architectureDocPath);
|
||||||
|
await vscode.window.showTextDocument(doc, {
|
||||||
|
viewColumn: vscode.ViewColumn.Two,
|
||||||
|
preview: false,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
vscode.window.showErrorMessage(`Architecture doc 열기 실패: ${e?.message ?? e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a debounced watcher over the project root. Only structural
|
||||||
|
* changes regen the doc — the signature hash decides whether to write.
|
||||||
|
* Files inside node_modules / out / dist are filtered by the glob to keep
|
||||||
|
* the noise floor sane during normal development.
|
||||||
|
*/
|
||||||
|
private _registerArchitectureWatcher(profile: ProjectProfile): void {
|
||||||
|
if (!profile.projectRoot) return;
|
||||||
|
if (this._archWatchedProjectId === profile.projectId && this._archWatcher) return;
|
||||||
|
this._disposeArchitectureWatcher();
|
||||||
|
if (profile.architectureAutoUpdate === false) {
|
||||||
|
this._archWatchedProjectId = profile.projectId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pattern = new vscode.RelativePattern(
|
||||||
|
profile.projectRoot,
|
||||||
|
'{package.json,tsconfig.json,README.md,src/**,media/**,docs/**,tests/**,core_py/**,lib/**,app/**}'
|
||||||
|
);
|
||||||
|
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
|
||||||
|
const onChange = () => this._scheduleArchitectureRefresh();
|
||||||
|
watcher.onDidCreate(onChange);
|
||||||
|
watcher.onDidDelete(onChange);
|
||||||
|
watcher.onDidChange(onChange);
|
||||||
|
this._archWatcher = watcher;
|
||||||
|
this._archWatchedProjectId = profile.projectId;
|
||||||
|
logInfo('architecture: watcher registered.', { projectId: profile.projectId, root: profile.projectRoot });
|
||||||
|
}
|
||||||
|
|
||||||
|
private _disposeArchitectureWatcher(): void {
|
||||||
|
try { this._archWatcher?.dispose(); } catch { /* noop */ }
|
||||||
|
this._archWatcher = undefined;
|
||||||
|
if (this._archWatchDebounce) { clearTimeout(this._archWatchDebounce); this._archWatchDebounce = undefined; }
|
||||||
|
this._archWatchedProjectId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _scheduleArchitectureRefresh(): void {
|
||||||
|
if (this._archWatchDebounce) clearTimeout(this._archWatchDebounce);
|
||||||
|
// 6 s debounce: long enough that a "save file" burst settles into one
|
||||||
|
// regen, short enough that the chip's "updated 2m ago" badge stays
|
||||||
|
// believable.
|
||||||
|
this._archWatchDebounce = setTimeout(async () => {
|
||||||
|
const profile = this._getActiveChronicleProject();
|
||||||
|
if (!profile || !profile.projectRoot || profile.architectureAutoUpdate === false) return;
|
||||||
|
try {
|
||||||
|
// Cheap signature check first — most file events don't change shape.
|
||||||
|
const scan = scanProject(profile.projectRoot, profile.projectName);
|
||||||
|
if (scan.signature === profile.architectureLastScanSignature) return;
|
||||||
|
await this._refreshArchitecture();
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('architecture: auto-refresh failed.', { error: e?.message ?? String(e) });
|
||||||
|
}
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
_getActiveChronicleProject(): ProjectProfile | null {
|
_getActiveChronicleProject(): ProjectProfile | null {
|
||||||
const projects = this._getChronicleProjects();
|
const projects = this._getChronicleProjects();
|
||||||
if (projects.length === 0) return null;
|
if (projects.length === 0) return null;
|
||||||
@@ -1132,8 +1418,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await vscode.workspace.openTextDocument(target);
|
await openInEditorGroup(target);
|
||||||
await vscode.window.showTextDocument(doc);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _writeChroniclePlanningFromCurrentChat() {
|
async _writeChroniclePlanningFromCurrentChat() {
|
||||||
@@ -1743,8 +2028,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
fs.writeFileSync(filePath, `# Agent Persona: ${safeName}\n\nAdd your instructions here...\n`, 'utf8');
|
fs.writeFileSync(filePath, `# Agent Persona: ${safeName}\n\nAdd your instructions here...\n`, 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await vscode.workspace.openTextDocument(filePath);
|
await openInEditorGroup(filePath);
|
||||||
await vscode.window.showTextDocument(doc);
|
|
||||||
await this._sendAgentsList();
|
await this._sendAgentsList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1820,6 +2104,10 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
this._currentSessionBrainId = selectedBrainId;
|
this._currentSessionBrainId = selectedBrainId;
|
||||||
|
|
||||||
let agentSkillContext = undefined;
|
let agentSkillContext = undefined;
|
||||||
|
// Per-agent model override: if the active agent has a pinned model in the
|
||||||
|
// knowledge map, it wins over the model the webview just sent. Falls back
|
||||||
|
// to the incoming `model` (which is the global default the user picked).
|
||||||
|
let effectiveModel: string = typeof model === 'string' ? model : '';
|
||||||
if (agentFile && agentFile !== 'none' && fs.existsSync(agentFile)) {
|
if (agentFile && agentFile !== 'none' && fs.existsSync(agentFile)) {
|
||||||
const fileContent = fs.readFileSync(agentFile, 'utf8');
|
const fileContent = fs.readFileSync(agentFile, 'utf8');
|
||||||
// Guard: a freshly-created agent still has only the placeholder template
|
// Guard: a freshly-created agent still has only the placeholder template
|
||||||
@@ -1842,6 +2130,21 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
if (block) {
|
if (block) {
|
||||||
agentSkillContext = `${agentSkillContext.trim()}\n\n${block}`;
|
agentSkillContext = `${agentSkillContext.trim()}\n\n${block}`;
|
||||||
}
|
}
|
||||||
|
// Apply the per-agent model override, if any.
|
||||||
|
const pinned = entry.model?.trim();
|
||||||
|
if (pinned && pinned !== effectiveModel) {
|
||||||
|
logInfo('Per-agent model override applied.', {
|
||||||
|
agent: entry.name,
|
||||||
|
requested: effectiveModel,
|
||||||
|
pinned,
|
||||||
|
});
|
||||||
|
effectiveModel = pinned;
|
||||||
|
// Inform the webview so its UI can reflect the model that's actually in use.
|
||||||
|
this._view?.webview.postMessage({
|
||||||
|
type: 'agentModelOverride',
|
||||||
|
value: { agent: entry.name, model: pinned },
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logError('External skill load failed.', { error: e?.message || String(e) });
|
logError('External skill load failed.', { error: e?.message || String(e) });
|
||||||
}
|
}
|
||||||
@@ -1850,6 +2153,19 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
|
|
||||||
const designerContext = designerGuard !== false ? this._buildDesignerGuardContext() : undefined;
|
const designerContext = designerGuard !== false ? this._buildDesignerGuardContext() : undefined;
|
||||||
|
|
||||||
|
// Project Architecture activation (Feature 2): if the user just said
|
||||||
|
// "나 X 프로젝트 진행할 거야" / mentioned an absolute path / etc., switch
|
||||||
|
// to that project's mode before assembling the prompt. Best-effort:
|
||||||
|
// failures here never block the actual answer.
|
||||||
|
if (typeof value === 'string' && value.trim().length > 0) {
|
||||||
|
try {
|
||||||
|
await this._tryActivateArchitectureFromText(value);
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('architecture: intent detection failed.', { error: e?.message ?? String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const projectArchitectureContext = this._buildProjectArchitectureContext();
|
||||||
|
|
||||||
// [File Processing v2] 파일 타입별 분류 처리
|
// [File Processing v2] 파일 타입별 분류 처리
|
||||||
let processedPrompt = value || '';
|
let processedPrompt = value || '';
|
||||||
let imageFiles: any[] | undefined = undefined;
|
let imageFiles: any[] | undefined = undefined;
|
||||||
@@ -1940,13 +2256,14 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this._agent.handlePrompt(processedPrompt, model, {
|
await this._agent.handlePrompt(processedPrompt, effectiveModel || model, {
|
||||||
internetEnabled: internet,
|
internetEnabled: internet,
|
||||||
visionContent: imageFiles,
|
visionContent: imageFiles,
|
||||||
agentSkillContext,
|
agentSkillContext,
|
||||||
agentSkillFile: typeof agentFile === 'string' ? agentFile : undefined,
|
agentSkillFile: typeof agentFile === 'string' ? agentFile : undefined,
|
||||||
negativePrompt,
|
negativePrompt,
|
||||||
designerContext,
|
designerContext,
|
||||||
|
projectArchitectureContext: projectArchitectureContext || undefined,
|
||||||
secondBrainTraceEnabled: secondBrainTrace !== false,
|
secondBrainTraceEnabled: secondBrainTrace !== false,
|
||||||
secondBrainTraceDebug: !!secondBrainTraceDebug,
|
secondBrainTraceDebug: !!secondBrainTraceDebug,
|
||||||
brainProfileId: selectedBrainId
|
brainProfileId: selectedBrainId
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export interface AgentKnowledgeEntry {
|
|||||||
skillFolders?: string[];
|
skillFolders?: string[];
|
||||||
/** Optional: pinned model override for this agent (e.g. `qwen3:8b`). */
|
/** Optional: pinned model override for this agent (e.g. `qwen3:8b`). */
|
||||||
model?: string;
|
model?: string;
|
||||||
|
/**
|
||||||
|
* Optional: per-agent Knowledge Mix override (0–100). Higher = lean harder on
|
||||||
|
* Second Brain notes. When undefined the resolver falls back to the global
|
||||||
|
* `g1nation.knowledgeMix.secondBrainWeight` setting. Stored as integer.
|
||||||
|
*/
|
||||||
|
secondBrainWeight?: number;
|
||||||
/** Optional: human-friendly note shown in UI hints. */
|
/** Optional: human-friendly note shown in UI hints. */
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
@@ -93,11 +99,19 @@ function _coerceMap(raw: unknown): AgentKnowledgeMap {
|
|||||||
const skillFolders = skillsRaw
|
const skillFolders = skillsRaw
|
||||||
.map((f) => (typeof f === 'string' ? f.trim() : ''))
|
.map((f) => (typeof f === 'string' ? f.trim() : ''))
|
||||||
.filter((f) => f.length > 0);
|
.filter((f) => f.length > 0);
|
||||||
|
// Per-agent Knowledge Mix weight: only accept integers within [0, 100].
|
||||||
|
// `null` and out-of-range numbers fall back to undefined (use global).
|
||||||
|
let secondBrainWeight: number | undefined;
|
||||||
|
if (typeof a.secondBrainWeight === 'number' && Number.isFinite(a.secondBrainWeight)) {
|
||||||
|
const w = Math.round(a.secondBrainWeight);
|
||||||
|
if (w >= 0 && w <= 100) secondBrainWeight = w;
|
||||||
|
}
|
||||||
agents.push({
|
agents.push({
|
||||||
name,
|
name,
|
||||||
knowledgeFolders: folders,
|
knowledgeFolders: folders,
|
||||||
skillFolders: skillFolders.length > 0 ? skillFolders : undefined,
|
skillFolders: skillFolders.length > 0 ? skillFolders : undefined,
|
||||||
model: typeof a.model === 'string' && a.model.trim() ? a.model.trim() : undefined,
|
model: typeof a.model === 'string' && a.model.trim() ? a.model.trim() : undefined,
|
||||||
|
secondBrainWeight,
|
||||||
description: typeof a.description === 'string' && a.description.trim() ? a.description.trim() : undefined,
|
description: typeof a.description === 'string' && a.description.trim() ? a.description.trim() : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -235,13 +249,21 @@ export function saveKnowledgeMap(map: AgentKnowledgeMap): { ok: boolean; path: s
|
|||||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
const normalized: AgentKnowledgeMap = {
|
const normalized: AgentKnowledgeMap = {
|
||||||
defaultAgent: map.defaultAgent?.trim() || undefined,
|
defaultAgent: map.defaultAgent?.trim() || undefined,
|
||||||
agents: (map.agents || []).map((a) => ({
|
agents: (map.agents || []).map((a) => {
|
||||||
name: a.name.trim(),
|
let secondBrainWeight: number | undefined;
|
||||||
knowledgeFolders: (a.knowledgeFolders || []).map((f) => f.trim()).filter(Boolean),
|
if (typeof a.secondBrainWeight === 'number' && Number.isFinite(a.secondBrainWeight)) {
|
||||||
skillFolders: (a.skillFolders || []).map((f) => f.trim()).filter(Boolean),
|
const w = Math.round(a.secondBrainWeight);
|
||||||
model: a.model?.trim() || undefined,
|
if (w >= 0 && w <= 100) secondBrainWeight = w;
|
||||||
description: a.description?.trim() || undefined,
|
}
|
||||||
})).filter((a) => a.name.length > 0),
|
return {
|
||||||
|
name: a.name.trim(),
|
||||||
|
knowledgeFolders: (a.knowledgeFolders || []).map((f) => f.trim()).filter(Boolean),
|
||||||
|
skillFolders: (a.skillFolders || []).map((f) => f.trim()).filter(Boolean),
|
||||||
|
model: a.model?.trim() || undefined,
|
||||||
|
secondBrainWeight,
|
||||||
|
description: a.description?.trim() || undefined,
|
||||||
|
};
|
||||||
|
}).filter((a) => a.name.length > 0),
|
||||||
};
|
};
|
||||||
// Drop empty skillFolders arrays so the JSON stays clean for entries
|
// Drop empty skillFolders arrays so the JSON stays clean for entries
|
||||||
// that never used the new feature.
|
// that never used the new feature.
|
||||||
@@ -272,6 +294,7 @@ export function getOrCreateAgentEntry(agentName: string): AgentKnowledgeEntry {
|
|||||||
knowledgeFolders: [...(existing.knowledgeFolders || [])],
|
knowledgeFolders: [...(existing.knowledgeFolders || [])],
|
||||||
skillFolders: [...(existing.skillFolders || [])],
|
skillFolders: [...(existing.skillFolders || [])],
|
||||||
model: existing.model,
|
model: existing.model,
|
||||||
|
secondBrainWeight: existing.secondBrainWeight,
|
||||||
description: existing.description,
|
description: existing.description,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -302,6 +325,7 @@ export function upsertAgentEntry(entry: AgentKnowledgeEntry): { ok: boolean; pat
|
|||||||
knowledgeFolders: entry.knowledgeFolders || [],
|
knowledgeFolders: entry.knowledgeFolders || [],
|
||||||
skillFolders: entry.skillFolders || [],
|
skillFolders: entry.skillFolders || [],
|
||||||
model: entry.model,
|
model: entry.model,
|
||||||
|
secondBrainWeight: entry.secondBrainWeight,
|
||||||
description: entry.description,
|
description: entry.description,
|
||||||
};
|
};
|
||||||
if (idx >= 0) next.agents[idx] = merged;
|
if (idx >= 0) next.agents[idx] = merged;
|
||||||
@@ -338,7 +362,11 @@ export async function openKnowledgeMapEditor(): Promise<void> {
|
|||||||
logInfo('agent-knowledge-map: starter created.', { jsonPath });
|
logInfo('agent-knowledge-map: starter created.', { jsonPath });
|
||||||
}
|
}
|
||||||
const doc = await vscode.workspace.openTextDocument(jsonPath);
|
const doc = await vscode.workspace.openTextDocument(jsonPath);
|
||||||
await vscode.window.showTextDocument(doc);
|
// Keep the ConnectAI sidebar (column 3) untouched — open the JSON in the editor group.
|
||||||
|
await vscode.window.showTextDocument(doc, {
|
||||||
|
viewColumn: vscode.ViewColumn.Two,
|
||||||
|
preview: false,
|
||||||
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logError('agent-knowledge-map: open failed.', { jsonPath, error: e?.message ?? String(e) });
|
logError('agent-knowledge-map: open failed.', { jsonPath, error: e?.message ?? String(e) });
|
||||||
vscode.window.showErrorMessage(`매핑 파일 열기 실패: ${e?.message ?? e}`);
|
vscode.window.showErrorMessage(`매핑 파일 열기 실패: ${e?.message ?? e}`);
|
||||||
|
|||||||
@@ -75,6 +75,26 @@ export function buildApiUrl(baseUrl: string, engine: EngineKind, endpoint: 'mode
|
|||||||
return `${apiRoot}/chat`;
|
return `${apiRoot}/chat`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a file in the editor and keep ConnectAI's sidebar (typically ViewColumn.Three)
|
||||||
|
* undisturbed. Markdown records, wiki docs, agent skill files, knowledge-map JSON,
|
||||||
|
* lessons — all should land in the *editor* area (group 2), never in the sidebar group.
|
||||||
|
*
|
||||||
|
* Falls back to whatever ViewColumn ends up being default if `Two` is unavailable
|
||||||
|
* (VS Code creates the column on demand when one doesn't exist yet).
|
||||||
|
*/
|
||||||
|
export async function openInEditorGroup(
|
||||||
|
target: string | vscode.Uri,
|
||||||
|
options: { preview?: boolean } = {}
|
||||||
|
): Promise<vscode.TextEditor> {
|
||||||
|
const uri = typeof target === 'string' ? vscode.Uri.file(target) : target;
|
||||||
|
const doc = await vscode.workspace.openTextDocument(uri);
|
||||||
|
return vscode.window.showTextDocument(doc, {
|
||||||
|
viewColumn: vscode.ViewColumn.Two,
|
||||||
|
preview: options.preview ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function summarizeText(text: string, maxLength: number = 400): string {
|
export function summarizeText(text: string, maxLength: number = 400): string {
|
||||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||||
if (normalized.length <= maxLength) return normalized;
|
if (normalized.length <= maxLength) return normalized;
|
||||||
|
|||||||
Reference in New Issue
Block a user