Compare commits
7 Commits
13e569f426
...
139ad3ee93
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
139ad3ee93 | ||
|
|
08fd08b9a6 | ||
|
|
326454be40 | ||
|
|
98b7585e3c | ||
|
|
c7f939ce85 | ||
|
|
7a1675fd5d | ||
|
|
6b9f1188c3 |
@@ -21,6 +21,44 @@
|
|||||||
> 鍮꾩듂븳 臾몄젣媛 옱諛쒗븯硫 archive뿉꽌 寃깋븯꽭슂.
|
> 鍮꾩듂븳 臾몄젣媛 옱諛쒗븯硫 archive뿉꽌 寃깋븯꽭슂.
|
||||||
|
|
||||||
|
|
||||||
|
### [2026-04-18] [Extension] ★ WS response 파일이 processResponseFile에 의해 삭제 → Observer pollResponseGroup 실패 (v0.5.78)
|
||||||
|
- **증상**: `!auto` Retry가 작동하지 않음. Observer가 `/response/{rid}`를 폴링하지만 항상 `{waiting: true}` 반환.
|
||||||
|
- **원인**: extension.ts의 WS 응답 핸들러가 `response/{rid}.json` 파일 작성 → 300ms 후 response watcher(`processResponseFile`)가 파일 감지 → pending 파일이 없어 `isDomObserver=false` → `fs.unlinkSync()` 실행 → Observer가 폴링할 때 파일이 이미 삭제됨.
|
||||||
|
- **해결 (v0.5.78)**: WS 응답 파일에 `_from_ws: true` 마커 추가. `processResponseFile`에서 `_from_ws` 파일 스킵 (WS 핸들러에서 이미 `tryApprovalStrategies` 실행하므로 중복 방지도 함께 해결).
|
||||||
|
- **주의**: http-bridge의 `_handlePending`는 pending 파일을 생성하지 않음 (WS 전송만 수행). 따라서 `processResponseFile`의 `isDomObserver` 판별이 실패함. WS 경로로 들어오는 모든 response는 반드시 마커로 구분해야 함.
|
||||||
|
|
||||||
|
### [2026-04-18] [Extension] ★ extractContextFromNearby 조상 탐색만으로는 명령어 추출 불가 (v0.5.79)
|
||||||
|
- **증상**: Discord auto-approve 알림에 "Always run"만 표시되고 실제 명령어가 안 보임. depth를 10으로 늘려도 동일.
|
||||||
|
- **원인**: AG Native DOM 구조에서 "Always run" 버튼은 `footer` 내부에 있고, 실제 명령어(`pre.font-mono`)는 `footer`의 **형제(sibling)** 요소에 있음. 조상 탐색(parentElement)으로는 도달 불가. trail: `d0:button → d1:div → d2:footer` (footer.parentElement가 null).
|
||||||
|
- **해결 (v0.5.79)**: `extractContextFromNearby`에 형제 탐색 로직 추가. 각 depth에서 `node.parentElement.children`을 순회하며 `pre.font-mono, pre, code`를 찾음 → `CONTEXT-OK src=sibling` 성공.
|
||||||
|
- **주의**: Observer 코드 변경은 HTML 인라인 스크립트이므로 AG 2번 재시작(Quit + Relaunch × 2) 필요.
|
||||||
|
|
||||||
|
### [2026-04-18] [Extension] Thinking 블록이 AI 응답으로 릴레이됨
|
||||||
|
- **증상**: AI의 내부 사고 과정(thinking/reasoning)이 Discord에 릴레이됨.
|
||||||
|
- **원인**: Observer의 `scanChatBodies`가 `.leading-relaxed.select-text` 블록을 전부 캡처하는데, thinking 블록도 이 셀렉터에 매칭됨.
|
||||||
|
- **해결**: thinking 블록의 조상에 `max-h-[200px]` 클래스가 있는지 확인하여 필터링.
|
||||||
|
- **주의**: AG UI 업데이트로 thinking 블록의 클래스가 변경될 수 있음.
|
||||||
|
|
||||||
|
|
||||||
|
- **증상**: Step Probe의 RT-CAPTURE, HB-CAPTURE가 현재 대화 중에 전혀 발동하지 않음. POLL에서 `status=IDLE, steps=928, delta=0` 고정. Heartbeat probe에서도 `real=928 known=928` 불변.
|
||||||
|
- **원인**: AG Native의 `GetCascadeTrajectorySteps` API는 **cascade가 완전히 종료(IDLE 전환)된 후에만** 새 step을 반환합니다. 진행 중인 cascade에서는 step count가 동결됩니다. `GetAllCascadeTrajectories`의 `stepCount`도 마찬가지.
|
||||||
|
- **영향**: Step Probe 기반의 모든 실시간 캡처(RT-CAPTURE, HB-CAPTURE, USER_INPUT 감지)가 **구조적으로 불가능**. Observer DOM이 유일한 실시간 데이터 경로.
|
||||||
|
- **해결**: Observer DOM 기반 chat relay를 재활성화 (v0.5.72). Step Probe는 cascade 완료 후 보완 용도로만 사용.
|
||||||
|
- **주의**: 이 제약은 AG Native 아키텍처의 근본적 특성. Polling 주기나 heartbeat 빈도를 변경해도 해결 불가. **실시간 릴레이는 반드시 Observer DOM 경로를 사용해야 함.**
|
||||||
|
- **참조**: `.agents/references/relay-architecture.md` (상세 분석)
|
||||||
|
|
||||||
|
### [2026-04-18] [Extension] Observer의 /pending POST에 명령어 컨텍스트가 없음 (Always run만 전달)
|
||||||
|
- **증상**: Discord auto-approve 알림에 "Always run"만 표시되고 실제 명령어가 안 보임.
|
||||||
|
- **원인**: Observer가 `/pending` POST 시 `command: "Always run"`, `description: "Always run"`만 보냄. `extractContextFromNearby(btn)`이 버튼 주변 DOM에서 유의미한 텍스트를 찾지 못함.
|
||||||
|
- **해결 (부분)**: v0.5.69에서 bridge/pending/ 디렉토리의 최신 Step Probe pending 파일에서 명령어를 읽는 fallback 추가. Step Probe pending이 있을 때만 작동.
|
||||||
|
- **주의**: Step Probe WAITING 감지가 진행 중 cascade에서 불가하므로 (위 이슈 참조), 현재 대부분의 경우 fallback도 실패. Observer의 DOM 컨텍스트 추출 개선이 필요.
|
||||||
|
|
||||||
|
### [2026-04-18] [Extension] Observer의 사용자 메시지 셀렉터 미매칭
|
||||||
|
- **증상**: 사용자가 AG에서 입력한 메시지가 Discord에 전혀 전달되지 않음.
|
||||||
|
- **원인**: Observer의 셀렉터(`.text-ide-message-block-user-color`, `[data-message-role="user"]` 등)가 AG Native DOM에서 매칭되지 않음. AI 응답만 `.leading-relaxed.select-text`로 매칭됨.
|
||||||
|
- **해결**: DOM 덤프에서 사용자 메시지 블록의 실제 CSS 클래스를 식별 후 셀렉터 추가 필요.
|
||||||
|
- **주의**: Step Probe의 USER_INPUT 캡처도 진행 중 cascade에서 불가 (위 이슈 참조).
|
||||||
|
|
||||||
### [2026-04-16] [Extension] ★ AG Native 세션 AI 응답이 Discord에 CSS로 전달됨 (v0.5.52 수정, #632)
|
### [2026-04-16] [Extension] ★ AG Native 세션 AI 응답이 Discord에 CSS로 전달됨 (v0.5.52 수정, #632)
|
||||||
- **증상**: Discord에 AI 대화 응답 대신 **CSS 스타일시트 코드** (`remark-github-blockquote-alert/alert.css`)가 전달됨. `scanChatBodies()` → POST /chat 경로는 작동하지만 내용이 CSS.
|
- **증상**: Discord에 AI 대화 응답 대신 **CSS 스타일시트 코드** (`remark-github-blockquote-alert/alert.css`)가 전달됨. `scanChatBodies()` → POST /chat 경로는 작동하지만 내용이 CSS.
|
||||||
- **원인**: `extractCleanStepText()`에서 clone한 DOM에서 버튼/SVG/아이콘은 제거하지만 **`<style>` 태그는 제거하지 않음**. AG Native 마크다운 렌더러가 `.leading-relaxed.select-text` 내부에 `<style>` 블록을 주입하는데, 이 CSS textContent가 AI 응답 텍스트로 추출됨.
|
- **원인**: `extractCleanStepText()`에서 clone한 DOM에서 버튼/SVG/아이콘은 제거하지만 **`<style>` 태그는 제거하지 않음**. AG Native 마크다운 렌더러가 `.leading-relaxed.select-text` 내부에 `<style>` 블록을 주입하는데, 이 CSS textContent가 AI 응답 텍스트로 추출됨.
|
||||||
|
|||||||
207
.agents/references/relay-architecture.md
Normal file
207
.agents/references/relay-architecture.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# AG Native 릴레이 아키텍처 분석
|
||||||
|
|
||||||
|
> **이 문서는 AG Native ↔ Discord 릴레이의 데이터 흐름 SSOT입니다.**
|
||||||
|
> 구현/디버깅 전 반드시 확인합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 데이터 경로 요약
|
||||||
|
|
||||||
|
AG Native에서 Discord로 메시지를 전달하는 경로는 크게 2개:
|
||||||
|
|
||||||
|
| # | 경로 | 소스 | 실시간? | 상태 |
|
||||||
|
|---|------|------|---------|------|
|
||||||
|
| 1 | **Observer DOM** | workbench.html 인라인 스크립트 → DOM 관찰 → HTTP POST → http-bridge | ✅ 실시간 | AI 응답: ✅ 작동 (v0.5.72+), 사용자 메시지: ✅ 작동 (v0.5.74+) |
|
||||||
|
| 2 | **Step Probe (trajectory API)** | LS RPC `GetCascadeTrajectorySteps` → step 분석 | ❌ cascade 완료 후에만 | AI 응답: ❌ 실시간 불가, 사용자 메시지: ❌ 실시간 불가 |
|
||||||
|
|
||||||
|
### 1.1 핵심 API 제약 (2026-04-18 확인)
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **`GetCascadeTrajectorySteps`는 진행 중인 cascade의 step을 실시간으로 반환하지 않습니다.**
|
||||||
|
> step count는 cascade가 **완전히 종료**(IDLE 전환)된 후에만 업데이트됩니다.
|
||||||
|
> 따라서 Step Probe의 RT-CAPTURE, HB-CAPTURE 모두 **현재 진행 중인 대화에서는 작동하지 않습니다.**
|
||||||
|
|
||||||
|
**검증 데이터**:
|
||||||
|
- POLL에서 `status=CASCADE_RUN_STATUS_IDLE`, `steps=928`, `delta=0` 고정
|
||||||
|
- HEARTBEAT probe: `offset=927 got=1 real=928 known=928` → 변함 없음
|
||||||
|
- 실제로 수십 개의 tool call이 실행되었지만 step count 불변
|
||||||
|
- Cascade 종료 후 다음 poll에서 step count가 점프 (예: 733 → 865 → 928)
|
||||||
|
|
||||||
|
### 1.2 AG Native SDK EventMonitor
|
||||||
|
|
||||||
|
SDK에 이벤트 시스템이 있으나 **모두 polling 기반**:
|
||||||
|
- `EventMonitor.onStepCountChanged` — getDiagnostics 기반 polling
|
||||||
|
- `EventMonitor.onActiveSessionChanged` — state.vscdb 기반 polling
|
||||||
|
- **실시간 push (WebSocket/SSE)는 없음**
|
||||||
|
- 현재 상태: ERR_CONNECTION_REFUSED 문제로 비활성화됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Observer DOM 경로 상세
|
||||||
|
|
||||||
|
### 2.1 Observer 스크립트 삽입 체인
|
||||||
|
|
||||||
|
```
|
||||||
|
extension.ts activate()
|
||||||
|
→ html-patcher.ts setupApprovalObserver()
|
||||||
|
→ observer-script.ts generateApprovalObserverScript(port)
|
||||||
|
→ workbench-jetski-agent.html에 인라인 <script> 삽입
|
||||||
|
→ AG 재시작 시 렌더러가 로드
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Observer 주요 함수
|
||||||
|
|
||||||
|
| 함수 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `scanChatBodies()` | 3초마다 실행, conversation view에서 메시지 블록 탐색 |
|
||||||
|
| `extractCleanStepText(el)` | DOM 클론 → style/script/button 제거 → textContent 추출 |
|
||||||
|
| `extractContextFromNearby(btn)` | 승인 버튼 주변 DOM에서 명령어 텍스트 추출 |
|
||||||
|
| `pollResponseGroup(rid, btnRefs)` | response 파일 polling → 버튼 자동 클릭 |
|
||||||
|
|
||||||
|
### 2.3 AI 응답 감지 셀렉터
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var responseBlocks = cv.querySelectorAll(
|
||||||
|
'.leading-relaxed.select-text, ' // ← AI 응답 마크다운 블록 (주력)
|
||||||
|
+ '.text-ide-message-block-user-color, ' // ← 사용자 메시지 (미매칭)
|
||||||
|
+ '.text-ide-message-block-bot-color, ' // ← NUX tooltip 전용 (오매칭)
|
||||||
|
+ '.bg-ide-message-block-user-background, '// ← 사용자 메시지 (미매칭)
|
||||||
|
+ '[data-message-role="user"], ' // ← 사용자 메시지 (미매칭)
|
||||||
|
+ '[data-role="user"]' // ← 사용자 메시지 (미매칭)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **v0.5.74에서 사용자 메시지 셀렉터가 추가되었습니다.**
|
||||||
|
> AG Native 소스(`jetskiAgent/main.js`)의 `Esn` 컴포넌트 분석으로
|
||||||
|
> 사용자 메시지 CSS 클래스(`msn = "bg-gray-500/10 border border-gray-500/20 p-2 rounded-lg w-full text-sm select-text"`)를 식별.
|
||||||
|
> 셀렉터: `.select-text.rounded-lg`, 역할 판별: `rounded-lg` 있고 `leading-relaxed` 없으면 → user
|
||||||
|
|
||||||
|
### 2.4 AI 응답 추출 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
scanChatBodies() 3초 간격
|
||||||
|
→ cv = document.querySelector('#conversation')
|
||||||
|
→ responseBlocks = cv.querySelectorAll('.leading-relaxed.select-text, ...')
|
||||||
|
→ lastBlock = responseBlocks[last] (가장 최근 블록)
|
||||||
|
→ 이미 scrape 됐으면 skip
|
||||||
|
→ blockText = extractCleanStepText(lastBlock)
|
||||||
|
→ 안정화 대기 (3초 동안 텍스트 변경 없으면)
|
||||||
|
→ POST /chat { text, source, block_index, role }
|
||||||
|
→ http-bridge → writeChatSnapshot() → WS → Discord
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 Observer 업데이트 제약
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Observer 코드는 workbench.html에 인라인 삽입됩니다.**
|
||||||
|
> extension reload만으로는 Observer 코드가 업데이트되지 않습니다.
|
||||||
|
> **AG 재시작 + V8 CachedData 삭제**가 필요합니다.
|
||||||
|
> (단, product.json 체크섬이 맞으면 CachedData 삭제 없이 AG 재시작만으로 충분할 수 있음)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 승인 버튼 (Auto-Approve) 경로
|
||||||
|
|
||||||
|
### 3.1 "Always run" 자동 승인 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
Observer DOM scan
|
||||||
|
→ "Always run" 버튼 텍스트 감지
|
||||||
|
→ POST /pending { command: "Always run", description: "...", buttons: [...] }
|
||||||
|
→ http-bridge _handlePending()
|
||||||
|
→ alwaysRunDetected = true
|
||||||
|
→ enrichment 시도:
|
||||||
|
1. rawDesc에서 > 프롬프트 마커 찾기 → ✅ 성공 (buttons=2일 때 desc에 프롬프트 포함)
|
||||||
|
2. rawDesc 최장 라인 사용 → buttons=1일 때 desc="Always run"이라 실패
|
||||||
|
3. v20 fallback: bridge/pending/ 최신 파일에서 command 읽기 → Step Probe pending 있을 때만
|
||||||
|
→ response 파일 작성 → Observer pollResponseGroup → 버튼 클릭
|
||||||
|
→ WS sendPending { status: 'auto_approved', command: displayCmd }
|
||||||
|
→ Discord embed 표시
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 명령어 enrichment 현황 (2026-04-18 검증)
|
||||||
|
|
||||||
|
| 조건 | 결과 | 빈도 |
|
||||||
|
|------|------|------|
|
||||||
|
| Observer가 buttons=2 (`["Always run","Cancel"]`)이고 desc에 `>` 포함 | ✅ 명령어 표시 | ~50% |
|
||||||
|
| Observer가 buttons=1 (`["Always run"]`)이고 desc="Always run" | ❌ "Always run" 표시 | ~50% |
|
||||||
|
|
||||||
|
**로그 증거** (04:25:59):
|
||||||
|
```
|
||||||
|
AUTO-APPROVE raw: cmd="Always run" desc="…\extension > npm.cmd run compile..." buttons=["Always run","Cancel"]
|
||||||
|
→ cmd="npm.cmd run compile 2>&1; npm.cmd version patch..." ✅ 성공
|
||||||
|
|
||||||
|
AUTO-APPROVE raw: cmd="Always run" desc="Always run" buttons=["Always run"]
|
||||||
|
→ cmd="Always run" ❌ 실패
|
||||||
|
```
|
||||||
|
|
||||||
|
> buttons=2인 경우("Always run" + "Cancel")는 Observer가 code 블록을 찾아 description에 포함.
|
||||||
|
> buttons=1인 경우는 code 블록이 DOM에서 아직 렌더링되지 않았거나 접근 불가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 사용자 메시지 릴레이 상태
|
||||||
|
|
||||||
|
### 4.1 현재 상태: ✅ 작동 (v0.5.74+)
|
||||||
|
|
||||||
|
| 경로 | 상태 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| Observer DOM | ✅ | `.select-text.rounded-lg` 셀렉터로 캡처 (v0.5.74) |
|
||||||
|
| Step Probe (trajectory API) | ❌ | cascade 진행 중 step 조회 불가 |
|
||||||
|
| Step Probe (observer [USER-MSG]) | ❌ | `lastUserInputStepIndex`가 갱신되지 않음 |
|
||||||
|
|
||||||
|
### 4.2 해결 방안
|
||||||
|
|
||||||
|
1. **DOM 덤프에서 사용자 메시지 클래스 식별** → Observer 셀렉터 추가
|
||||||
|
2. **Cascade 완료 후** Step Probe HB-CAPTURE에서 `USER_INPUT` step 캡처 (지연 릴레이)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 파일/포트 매핑
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| Observer 삽입 대상 | `workbench-jetski-agent.html` |
|
||||||
|
| HTTP Bridge 포트 | `getDeterministicPort('gravity_control')` = **18080** |
|
||||||
|
| Extension 로그 | `~/.gemini/antigravity/bridge/extension.log` |
|
||||||
|
| Pending 파일 | `~/.gemini/antigravity/bridge/pending/*.json` |
|
||||||
|
| Response 파일 | `~/.gemini/antigravity/bridge/response/*.json` |
|
||||||
|
| Chat Snapshot 파일 | `~/.gemini/antigravity/bridge/chat_snapshots/*.json` |
|
||||||
|
| Discord 채널 | `#ag-gravity_control` (ID: 1483082084540223663) |
|
||||||
|
| Discord Bot 토큰 | `.env` → `DISCORD_TOKEN` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 디버깅 도구
|
||||||
|
|
||||||
|
| 도구 | 경로 | 용도 |
|
||||||
|
|------|------|------|
|
||||||
|
| Discord 메시지 읽기 | `extension/scratch/discord_read.js` | API로 채널 최근 메시지 조회 |
|
||||||
|
| Discord 채널 목록 | `extension/scratch/discord_channels.js` | 서버 채널 목록 조회 |
|
||||||
|
| Extension 로그 확인 | `Select-String -Path $logFile -Pattern "패턴"` | 실시간 로그 분석 |
|
||||||
|
| DOM 구조 덤프 | Observer 자동 (CV-CHILDREN 로그) | AG Native DOM 클래스 식별 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 버전 히스토리 (v0.5.67~)
|
||||||
|
|
||||||
|
| 버전 | 변경 | 결과 |
|
||||||
|
|------|------|------|
|
||||||
|
| v0.5.67 | Observer DOM relay 비활성화, Step Probe RT-CAPTURE로 전환 | ❌ API가 진행중 step 미반환 |
|
||||||
|
| v0.5.68 | auto-approve enrichment 디버그 로그 추가, 조건 >10 → >3 완화 | Observer가 desc="Always run" 보냄 확인 |
|
||||||
|
| v0.5.69 | pending 파일 fallback으로 auto-approve 명령어 enrichment | 일부 개선 (Step Probe pending 있을 때만) |
|
||||||
|
| v0.5.70 | heartbeat 로깅 강화 | API step count 동결 확인 |
|
||||||
|
| v0.5.71 | heartbeat 3 poll마다 실행, HB-CAPTURE 추가 | API가 진행중 step 미반환 재확인 |
|
||||||
|
| v0.5.72 | Observer DOM relay 재활성화 | AG 재시작 필요 (Observer HTML 캐시) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 남은 작업 (TODO)
|
||||||
|
|
||||||
|
- [x] AG 재시작하여 Observer 반영 확인 — ✅ v0.5.72 작동 확인
|
||||||
|
- [x] Observer의 AI 응답 릴레이가 작동하는지 Discord에서 확인 — ✅ 작동
|
||||||
|
- [ ] v0.5.73 설치 (MSG-BLOCKS 로그 추가) → AG 재시작 → 사용자 메시지 DOM 클래스 식별
|
||||||
|
- [ ] 사용자 메시지 셀렉터 추가 후 테스트
|
||||||
|
- [ ] "Always run" enrichment 개선 — buttons=1일 때 code 블록 추출 개선
|
||||||
|
- [ ] AI 응답이 마지막 블록만 캡처되는 문제 개선 (전문 캡처)
|
||||||
5
docs/devlog/2026-04-17.md
Normal file
5
docs/devlog/2026-04-17.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-04-17
|
||||||
|
|
||||||
|
| NNN | HH:MM | 작업 설명 | `커밋해시` | 완료 |
|
||||||
|
|-------|-------|----------|-----------|----------|
|
||||||
|
| 001 | 08:05 | v18 Observer DOM->Markdown 파서 개선(<a> 파싱 포함) 및 노이즈 필터 부작용(코드 블럭 잘림 방지) 해결, User 구문 추출 연동, v0.5.56 배포 | `미정` | ✅ |
|
||||||
5
docs/devlog/2026-04-18.md
Normal file
5
docs/devlog/2026-04-18.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Devlog — 2026-04-18
|
||||||
|
|
||||||
|
| # | 시간 | 작업 설명 | 커밋 | 상태 |
|
||||||
|
|---|------|----------|------|------|
|
||||||
|
| 001 | 09:20~23:50 | Retry auto-approve 흐름 복구 — WS response 파일 보존 (`_from_ws`), Observer 형제 탐색(sibling), thinking 블록 필터링 | `pending` | ✅ |
|
||||||
17
docs/devlog/entries/20260417-001.md
Normal file
17
docs/devlog/entries/20260417-001.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# DOM Observer 마크다운 구조 복원 및 사용자 메시지 연동 (v0.5.56)
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
DOM Observer(`observer-script.ts`)가 AI 채팅을 `innerText`로 추출하며 잃어버리는 마크다운 서식을 복원하고, 사용자(User) 메시지도 포착하여 함께 Discord 봇으로 보내기 (#634 이슈).
|
||||||
|
|
||||||
|
### 변경 사항
|
||||||
|
1. **`convertNodeToMarkdown` 파서 확장**:
|
||||||
|
- AI 채팅창의 DOM Tree를 순회하며 `<h1>`~`<h4>`, `<p>`, `<ul>`, `<ol>`, `<li>`, `<strong>`, `<em>`, `<code>`, `<pre>`, `<blockquote>` 등 대부분의 마크다운 요소를 파싱하는 로직 도입.
|
||||||
|
- 추가로 `<a>` 태그(Link) 속성을 지원하여 `[text](href)` 형태로 복원하도록 개선.
|
||||||
|
2. **파괴적인 `cleanLines()` 노이즈 필터 제거**:
|
||||||
|
- 이전에 사용되던 `cleanLines()`가 `}[공백]`이나 `import` 같은 코드를 UI 노이즈로 오인하여 삭제(Drop)하는 심각한 이슈를 발견. 전체 마크다운 문자열에는 해당 필터를 적용하지 않고 정규식을 통해 `Thought for X s` 형태의 메시지만 지우도록 수정.
|
||||||
|
3. **User 메시지 대상 추가**:
|
||||||
|
- `scanChatBodies()`의 탐색 Selector에 `.text-ide-message-block-user-color`, `.bg-ide-message-block-user-background` 등을 추가하여 사용자 메시지 블록도 대상에 포함.
|
||||||
|
- 데이터 전송 시 `role: 'user'` 정보를 보내고, `http-bridge.ts`에서 이를 구분하여 헤더를 `🧑💻 **[DOM 추출] 사용자 요청**`로 지정해 Discord로 릴레이.
|
||||||
|
|
||||||
|
### 결과
|
||||||
|
`v0.5.56` VSIX 배포 준비 완료 (v0.5.54/55 빌드는 테스트 과정 중 건너뜀). AG Native에서 확장 설치 캐시를 리셋하거나 직접 VSIX를 설치하면 적용됨.
|
||||||
31
docs/devlog/entries/20260418-001.md
Normal file
31
docs/devlog/entries/20260418-001.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Retry Auto-Approve 흐름 복구 및 Observer 고도화
|
||||||
|
|
||||||
|
- **시간**: 2026-04-18 09:20~23:50
|
||||||
|
- **Commit**: `pending`
|
||||||
|
- **Vikunja**: 신규 생성 예정
|
||||||
|
|
||||||
|
## 결정 사항
|
||||||
|
|
||||||
|
### 1. `_from_ws` 마커 기반 response 파일 보존
|
||||||
|
- **문제**: WS 응답 핸들러가 response 파일 작성 → processResponseFile이 300ms 후 삭제 → Observer pollResponseGroup 실패
|
||||||
|
- **선택**: response 파일에 `_from_ws: true` 마커 추가, processResponseFile에서 스킵
|
||||||
|
- **이유**: pending 파일 생성을 추가하는 것보다 단순하고, WS 핸들러에서 이미 tryApprovalStrategies를 실행하므로 중복 실행 방지도 함께 해결
|
||||||
|
|
||||||
|
### 2. 형제(sibling) DOM 탐색
|
||||||
|
- **문제**: "Always run" 버튼의 조상(parentElement) 탐색으로는 `pre.font-mono` 도달 불가 (footer.parentElement가 null)
|
||||||
|
- **선택**: 각 depth에서 `node.parentElement.children`을 순회하여 형제 요소의 code 블록 탐색
|
||||||
|
- **이유**: AG Native DOM 구조에서 명령어는 footer의 형제 요소에 있으므로 조상 탐색만으로는 구조적으로 불가
|
||||||
|
|
||||||
|
### 3. Thinking 블록 필터링
|
||||||
|
- **문제**: AI의 내부 사고 과정이 Discord에 릴레이됨
|
||||||
|
- **선택**: `max-h-[200px]` 조상 확인으로 thinking 블록 식별
|
||||||
|
- **이유**: thinking 블록은 접힌 상태에서 max-height가 200px로 제한되는 특징이 있음
|
||||||
|
|
||||||
|
## 시행착오
|
||||||
|
1. depth 5→10 증가만으로 해결 시도 → 실패 (조상이 아닌 형제에 명령어가 있었음)
|
||||||
|
2. Observer HTML 변경 후 Reload Window만 실행 → 실패 (AG 2번 재시작 필요)
|
||||||
|
3. response 파일이 삭제되는 원인을 clickTrigger 타이밍으로 오인 → 실제는 processResponseFile의 isDomObserver 판별 실패
|
||||||
|
|
||||||
|
## 미완료
|
||||||
|
- [ ] 명령어 컨텍스트 추출 타이밍 이슈 (DOM 렌더링 전 scan 시 추출 실패)
|
||||||
|
- [ ] Observer pollResponseGroup이 시작되지 않는 케이스 (POST /pending 이전에 trigger-click 소비)
|
||||||
@@ -397,6 +397,23 @@ async function activate(context) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Normal approval — tryApprovalStrategies
|
// Normal approval — tryApprovalStrategies
|
||||||
|
// v22: ALSO write response file so Observer's pollResponseGroup can click
|
||||||
|
// the correct button (with exact button_index). Without this, only the
|
||||||
|
// imprecise pollTriggerClick fallback was used for WS-path responses.
|
||||||
|
const responseDir = path.join(bridgePath, 'response');
|
||||||
|
if (!fs.existsSync(responseDir)) {
|
||||||
|
fs.mkdirSync(responseDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const respPayload = {
|
||||||
|
request_id: data.request_id,
|
||||||
|
approved,
|
||||||
|
button_index: data.button_index ?? 0,
|
||||||
|
step_type: stepType,
|
||||||
|
project_name: projectName,
|
||||||
|
_from_ws: true,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(path.join(responseDir, `${data.request_id}.json`), JSON.stringify(respPayload), 'utf-8');
|
||||||
|
logToFile(`[WS-RESPONSE] Response file written for pollResponseGroup: ${data.request_id?.substring(0, 12)}`);
|
||||||
const approvalCtx = (0, step_probe_1.getApprovalContext)();
|
const approvalCtx = (0, step_probe_1.getApprovalContext)();
|
||||||
logToFile(`[WS-RESPONSE] Triggering approval: approved=${approved} session=${approvalCtx.sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${approvalCtx.stepIndex}`);
|
logToFile(`[WS-RESPONSE] Triggering approval: approved=${approved} session=${approvalCtx.sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${approvalCtx.stepIndex}`);
|
||||||
(0, step_probe_1.tryApprovalStrategies)(approved, approvalCtx.sessionId, stepType, approvalCtx.stepIndex)
|
(0, step_probe_1.tryApprovalStrategies)(approved, approvalCtx.sessionId, stepType, approvalCtx.stepIndex)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
4
extension/package-lock.json
generated
4
extension/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "gravity-bridge",
|
"name": "gravity-bridge",
|
||||||
"version": "0.5.34",
|
"version": "0.5.79",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gravity-bridge",
|
"name": "gravity-bridge",
|
||||||
"version": "0.5.34",
|
"version": "0.5.79",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "gravity-bridge",
|
"name": "gravity-bridge",
|
||||||
"displayName": "Gravity Bridge",
|
"displayName": "Gravity Bridge",
|
||||||
"description": "Discord-based unified approval system for Antigravity AI interactions.",
|
"description": "Discord-based unified approval system for Antigravity AI interactions.",
|
||||||
"version": "0.5.54",
|
"version": "0.5.79",
|
||||||
"publisher": "variet",
|
"publisher": "variet",
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.100.0"
|
"vscode": "^1.100.0"
|
||||||
|
|||||||
28
extension/scratch/analyze_dump.js
Normal file
28
extension/scratch/analyze_dump.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
const dump = JSON.parse(fs.readFileSync(
|
||||||
|
path.join(os.homedir(), '.gemini', 'antigravity', 'bridge', 'dump_html.json'), 'utf-8'
|
||||||
|
));
|
||||||
|
|
||||||
|
const bodyStr = JSON.stringify(dump.body);
|
||||||
|
|
||||||
|
// Find all unique tag names
|
||||||
|
const tagMatches = bodyStr.match(/"tag":"[a-z0-9]+"/g) || [];
|
||||||
|
const uniqueTags = [...new Set(tagMatches)];
|
||||||
|
console.log('=== Unique DOM tags ===');
|
||||||
|
console.log(uniqueTags.sort().join('\n'));
|
||||||
|
|
||||||
|
// Check for pipe characters (markdown table syntax)
|
||||||
|
console.log('\n=== Pipe | in text content ===');
|
||||||
|
const pipeMatches = [...bodyStr.matchAll(/"text":"[^"]*\|[^"]*"/g)];
|
||||||
|
console.log(`Found ${pipeMatches.length} text nodes with pipe |`);
|
||||||
|
pipeMatches.slice(0, 5).forEach(m => console.log(' ', m[0].substring(0, 120)));
|
||||||
|
|
||||||
|
// Check for table-related class names
|
||||||
|
console.log('\n=== Table-related classes ===');
|
||||||
|
const classMatches = bodyStr.match(/"cls":"[^"]*"/g) || [];
|
||||||
|
const tableClasses = classMatches.filter(c => /table|grid|cell|col|row/i.test(c));
|
||||||
|
console.log(`Found ${tableClasses.length} table-related classes`);
|
||||||
|
[...new Set(tableClasses)].slice(0, 10).forEach(c => console.log(' ', c));
|
||||||
37
extension/scratch/discord_channels.js
Normal file
37
extension/scratch/discord_channels.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// List all channels in the guild
|
||||||
|
const https = require('https');
|
||||||
|
const TOKEN = 'MTQ3OTY0ODcxNjA1MzgwNzI4NQ.GVMGbd.WN7BliH8oq9fqbaiQcyxXesJTYgBx-ObsDkK7o';
|
||||||
|
const GUILD_ID = '1478722210460991662';
|
||||||
|
|
||||||
|
function apiGet(path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const opts = {
|
||||||
|
hostname: 'discord.com',
|
||||||
|
path: `/api/v10${path}`,
|
||||||
|
headers: { 'Authorization': `Bot ${TOKEN}` }
|
||||||
|
};
|
||||||
|
https.get(opts, res => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', c => data += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(data)); } catch { resolve(data); }
|
||||||
|
});
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const channels = await apiGet(`/guilds/${GUILD_ID}/channels`);
|
||||||
|
if (!Array.isArray(channels)) {
|
||||||
|
console.log('Error:', channels);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`Total channels: ${channels.length}\n`);
|
||||||
|
channels.sort((a,b) => (a.position||0) - (b.position||0));
|
||||||
|
channels.forEach(c => {
|
||||||
|
const type = ['TEXT','DM','VOICE','GROUP_DM','CATEGORY','ANNOUNCE','','','','','','THREAD','THREAD','THREAD','','FORUM','MEDIA'][c.type] || c.type;
|
||||||
|
console.log(`${c.id} | ${type.padEnd(10)} | #${c.name}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => console.error(e));
|
||||||
55
extension/scratch/discord_read.js
Normal file
55
extension/scratch/discord_read.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// Read latest Discord messages from ag-gravity_control channel
|
||||||
|
const https = require('https');
|
||||||
|
const TOKEN = 'MTQ3OTY0ODcxNjA1MzgwNzI4NQ.GVMGbd.WN7BliH8oq9fqbaiQcyxXesJTYgBx-ObsDkK7o';
|
||||||
|
const CHANNEL_ID = '1483082084540223663';
|
||||||
|
|
||||||
|
function apiGet(path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const opts = {
|
||||||
|
hostname: 'discord.com',
|
||||||
|
path: `/api/v10${path}`,
|
||||||
|
headers: { 'Authorization': `Bot ${TOKEN}` }
|
||||||
|
};
|
||||||
|
https.get(opts, res => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', c => data += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(data)); } catch { resolve(data); }
|
||||||
|
});
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const limit = process.argv[2] || 15;
|
||||||
|
const msgs = await apiGet(`/channels/${CHANNEL_ID}/messages?limit=${limit}`);
|
||||||
|
if (!Array.isArray(msgs)) {
|
||||||
|
console.log('Error:', JSON.stringify(msgs));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`=== #ag-gravity_control — Last ${msgs.length} messages ===\n`);
|
||||||
|
|
||||||
|
msgs.reverse().forEach(m => {
|
||||||
|
const time = new Date(m.timestamp).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
const author = m.author?.username || '?';
|
||||||
|
|
||||||
|
if (m.embeds?.length > 0) {
|
||||||
|
m.embeds.forEach(e => {
|
||||||
|
const title = e.title || '(no title)';
|
||||||
|
const desc = (e.description || '').substring(0, 300);
|
||||||
|
const colorHex = e.color ? `#${e.color.toString(16).padStart(6, '0')}` : 'default';
|
||||||
|
const footer = e.footer?.text || '';
|
||||||
|
console.log(`[${time}] 📦 EMBED [${colorHex}] ${title}`);
|
||||||
|
if (desc) console.log(` ${desc.replace(/\n/g, '\n ')}`);
|
||||||
|
if (footer) console.log(` 📎 ${footer}`);
|
||||||
|
});
|
||||||
|
} else if (m.content) {
|
||||||
|
const content = m.content.substring(0, 300);
|
||||||
|
console.log(`[${time}] 💬 ${author}: ${content}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => console.error(e));
|
||||||
29
extension/scratch/find_user_msg.js
Normal file
29
extension/scratch/find_user_msg.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
const d = JSON.parse(fs.readFileSync(
|
||||||
|
path.join(os.homedir(), '.gemini', 'antigravity', 'bridge', 'dump_html.json'), 'utf-8'
|
||||||
|
));
|
||||||
|
const s = JSON.stringify(d.body);
|
||||||
|
|
||||||
|
console.log('title:', d.quickInfo.title);
|
||||||
|
console.log('Has id=conversation:', s.includes('"id":"conversation"'));
|
||||||
|
console.log('Has agent-side-panel:', s.includes('antigravity-agent-side-panel'));
|
||||||
|
|
||||||
|
// Find message-block patterns
|
||||||
|
const mb = [...s.matchAll(/message-block/g)];
|
||||||
|
console.log('message-block occurrences:', mb.length);
|
||||||
|
|
||||||
|
// Find user-related class patterns
|
||||||
|
const userPatterns = ['user-color', 'user-background', 'user-message', 'user-query', 'user-input', 'human'];
|
||||||
|
userPatterns.forEach(p => {
|
||||||
|
const cnt = [...s.matchAll(new RegExp(p, 'gi'))].length;
|
||||||
|
if (cnt > 0) console.log(` ${p}: ${cnt} occurrences`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show all unique classes that include 'message' or 'chat' or 'conversation'
|
||||||
|
const clsMatches = [...s.matchAll(/"cls":"([^"]*(?:message|chat|conversation|query|user|human)[^"]*)"/gi)];
|
||||||
|
console.log('\nClasses with message/chat/conversation/user/human:');
|
||||||
|
const uniq = [...new Set(clsMatches.map(m => m[1]))];
|
||||||
|
uniq.forEach(c => console.log(' ', c.substring(0, 120)));
|
||||||
@@ -205,6 +205,17 @@ async function processResponseFile(filePath: string) {
|
|||||||
}
|
}
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
const resp = JSON.parse(content);
|
const resp = JSON.parse(content);
|
||||||
|
|
||||||
|
// v22: Skip files written by the WS response handler (extension.ts onResponse).
|
||||||
|
// Those files exist ONLY for Observer's pollResponseGroup to read via HTTP.
|
||||||
|
// The WS handler already calls tryApprovalStrategies, so processing here is redundant.
|
||||||
|
// Without this skip, the watcher deletes the file before Observer can poll it
|
||||||
|
// (since no pending file exists for the isDomObserver check).
|
||||||
|
if (resp._from_ws) {
|
||||||
|
ctx.logToFile(`[RESPONSE] SKIP _from_ws file (for Observer pollResponseGroup): ${resp.request_id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved} step_type=${resp.step_type || '(missing)'} keys=[${Object.keys(resp).join(',')}]`;
|
const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved} step_type=${resp.step_type || '(missing)'} keys=[${Object.keys(resp).join(',')}]`;
|
||||||
console.log(`Gravity Bridge: ${msg}`);
|
console.log(`Gravity Bridge: ${msg}`);
|
||||||
ctx.logToFile(msg);
|
ctx.logToFile(msg);
|
||||||
|
|||||||
@@ -388,6 +388,28 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Normal approval — tryApprovalStrategies
|
// Normal approval — tryApprovalStrategies
|
||||||
|
// v22: ALSO write response file so Observer's pollResponseGroup can click
|
||||||
|
// the correct button (with exact button_index). Without this, only the
|
||||||
|
// imprecise pollTriggerClick fallback was used for WS-path responses.
|
||||||
|
const responseDir = path.join(bridgePath, 'response');
|
||||||
|
if (!fs.existsSync(responseDir)) {
|
||||||
|
fs.mkdirSync(responseDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const respPayload = {
|
||||||
|
request_id: data.request_id,
|
||||||
|
approved,
|
||||||
|
button_index: data.button_index ?? 0,
|
||||||
|
step_type: stepType,
|
||||||
|
project_name: projectName,
|
||||||
|
_from_ws: true,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(responseDir, `${data.request_id}.json`),
|
||||||
|
JSON.stringify(respPayload),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
logToFile(`[WS-RESPONSE] Response file written for pollResponseGroup: ${data.request_id?.substring(0, 12)}`);
|
||||||
|
|
||||||
const approvalCtx = getApprovalContext();
|
const approvalCtx = getApprovalContext();
|
||||||
logToFile(`[WS-RESPONSE] Triggering approval: approved=${approved} session=${approvalCtx.sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${approvalCtx.stepIndex}`);
|
logToFile(`[WS-RESPONSE] Triggering approval: approved=${approved} session=${approvalCtx.sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${approvalCtx.stepIndex}`);
|
||||||
tryApprovalStrategies(approved, approvalCtx.sessionId, stepType, approvalCtx.stepIndex)
|
tryApprovalStrategies(approved, approvalCtx.sessionId, stepType, approvalCtx.stepIndex)
|
||||||
|
|||||||
@@ -126,6 +126,21 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<numbe
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /log — renderer relays important diagnostic logs
|
||||||
|
if (req.method === 'POST' && url.pathname === '/log') {
|
||||||
|
let logBody = '';
|
||||||
|
req.setEncoding('utf8');
|
||||||
|
req.on('data', (c: string) => logBody += c);
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const logData = JSON.parse(logBody);
|
||||||
|
ctx.logToFile(`[OBSERVER-LOG] ${logData.msg || logBody.substring(0, 500)}`);
|
||||||
|
} catch { ctx.logToFile(`[OBSERVER-LOG] ${logBody.substring(0, 500)}`); }
|
||||||
|
res.writeHead(200); res.end('ok');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && url.pathname === '/dump-html') {
|
if (req.method === 'POST' && url.pathname === '/dump-html') {
|
||||||
let dumpBody = '';
|
let dumpBody = '';
|
||||||
req.setEncoding('utf8');
|
req.setEncoding('utf8');
|
||||||
@@ -266,6 +281,94 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
|
|||||||
const GENERIC_BTN_RE = /^(?:Always\s*)?(?:Run|Allow|Accept|Approve)$/i;
|
const GENERIC_BTN_RE = /^(?:Always\s*)?(?:Run|Allow|Accept|Approve)$/i;
|
||||||
let enrichedCmd = rawCmd;
|
let enrichedCmd = rawCmd;
|
||||||
let enrichedDesc = rawDesc;
|
let enrichedDesc = rawDesc;
|
||||||
|
|
||||||
|
// v19: "Always run" auto-approve — MUST run BEFORE any filter can reject it
|
||||||
|
// Detects from rawCmd OR from buttons array (Observer may detect sibling first)
|
||||||
|
let alwaysRunDetected = /^Always\s+run$/i.test(rawCmd);
|
||||||
|
let alwaysRunBtnIndex = alwaysRunDetected ? 0 : -1;
|
||||||
|
if (!alwaysRunDetected && Array.isArray(data.buttons)) {
|
||||||
|
for (let bi = 0; bi < data.buttons.length; bi++) {
|
||||||
|
if (/^Always\s+run$/i.test((data.buttons[bi].text || '').trim())) {
|
||||||
|
alwaysRunDetected = true;
|
||||||
|
alwaysRunBtnIndex = bi;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (alwaysRunDetected) {
|
||||||
|
// Try enrichment for better Discord display text
|
||||||
|
let displayCmd = rawCmd;
|
||||||
|
ctx.logToFile(`[HTTP] AUTO-APPROVE raw: cmd="${rawCmd}" desc="${rawDesc.substring(0, 120)}" buttons=${JSON.stringify((data.buttons || []).map((b: any) => b.text)).substring(0, 200)}`);
|
||||||
|
if (GENERIC_BTN_RE.test(rawCmd) && rawDesc.length > 3 && rawDesc !== rawCmd) {
|
||||||
|
const promptMatch = rawDesc.match(/[>»]\s*(.+)/);
|
||||||
|
if (promptMatch && promptMatch[1].trim().length > 3) {
|
||||||
|
displayCmd = promptMatch[1].trim().substring(0, 200);
|
||||||
|
} else {
|
||||||
|
// v19: Fallback — use longest line from description as command text
|
||||||
|
const descLines = rawDesc.split(/\n/).map((l: string) => l.trim()).filter((l: string) => l.length > 3);
|
||||||
|
if (descLines.length > 0) {
|
||||||
|
descLines.sort((a: string, b: string) => b.length - a.length);
|
||||||
|
displayCmd = descLines[0].substring(0, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// v20: Final fallback — read latest Step Probe pending file for actual command
|
||||||
|
if (displayCmd === rawCmd && GENERIC_BTN_RE.test(displayCmd)) {
|
||||||
|
try {
|
||||||
|
const pendingDir = path.join(ctx.bridgePath, 'pending');
|
||||||
|
if (fs.existsSync(pendingDir)) {
|
||||||
|
const pFiles = fs.readdirSync(pendingDir)
|
||||||
|
.filter((f: string) => f.endsWith('.json'))
|
||||||
|
.map((f: string) => ({ name: f, time: fs.statSync(path.join(pendingDir, f)).mtimeMs }))
|
||||||
|
.sort((a: any, b: any) => b.time - a.time);
|
||||||
|
if (pFiles.length > 0 && (Date.now() - pFiles[0].time) < 30_000) {
|
||||||
|
const pData = JSON.parse(fs.readFileSync(path.join(pendingDir, pFiles[0].name), 'utf-8'));
|
||||||
|
if (pData.command && pData.command.length > 3 && !GENERIC_BTN_RE.test(pData.command)) {
|
||||||
|
displayCmd = pData.command.substring(0, 200);
|
||||||
|
ctx.logToFile(`[HTTP] AUTO-APPROVE enriched from pending file: "${displayCmd.substring(0, 80)}"`);
|
||||||
|
} else if (pData.description && pData.description.length > 5) {
|
||||||
|
displayCmd = pData.description.substring(0, 200);
|
||||||
|
ctx.logToFile(`[HTTP] AUTO-APPROVE enriched from pending desc: "${displayCmd.substring(0, 80)}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) { ctx.logToFile(`[HTTP] AUTO-APPROVE pending lookup error: ${e.message}`); }
|
||||||
|
}
|
||||||
|
const rid = data.request_id || Date.now().toString();
|
||||||
|
ctx.logToFile(`[HTTP] AUTO-APPROVE "Always run" (btnIdx=${alwaysRunBtnIndex}): cmd="${displayCmd.substring(0, 80)}"`);
|
||||||
|
// Write response file so observer's pollResponseGroup picks it up and clicks the button
|
||||||
|
const responseDir = path.join(ctx.bridgePath, 'response');
|
||||||
|
if (!fs.existsSync(responseDir)) {
|
||||||
|
fs.mkdirSync(responseDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const respPayload = {
|
||||||
|
request_id: rid,
|
||||||
|
approved: true,
|
||||||
|
button_index: alwaysRunBtnIndex >= 0 ? alwaysRunBtnIndex : 0,
|
||||||
|
step_type: data.step_type || 'command',
|
||||||
|
project_name: ctx.projectName,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(responseDir, `${rid}.json`),
|
||||||
|
JSON.stringify(respPayload),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
// Notify Discord (non-interactive "자동 승인" embed)
|
||||||
|
if (ctx.wsBridge && ctx.wsBridge.isConnected()) {
|
||||||
|
ctx.wsBridge.sendPending({
|
||||||
|
request_id: rid,
|
||||||
|
command: displayCmd,
|
||||||
|
description: rawDesc ? `[${rawCmd}] ${rawDesc}` : rawCmd,
|
||||||
|
step_type: data.step_type || 'command',
|
||||||
|
status: 'auto_approved',
|
||||||
|
buttons: data.buttons,
|
||||||
|
project_name: ctx.projectName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: true, request_id: rid, auto_approved: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (GENERIC_BTN_RE.test(rawCmd) && rawDesc.length > 10 && rawDesc !== rawCmd) {
|
if (GENERIC_BTN_RE.test(rawCmd) && rawDesc.length > 10 && rawDesc !== rawCmd) {
|
||||||
// Extract the actual command from description (often includes terminal prompt)
|
// Extract the actual command from description (often includes terminal prompt)
|
||||||
// Pattern: "…\project_name > actual_command"
|
// Pattern: "…\project_name > actual_command"
|
||||||
@@ -361,44 +464,8 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
|
|||||||
buttons: data.buttons,
|
buttons: data.buttons,
|
||||||
step_index: ctx.lastPendingStepIndex >= 0 ? ctx.lastPendingStepIndex : undefined,
|
step_index: ctx.lastPendingStepIndex >= 0 ? ctx.lastPendingStepIndex : undefined,
|
||||||
};
|
};
|
||||||
// v17: "Always run" auto-approve — click button immediately without Discord roundtrip
|
// v19: "Always run" auto-approve was already handled above (before filter chain)
|
||||||
// rawCmd is the original button text before enrichment. "Always run" means the user
|
// No need for duplicate check here.
|
||||||
// already trusts this command pattern, so we auto-approve at the bridge level.
|
|
||||||
if (/^Always\s+run$/i.test(rawCmd)) {
|
|
||||||
ctx.logToFile(`[HTTP] AUTO-APPROVE "Always run": enriched="${enrichedCmd.substring(0, 80)}"`);
|
|
||||||
// Write response file so observer's pollResponseGroup picks it up and clicks the button
|
|
||||||
const responseDir = path.join(ctx.bridgePath, 'response');
|
|
||||||
if (!fs.existsSync(responseDir)) {
|
|
||||||
fs.mkdirSync(responseDir, { recursive: true });
|
|
||||||
}
|
|
||||||
const respPayload = {
|
|
||||||
request_id: rid,
|
|
||||||
approved: true,
|
|
||||||
button_index: 0, // "Always run" is always the first button
|
|
||||||
step_type: data.step_type || 'command',
|
|
||||||
project_name: ctx.projectName,
|
|
||||||
};
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(responseDir, `${rid}.json`),
|
|
||||||
JSON.stringify(respPayload),
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
// Notify Discord (non-interactive "자동 승인" embed)
|
|
||||||
if (ctx.wsBridge && ctx.wsBridge.isConnected()) {
|
|
||||||
ctx.wsBridge.sendPending({
|
|
||||||
request_id: rid,
|
|
||||||
command: enrichedCmd || rawCmd,
|
|
||||||
description: enrichedDesc,
|
|
||||||
step_type: pending.step_type,
|
|
||||||
status: 'auto_approved',
|
|
||||||
buttons: pending.buttons,
|
|
||||||
project_name: ctx.projectName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ ok: true, request_id: rid, auto_approved: true }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// File permission: inject multi-choice buttons
|
// File permission: inject multi-choice buttons
|
||||||
const cmdLower = enrichedCmd.toLowerCase();
|
const cmdLower = enrichedCmd.toLowerCase();
|
||||||
if (cmdLower.includes('allow') && !pending.buttons) {
|
if (cmdLower.includes('allow') && !pending.buttons) {
|
||||||
@@ -544,8 +611,10 @@ function _handleChatSnapshot(req: any, res: any, ctx: HttpBridgeContext) {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(body);
|
const data = JSON.parse(body);
|
||||||
if (data.text && typeof ctx.writeChatSnapshot === 'function') {
|
if (data.text && typeof ctx.writeChatSnapshot === 'function') {
|
||||||
ctx.writeChatSnapshot(`💬 **[DOM 추출] AI 응답**\n\n${data.text}`);
|
const isUser = data.role === 'user';
|
||||||
ctx.logToFile(`[HTTP] chat snapshot written (${data.text.length} chars)`);
|
const prefix = isUser ? '🧑💻 **[DOM 추출] 사용자 요청**' : '💬 **[DOM 추출] AI 응답**';
|
||||||
|
ctx.writeChatSnapshot(`${prefix}\n\n${data.text}`);
|
||||||
|
ctx.logToFile(`[HTTP] chat snapshot written (${data.text.length} chars, role: ${data.role || 'bot'})`);
|
||||||
}
|
}
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ ok: true }));
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
var THROTTLE_MS=500;
|
var THROTTLE_MS=500;
|
||||||
var CLEANUP_MS=300000;
|
var CLEANUP_MS=300000;
|
||||||
|
|
||||||
function log(m){console.log('[GB Observer] '+m);}
|
function log(m){
|
||||||
|
console.log('[GB Observer] '+m);
|
||||||
|
// v19: Relay important logs to extension via HTTP so they appear in extension.log
|
||||||
|
if (BASE && (m.indexOf('CV-CLASSES')!==-1 || m.indexOf('CV-CHILDREN')!==-1 || m.indexOf('child[')!==-1 || m.indexOf('CV found')!==-1 || m.indexOf('Conversation view')!==-1 || m.indexOf('BEACON')!==-1 || m.indexOf('ERROR')!==-1 || m.indexOf('chat relay')!==-1 || m.indexOf('user-cls')!==-1)) {
|
||||||
|
try { fetch(BASE+'/log', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({msg:m.substring(0,2000)})}); } catch(e){}
|
||||||
|
}
|
||||||
|
}
|
||||||
log('v17 Script loaded — Always Run Auto-Approve + Retry Detection');
|
log('v17 Script loaded — Always Run Auto-Approve + Retry Detection');
|
||||||
|
|
||||||
// DIAGNOSTIC BEACON: immediate POST to confirm script execution in renderer
|
// DIAGNOSTIC BEACON: immediate POST to confirm script execution in renderer
|
||||||
@@ -138,9 +144,11 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
var _bestCodeHeader = '';
|
var _bestCodeHeader = '';
|
||||||
var _sawCodeEls = false;
|
var _sawCodeEls = false;
|
||||||
var _allSkipped = true;
|
var _allSkipped = true;
|
||||||
for (var depth = 0; depth < 5 && node; depth++) {
|
// v22: Increased from 5 to 10 — AG Native command display (SRi) can be many levels up
|
||||||
|
for (var depth = 0; depth < 10 && node; depth++) {
|
||||||
if (!node.querySelector) { _debugTrail.push('d'+depth+':noQS'); node = node.parentElement; continue; }
|
if (!node.querySelector) { _debugTrail.push('d'+depth+':noQS'); node = node.parentElement; continue; }
|
||||||
var codeEls = node.querySelectorAll('pre, code, [class*="terminal"]');
|
// v22: Prioritize pre.font-mono (AG Native command line display from SRi component)
|
||||||
|
var codeEls = node.querySelectorAll('pre.font-mono, pre, code, [class*="terminal"]');
|
||||||
_debugTrail.push('d'+depth+':tag='+((node.tagName||'?').toLowerCase())+',cls='+(((typeof node.className==='string')?node.className:'').substring(0,60))+',codeEls='+codeEls.length);
|
_debugTrail.push('d'+depth+':tag='+((node.tagName||'?').toLowerCase())+',cls='+(((typeof node.className==='string')?node.className:'').substring(0,60))+',codeEls='+codeEls.length);
|
||||||
for (var ci = 0; ci < codeEls.length; ci++) {
|
for (var ci = 0; ci < codeEls.length; ci++) {
|
||||||
var codeText = cleanLines((codeEls[ci].textContent || '').trim().substring(0, 500));
|
var codeText = cleanLines((codeEls[ci].textContent || '').trim().substring(0, 500));
|
||||||
@@ -181,6 +189,33 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
_lastContextDebug = _debugTrail.join(' > ');
|
_lastContextDebug = _debugTrail.join(' > ');
|
||||||
return parts.join(' \u2014 ');
|
return parts.join(' \u2014 ');
|
||||||
}
|
}
|
||||||
|
// v23: Also search sibling elements at each level
|
||||||
|
// AG Native's command display (pre.font-mono) is a SIBLING of footer, not ancestor
|
||||||
|
if (node && node.parentElement) {
|
||||||
|
var siblings = node.parentElement.children;
|
||||||
|
for (var si = 0; si < siblings.length; si++) {
|
||||||
|
if (siblings[si] === node) continue;
|
||||||
|
if (!siblings[si].querySelector) continue;
|
||||||
|
var sibCodeEls = siblings[si].querySelectorAll('pre.font-mono, pre, code');
|
||||||
|
for (var sci = 0; sci < sibCodeEls.length; sci++) {
|
||||||
|
var sibCode = cleanLines((sibCodeEls[sci].textContent || '').trim().substring(0, 500));
|
||||||
|
if (!sibCode || sibCode.length <= 5) continue;
|
||||||
|
if (JUNK_CODE_RE.test(sibCode) || ICON_GLUE_RE.test(sibCode)) continue;
|
||||||
|
if (PROMPT_ONLY_RE.test(sibCode.trim())) continue;
|
||||||
|
_debugTrail.push('sibling_d'+depth+':tag='+siblings[si].tagName.toLowerCase()+',code='+sibCode.substring(0,40));
|
||||||
|
_bestCodeText = sibCode;
|
||||||
|
_allSkipped = false;
|
||||||
|
// Found in sibling — return immediately
|
||||||
|
var sibParts = [];
|
||||||
|
var sibHdr = siblings[si].querySelector('h1, h2, h3, [class*="header"], [class*="title"], [class*="cursor-pointer"]');
|
||||||
|
if (sibHdr) sibParts.push(cleanLines((sibHdr.textContent || '').trim().substring(0, 200)));
|
||||||
|
sibParts.push(sibCode);
|
||||||
|
log('CONTEXT-OK d='+depth+' src=sibling trail='+_debugTrail.join(' > '));
|
||||||
|
_lastContextDebug = _debugTrail.join(' > ');
|
||||||
|
return sibParts.join(' \u2014 ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
node = node.parentElement;
|
node = node.parentElement;
|
||||||
}
|
}
|
||||||
if (_sawCodeEls && _allSkipped) {
|
if (_sawCodeEls && _allSkipped) {
|
||||||
@@ -338,7 +373,8 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
// ══════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
var _dumpCount=0;
|
var _dumpCount=0;
|
||||||
var MAX_DUMPS=5;
|
var MAX_DUMPS=8;
|
||||||
|
var _conversationDumpCount=0;
|
||||||
|
|
||||||
function walkNode(el, depth, maxDepth, maxChildren) {
|
function walkNode(el, depth, maxDepth, maxChildren) {
|
||||||
if (depth > maxDepth) return {tag:'…',text:'depth limit'};
|
if (depth > maxDepth) return {tag:'…',text:'depth limit'};
|
||||||
@@ -484,15 +520,118 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
var _lastStepTextSent = false;
|
var _lastStepTextSent = false;
|
||||||
var _lastResponseBlockCount = 0; // track number of response blocks for AG Native
|
var _lastResponseBlockCount = 0; // track number of response blocks for AG Native
|
||||||
|
|
||||||
|
function convertNodeToMarkdown(node) {
|
||||||
|
if (!node) return '';
|
||||||
|
if (node.nodeType === 3) return node.textContent; // Text node
|
||||||
|
if (node.nodeType !== 1) return ''; // Skip other node types
|
||||||
|
|
||||||
|
var tag = node.tagName.toLowerCase();
|
||||||
|
|
||||||
|
// Skip hidden or UI elements
|
||||||
|
if (tag === 'style' || tag === 'script' || tag === 'noscript' || tag === 'button' || tag === 'svg') return '';
|
||||||
|
var cls = '';
|
||||||
|
if (typeof node.className === 'string') cls = node.className;
|
||||||
|
else if (node.className && node.className.baseVal) cls = node.className.baseVal;
|
||||||
|
|
||||||
|
if (cls && (cls.indexOf('google-symbols') !== -1 || cls.indexOf('material-icons') !== -1 || cls.indexOf('copy') !== -1 || cls.indexOf('codicon') !== -1)) return '';
|
||||||
|
|
||||||
|
var childrenMd = '';
|
||||||
|
for (var i = 0; i < node.childNodes.length; i++) {
|
||||||
|
childrenMd += convertNodeToMarkdown(node.childNodes[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TABLE: Discord doesn't support markdown tables, so convert to fixed-width code block
|
||||||
|
if (tag === 'table') {
|
||||||
|
var rows = node.querySelectorAll('tr');
|
||||||
|
if (!rows || rows.length === 0) return childrenMd;
|
||||||
|
var grid = [];
|
||||||
|
var colWidths = [];
|
||||||
|
for (var ri = 0; ri < rows.length; ri++) {
|
||||||
|
var cells = rows[ri].querySelectorAll('th, td');
|
||||||
|
var row = [];
|
||||||
|
for (var ci = 0; ci < cells.length; ci++) {
|
||||||
|
var cellText = (cells[ci].textContent || '').trim();
|
||||||
|
row.push(cellText);
|
||||||
|
if (!colWidths[ci] || cellText.length > colWidths[ci]) colWidths[ci] = cellText.length;
|
||||||
|
}
|
||||||
|
grid.push(row);
|
||||||
|
}
|
||||||
|
// Build fixed-width text
|
||||||
|
var tbl = '';
|
||||||
|
for (var ri2 = 0; ri2 < grid.length; ri2++) {
|
||||||
|
var line = '';
|
||||||
|
for (var ci2 = 0; ci2 < colWidths.length; ci2++) {
|
||||||
|
var cell = grid[ri2][ci2] || '';
|
||||||
|
var pad = colWidths[ci2] - cell.length;
|
||||||
|
var padding = '';
|
||||||
|
for (var pi = 0; pi < pad; pi++) padding += ' ';
|
||||||
|
line += (ci2 > 0 ? ' | ' : '') + cell + padding;
|
||||||
|
}
|
||||||
|
tbl += line + '\\n';
|
||||||
|
// Add separator after header row (first row)
|
||||||
|
if (ri2 === 0) {
|
||||||
|
var sep = '';
|
||||||
|
for (var si2 = 0; si2 < colWidths.length; si2++) {
|
||||||
|
var dashes = '';
|
||||||
|
for (var di = 0; di < colWidths[si2]; di++) dashes += '-';
|
||||||
|
sep += (si2 > 0 ? '-+-' : '') + dashes;
|
||||||
|
}
|
||||||
|
tbl += sep + '\\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '\\n' + String.fromCharCode(96,96,96) + '\\n' + tbl + String.fromCharCode(96,96,96) + '\\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (tag) {
|
||||||
|
case 'h1': return '\\n# ' + childrenMd.trim() + '\\n';
|
||||||
|
case 'h2': return '\\n## ' + childrenMd.trim() + '\\n';
|
||||||
|
case 'h3': return '\\n### ' + childrenMd.trim() + '\\n';
|
||||||
|
case 'h4': return '\\n#### ' + childrenMd.trim() + '\\n';
|
||||||
|
case 'p': return '\\n' + childrenMd.trim() + '\\n';
|
||||||
|
case 'div':
|
||||||
|
// Treat specific divs as blocks if they end up behaving like paragraphs
|
||||||
|
if (cls.indexOf('block') !== -1 || cls.indexOf('message') !== -1) return '\\n' + childrenMd.trim() + '\\n';
|
||||||
|
return childrenMd;
|
||||||
|
case 'br': return '\\n';
|
||||||
|
case 'strong':
|
||||||
|
case 'b': return '**' + childrenMd + '**';
|
||||||
|
case 'em':
|
||||||
|
case 'i': return '*' + childrenMd + '*';
|
||||||
|
case 'a':
|
||||||
|
var href = node.getAttribute('href') || '';
|
||||||
|
return '[' + childrenMd + '](' + href + ')';
|
||||||
|
case 'code': return (node.parentNode && node.parentNode.tagName === 'PRE') ? childrenMd : (String.fromCharCode(96) + childrenMd + String.fromCharCode(96));
|
||||||
|
case 'pre': return '\\n' + String.fromCharCode(96,96,96) + '\\n' + childrenMd.trim() + '\\n' + String.fromCharCode(96,96,96) + '\\n';
|
||||||
|
case 'li':
|
||||||
|
var prefix = '- ';
|
||||||
|
if (node.parentNode && node.parentNode.tagName.toLowerCase() === 'ol') {
|
||||||
|
var idx = 1;
|
||||||
|
var curr = node.previousSibling;
|
||||||
|
while(curr) { if (curr.nodeType === 1 && curr.tagName.toLowerCase() === 'li') idx++; curr = curr.previousSibling; }
|
||||||
|
prefix = idx + '. ';
|
||||||
|
}
|
||||||
|
return '\\n' + prefix + childrenMd.trim();
|
||||||
|
case 'ul':
|
||||||
|
case 'ol': return '\\n' + childrenMd + '\\n';
|
||||||
|
case 'blockquote': return '\\n> ' + childrenMd.trim().split('\\n').join('\\n> ') + '\\n';
|
||||||
|
// Table sub-elements: already handled by the table case above via querySelectorAll
|
||||||
|
case 'thead':
|
||||||
|
case 'tbody':
|
||||||
|
case 'tfoot':
|
||||||
|
case 'tr':
|
||||||
|
case 'th':
|
||||||
|
case 'td': return '';
|
||||||
|
default: return childrenMd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function extractCleanStepText(stepEl) {
|
function extractCleanStepText(stepEl) {
|
||||||
if (!stepEl) return '';
|
if (!stepEl) return '';
|
||||||
|
|
||||||
// Clone the step element so we can strip UI elements without affecting the DOM
|
// Clone the step element so we can strip UI elements without affecting the DOM
|
||||||
var clone = stepEl.cloneNode(true);
|
var clone = stepEl.cloneNode(true);
|
||||||
|
|
||||||
// v16: Remove style/script/noscript elements FIRST — AG Native markdown injects <style> blocks
|
// v16: Remove style/script/noscript elements FIRST
|
||||||
// that contain CSS rules (e.g. remark-github-blockquote-alert/alert.css) whose textContent
|
|
||||||
// gets captured as AI response text
|
|
||||||
var styleEls = clone.querySelectorAll('style, script, noscript, link[rel="stylesheet"]');
|
var styleEls = clone.querySelectorAll('style, script, noscript, link[rel="stylesheet"]');
|
||||||
for (var si = 0; si < styleEls.length; si++) {
|
for (var si = 0; si < styleEls.length; si++) {
|
||||||
if (styleEls[si].parentNode) styleEls[si].parentNode.removeChild(styleEls[si]);
|
if (styleEls[si].parentNode) styleEls[si].parentNode.removeChild(styleEls[si]);
|
||||||
@@ -518,34 +657,90 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
|
|
||||||
// Try to get text from markdown rendering area first
|
// Try to get text from markdown rendering area first
|
||||||
// AG Native uses .leading-relaxed.select-text, Cascade uses .markdown-body/.prose
|
// AG Native uses .leading-relaxed.select-text, Cascade uses .markdown-body/.prose
|
||||||
var mdEl = clone.querySelector('.markdown-body, .prose, [class*="markdown"], [class*="rendered"]');
|
var mdEl = clone.querySelector('.markdown-body, .prose, [class*="markdown"], [class*="rendered"]') || clone;
|
||||||
|
|
||||||
// v18 FIX: Temporarily attach to DOM to force layout computation for .innerText
|
// Use our custom DOM-to-Markdown parser instead of innerText
|
||||||
// Without this, .innerText on unattached node behaves exactly like .textContent (loses block newlines)
|
var rawText = convertNodeToMarkdown(mdEl).trim();
|
||||||
var container = document.createElement('div');
|
|
||||||
container.style.position = 'absolute';
|
|
||||||
container.style.left = '-9999px';
|
|
||||||
container.style.top = '-9999px';
|
|
||||||
container.style.opacity = '0';
|
|
||||||
container.style.width = '800px';
|
|
||||||
container.appendChild(clone);
|
|
||||||
document.body.appendChild(container);
|
|
||||||
|
|
||||||
var targetEl = mdEl || clone;
|
// v18 FIX: DO NOT apply cleanLines to full markdown content, it destroys valid code blocks
|
||||||
var rawText = '';
|
// Safely remove "Thought for X" lines only
|
||||||
try {
|
rawText = rawText.replace(/Thought for \\d+s?/gi, '');
|
||||||
if (targetEl.innerText && targetEl.innerText.trim().length > 10) {
|
rawText = rawText.replace(/Thought for a few seconds/gi, '');
|
||||||
rawText = targetEl.innerText.trim();
|
|
||||||
} else {
|
// Cleanup multiple empty lines
|
||||||
// Fallback: get all text but filter aggressively
|
var lines = rawText.split('\\n');
|
||||||
rawText = (targetEl.innerText || targetEl.textContent || '').trim();
|
var finalLines = [];
|
||||||
}
|
var lastEmpty = false;
|
||||||
} finally {
|
for (var i = 0; i < lines.length; i++) {
|
||||||
if (container.parentNode) container.parentNode.removeChild(container);
|
var line = lines[i].replace(/\\s+$/, '');
|
||||||
|
if (line.length === 0) {
|
||||||
|
if (!lastEmpty && finalLines.length > 0) {
|
||||||
|
finalLines.push('');
|
||||||
|
lastEmpty = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finalLines.push(line);
|
||||||
|
lastEmpty = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply line-by-line noise filter
|
// v19: Post-process — wrap markdown table patterns in code blocks for Discord
|
||||||
return cleanLines(rawText).substring(0, 3500);
|
// AG Native renders tables as divs, not <table> HTML, so DOM-level handler can't catch them.
|
||||||
|
// Detect consecutive lines with pipe separators (| col1 | col2 |) and wrap in code block fences
|
||||||
|
var bt = String.fromCharCode(96, 96, 96);
|
||||||
|
var result = [];
|
||||||
|
var tableBlock = [];
|
||||||
|
var inCodeBlock = false;
|
||||||
|
for (var fi = 0; fi < finalLines.length; fi++) {
|
||||||
|
var fl = finalLines[fi];
|
||||||
|
// Track existing code blocks to avoid double-wrapping
|
||||||
|
if (fl.trim().indexOf(bt) === 0) {
|
||||||
|
inCodeBlock = !inCodeBlock;
|
||||||
|
// Flush any pending table block before code block marker
|
||||||
|
if (tableBlock.length > 0) {
|
||||||
|
result.push(bt);
|
||||||
|
for (var ti = 0; ti < tableBlock.length; ti++) result.push(tableBlock[ti]);
|
||||||
|
result.push(bt);
|
||||||
|
tableBlock = [];
|
||||||
|
}
|
||||||
|
result.push(fl);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCodeBlock) {
|
||||||
|
result.push(fl);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Detect table row: has at least 2 pipe characters and content between them
|
||||||
|
var pipeCount = 0;
|
||||||
|
for (var pc = 0; pc < fl.length; pc++) { if (fl.charAt(pc) === '|') pipeCount++; }
|
||||||
|
var isTableRow = pipeCount >= 2 && fl.trim().charAt(0) === '|';
|
||||||
|
var isSeparator = isTableRow && /^[\\s|:-]+$/.test(fl.trim());
|
||||||
|
if (isTableRow) {
|
||||||
|
tableBlock.push(fl);
|
||||||
|
} else {
|
||||||
|
// Flush table block if it had enough rows (header + separator + data)
|
||||||
|
if (tableBlock.length >= 2) {
|
||||||
|
result.push(bt);
|
||||||
|
for (var ti2 = 0; ti2 < tableBlock.length; ti2++) result.push(tableBlock[ti2]);
|
||||||
|
result.push(bt);
|
||||||
|
} else {
|
||||||
|
// Not a real table, push lines back normally
|
||||||
|
for (var ti3 = 0; ti3 < tableBlock.length; ti3++) result.push(tableBlock[ti3]);
|
||||||
|
}
|
||||||
|
tableBlock = [];
|
||||||
|
result.push(fl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Flush trailing table block
|
||||||
|
if (tableBlock.length >= 2) {
|
||||||
|
result.push(bt);
|
||||||
|
for (var ti4 = 0; ti4 < tableBlock.length; ti4++) result.push(tableBlock[ti4]);
|
||||||
|
result.push(bt);
|
||||||
|
} else {
|
||||||
|
for (var ti5 = 0; ti5 < tableBlock.length; ti5++) result.push(tableBlock[ti5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join('\\n').substring(0, 3500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scanChatBodies() {
|
function scanChatBodies() {
|
||||||
@@ -559,24 +754,103 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
if (!cv) {
|
if (!cv) {
|
||||||
cv = document.querySelector('.antigravity-agent-side-panel');
|
cv = document.querySelector('.antigravity-agent-side-panel');
|
||||||
}
|
}
|
||||||
|
// v19: Fallback — find conversation by tracing from known content elements
|
||||||
|
if (!cv) {
|
||||||
|
var probe = document.querySelector('.leading-relaxed.select-text') || document.querySelector('.text-ide-message-block-bot-color');
|
||||||
|
if (probe) {
|
||||||
|
// Walk up to find a reasonable container (has overflow-y or is big enough)
|
||||||
|
var p = probe.parentElement;
|
||||||
|
for (var pi2 = 0; pi2 < 10 && p && p !== document.body; pi2++) {
|
||||||
|
var pCls = (typeof p.className === 'string') ? p.className : '';
|
||||||
|
if (pCls.indexOf('overflow') !== -1 || p.children.length > 3) {
|
||||||
|
cv = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
p = p.parentElement;
|
||||||
|
}
|
||||||
|
if (!cv && probe.parentElement) cv = probe.parentElement.parentElement || probe.parentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (cv) {
|
if (cv) {
|
||||||
// AG Native path: find AI response blocks by class pattern
|
// v20: Dump CV structure for first 3 scans to ensure we capture it (even with stale HTML cache)
|
||||||
// DOM structure: #conversation > ... > .leading-relaxed.select-text (AI response text)
|
if (_conversationDumpCount < 3) {
|
||||||
var responseBlocks = cv.querySelectorAll('.leading-relaxed.select-text');
|
_conversationDumpCount++;
|
||||||
|
log('CV found via: ' + (cv.id || (typeof cv.className === 'string' ? cv.className : cv.tagName) || 'unknown').substring(0, 100));
|
||||||
|
// Log all unique class names under #conversation for selector discovery
|
||||||
|
var allCvEls = cv.querySelectorAll('*');
|
||||||
|
var clsSet = {};
|
||||||
|
for (var ci2 = 0; ci2 < allCvEls.length; ci2++) {
|
||||||
|
var cn = allCvEls[ci2].className;
|
||||||
|
if (typeof cn === 'string' && cn.length > 0) {
|
||||||
|
var parts = cn.split(/\\s+/);
|
||||||
|
for (var pi = 0; pi < parts.length; pi++) {
|
||||||
|
if (parts[pi].length > 3 && !clsSet[parts[pi]]) clsSet[parts[pi]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var clsList = Object.keys(clsSet).sort().join(', ');
|
||||||
|
log('CV-CLASSES (' + Object.keys(clsSet).length + '): ' + clsList.substring(0, 1500));
|
||||||
|
// v19: Log direct children to discover message block structure
|
||||||
|
var cvKids = cv.children;
|
||||||
|
log('CV-CHILDREN (' + cvKids.length + '):');
|
||||||
|
for (var ck = 0; ck < Math.min(cvKids.length, 15); ck++) {
|
||||||
|
var kid = cvKids[ck];
|
||||||
|
var kidCls = (typeof kid.className === 'string') ? kid.className : '';
|
||||||
|
var kidText = (kid.textContent || '').trim().substring(0, 60);
|
||||||
|
log(' child[' + ck + '] tag=' + kid.tagName + ' cls=' + kidCls.substring(0, 120) + ' text=' + kidText);
|
||||||
|
}
|
||||||
|
// v22: Deep-dive into gap-8 container to find individual message blocks
|
||||||
|
var msgContainer = cv.querySelector('.gap-8') || cv.children[0];
|
||||||
|
if (msgContainer) {
|
||||||
|
var msgKids = msgContainer.children;
|
||||||
|
log('MSG-BLOCKS (' + msgKids.length + '):');
|
||||||
|
for (var mk = 0; mk < Math.min(msgKids.length, 30); mk++) {
|
||||||
|
var mb = msgKids[mk];
|
||||||
|
var mbCls = (typeof mb.className === 'string') ? mb.className : '';
|
||||||
|
var mbText = (mb.textContent || '').trim().substring(0, 80);
|
||||||
|
var hasLeadingRelaxed = mb.querySelector('.leading-relaxed.select-text') ? 'Y' : 'N';
|
||||||
|
var firstChildCls = (mb.children[0] && typeof mb.children[0].className === 'string') ? mb.children[0].className : '';
|
||||||
|
log(' msg[' + mk + '] cls=' + mbCls.substring(0, 120) + ' lr=' + hasLeadingRelaxed + ' fc=' + firstChildCls.substring(0, 80) + ' text=' + mbText.substring(0, 60));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Force a dump with conversation context
|
||||||
|
dumpDOMStructure();
|
||||||
|
}
|
||||||
|
|
||||||
|
// v22: AI response = .leading-relaxed.select-text, User message = .select-text.rounded-lg (Esn component, msn class)
|
||||||
|
// Source: jetskiAgent/main.js — msn="bg-gray-500/10 border border-gray-500/20 p-2 rounded-lg w-full text-sm select-text"
|
||||||
|
var responseBlocks = cv.querySelectorAll('.leading-relaxed.select-text, .select-text.rounded-lg');
|
||||||
|
|
||||||
if (responseBlocks.length > 0) {
|
if (responseBlocks.length > 0) {
|
||||||
// Process the LAST (most recent) response block
|
// v22: Filter out thinking/reasoning blocks — they have ancestor with max-h-[200px]
|
||||||
var lastBlock = responseBlocks[responseBlocks.length - 1];
|
// These are internal AI reasoning and should NOT be relayed to Discord
|
||||||
|
var filteredBlocks = [];
|
||||||
|
for (var fbi = 0; fbi < responseBlocks.length; fbi++) {
|
||||||
|
var isThinking = false;
|
||||||
|
var ancestor = responseBlocks[fbi].parentElement;
|
||||||
|
for (var depth = 0; ancestor && depth < 5; depth++) {
|
||||||
|
var aCls = (typeof ancestor.className === 'string') ? ancestor.className : '';
|
||||||
|
if (aCls.indexOf('max-h-[200px]') !== -1 || aCls.indexOf('max-h-[150px]') !== -1) {
|
||||||
|
isThinking = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ancestor = ancestor.parentElement;
|
||||||
|
}
|
||||||
|
if (!isThinking) filteredBlocks.push(responseBlocks[fbi]);
|
||||||
|
}
|
||||||
|
if (filteredBlocks.length === 0) return;
|
||||||
|
|
||||||
|
// Process the LAST (most recent) non-thinking response block
|
||||||
|
var lastBlock = filteredBlocks[filteredBlocks.length - 1];
|
||||||
|
|
||||||
// Skip if already scraped
|
// Skip if already scraped
|
||||||
if (lastBlock.dataset.agChatScraped === 'true' || lastBlock.dataset.agChatScraped === 'pending') {
|
if (lastBlock.dataset.agChatScraped === 'true' || lastBlock.dataset.agChatScraped === 'pending') {
|
||||||
// Check for NEW blocks since last scrape
|
// Check for NEW blocks since last scrape
|
||||||
if (responseBlocks.length > _lastResponseBlockCount) {
|
if (filteredBlocks.length > _lastResponseBlockCount) {
|
||||||
// New block appeared — process it
|
// New block appeared — process it
|
||||||
for (var rbi = responseBlocks.length - 1; rbi >= 0; rbi--) {
|
for (var rbi = filteredBlocks.length - 1; rbi >= 0; rbi--) {
|
||||||
if (responseBlocks[rbi].dataset.agChatScraped !== 'true' && responseBlocks[rbi].dataset.agChatScraped !== 'pending') {
|
if (filteredBlocks[rbi].dataset.agChatScraped !== 'true' && filteredBlocks[rbi].dataset.agChatScraped !== 'pending') {
|
||||||
lastBlock = responseBlocks[rbi];
|
lastBlock = filteredBlocks[rbi];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -587,13 +861,26 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var blockText = extractCleanStepText(lastBlock);
|
var blockText = extractCleanStepText(lastBlock);
|
||||||
if (blockText && blockText.length > 30) {
|
|
||||||
// QUALITY CHECK: Skip if the text is mostly short lines (UI artifacts)
|
var clsStr = (typeof lastBlock.className === 'string') ? lastBlock.className : '';
|
||||||
var lines = blockText.split('\\n').filter(function(l) { return l.trim().length > 0; });
|
// v19: Log block classes for user message selector discovery
|
||||||
var longLines = lines.filter(function(l) { return l.trim().length > 20; });
|
var parentCls = lastBlock.parentElement ? ((typeof lastBlock.parentElement.className === 'string') ? lastBlock.parentElement.className : '') : '';
|
||||||
if (longLines.length === 0) {
|
var grandCls = (lastBlock.parentElement && lastBlock.parentElement.parentElement) ? ((typeof lastBlock.parentElement.parentElement.className === 'string') ? lastBlock.parentElement.parentElement.className : '') : '';
|
||||||
log('AG-Native: skipped (no long lines, likely UI noise)');
|
log('user-cls-debug block=' + clsStr.substring(0, 150) + ' | parent=' + parentCls.substring(0, 150) + ' | grand=' + grandCls.substring(0, 150) + ' | text=' + (blockText||'').substring(0, 50));
|
||||||
return;
|
// v22: Detect user message: has select-text + rounded-lg but NOT leading-relaxed
|
||||||
|
var isUser = (clsStr.indexOf('rounded-lg') !== -1 && clsStr.indexOf('leading-relaxed') === -1) || clsStr.indexOf('user-color') !== -1;
|
||||||
|
var role = isUser ? 'user' : 'bot';
|
||||||
|
|
||||||
|
// Bot messages often start empty and stream in. User messages are usually immediate.
|
||||||
|
if (blockText && (blockText.length > 30 || isUser && blockText.length > 0)) {
|
||||||
|
// QUALITY CHECK: Skip if the text is mostly short lines (UI artifacts), BUT skip this check for user messages
|
||||||
|
if (!isUser) {
|
||||||
|
var lines = blockText.split('\\n').filter(function(l) { return l.trim().length > 0; });
|
||||||
|
var longLines = lines.filter(function(l) { return l.trim().length > 20; });
|
||||||
|
if (longLines.length === 0) {
|
||||||
|
log('AG-Native: skipped (no long lines, likely UI noise)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for content to stabilize (3s no change)
|
// Wait for content to stabilize (3s no change)
|
||||||
@@ -605,22 +892,26 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_lastStepTextSent) return;
|
if (_lastStepTextSent) return;
|
||||||
if (Date.now() - _lastStepTextTime < 3000) return; // Still waiting
|
// Bot needs 3s to stabilize, User just needs 500ms
|
||||||
|
var waitTime = isUser ? 500 : 3000;
|
||||||
|
if (Date.now() - _lastStepTextTime < waitTime) return; // Still waiting
|
||||||
|
|
||||||
// Content is stable — send it
|
// v21: DOM-based chat relay RE-ENABLED — GetCascadeTrajectorySteps does NOT
|
||||||
|
// return steps for in-progress cascades, making Step Probe RT-CAPTURE useless.
|
||||||
|
// Observer DOM extraction is the ONLY real-time path for AI response relay.
|
||||||
_lastStepTextSent = true;
|
_lastStepTextSent = true;
|
||||||
_lastResponseBlockCount = responseBlocks.length;
|
_lastResponseBlockCount = filteredBlocks.length;
|
||||||
lastBlock.dataset.agChatScraped = 'pending';
|
lastBlock.dataset.agChatScraped = 'pending';
|
||||||
|
|
||||||
log('AG-Native chat relay: blocks=' + responseBlocks.length + ' text=' + blockText.length + ' chars');
|
log('AG-Native chat relay [' + role + ']: blocks=' + filteredBlocks.length + ' text=' + blockText.length + ' chars');
|
||||||
(function(el, txt, count) {
|
(function(el, txt, count, r) {
|
||||||
fetch(BASE + '/chat', {
|
fetch(BASE + '/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text: txt, source: 'ag_native_block_' + count, block_index: count })
|
body: JSON.stringify({ text: txt, source: 'ag_native_block_' + count, block_index: count, role: r })
|
||||||
}).then(function() { el.dataset.agChatScraped = 'true'; log('AG-Native chat sent OK'); })
|
}).then(function() { el.dataset.agChatScraped = 'true'; log('AG-Native chat sent OK'); })
|
||||||
.catch(function(e) { el.dataset.agChatScraped = 'false'; log('AG-Native chat send error: ' + e.message); });
|
.catch(function(e) { el.dataset.agChatScraped = 'false'; log('AG-Native chat send error: ' + e.message); });
|
||||||
})(lastBlock, blockText, responseBlocks.length);
|
})(lastBlock, blockText, filteredBlocks.length, role);
|
||||||
}
|
}
|
||||||
return; // AG Native path handled — don't fall through to Cascade path
|
return; // AG Native path handled — don't fall through to Cascade path
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -453,28 +453,70 @@ function setupMonitor() {
|
|||||||
|
|
||||||
// ── v15: Heartbeat probe — detect step changes when summary API is stale ──
|
// ── v15: Heartbeat probe — detect step changes when summary API is stale ──
|
||||||
// GetAllCascadeTrajectories can return frozen stepCount/lastModifiedTime,
|
// GetAllCascadeTrajectories can return frozen stepCount/lastModifiedTime,
|
||||||
// preventing delta>0 from ever firing. Every 10 polls (~50s), directly
|
// v20: Heartbeat every 3 polls (~15s) — AG API never reports delta for active sessions
|
||||||
// probe GetCascadeTrajectorySteps to get the REAL latest step count.
|
if (delta === 0 && ctx.sdk && ctx.activeSessionId && pollCount % 3 === 0) {
|
||||||
if (delta === 0 && ctx.sdk && ctx.activeSessionId && pollCount % 10 === 0) {
|
|
||||||
try {
|
try {
|
||||||
const hbOffset = Math.max(0, currentCount - 1);
|
const hbOffset = Math.max(0, currentCount - 5);
|
||||||
const hbResp = await ctx.sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
|
const hbResp = await ctx.sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
|
||||||
cascadeId: ctx.activeSessionId,
|
cascadeId: ctx.activeSessionId,
|
||||||
stepOffset: hbOffset,
|
stepOffset: hbOffset,
|
||||||
verbosity: 0, // minimal — just need step count
|
verbosity: 1, // need content for capture
|
||||||
});
|
});
|
||||||
if (hbResp?.steps?.length > 0) {
|
if (hbResp?.steps?.length > 0) {
|
||||||
const realStepCount = hbOffset + hbResp.steps.length;
|
const realStepCount = hbOffset + hbResp.steps.length;
|
||||||
if (realStepCount > lastKnownStepCount) {
|
if (realStepCount > lastKnownStepCount) {
|
||||||
ctx.logToFile(`[HEARTBEAT] summary stale! reported=${lastKnownStepCount} real=${realStepCount} — correcting`);
|
ctx.logToFile(`[HEARTBEAT] stale! reported=${lastKnownStepCount} real=${realStepCount}`);
|
||||||
|
// Process new steps for RT-CAPTURE
|
||||||
|
for (let hi = 0; hi < hbResp.steps.length; hi++) {
|
||||||
|
const hs = hbResp.steps[hi];
|
||||||
|
const hIdx = hbOffset + hi;
|
||||||
|
if (hIdx <= lastResponseCaptureStep) continue;
|
||||||
|
const hType = hs?.type || '';
|
||||||
|
// Capture AI responses
|
||||||
|
if (hType.includes('PLANNER_RESPONSE') && hs?.status?.includes('DONE')) {
|
||||||
|
let text = extractPlannerText(hs) || '';
|
||||||
|
if (text.length > 10) {
|
||||||
|
lastResponseCaptureStep = hIdx;
|
||||||
|
ctx.logToFile(`[HB-CAPTURE] AI step=${hIdx} (${text.length} chars)`);
|
||||||
|
const truncated = text.length > 3500
|
||||||
|
? text.substring(0, 3500) + '\n\n_(이하 생략)_'
|
||||||
|
: text;
|
||||||
|
ctx.writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Capture user messages
|
||||||
|
if (hType.includes('USER_INPUT') && hIdx > lastUserInputStepIdx) {
|
||||||
|
lastUserInputStepIdx = hIdx;
|
||||||
|
const ui = hs?.userInput;
|
||||||
|
const umText = (ui?.userResponse || ui?.text || '').trim();
|
||||||
|
if (umText.length > 2) {
|
||||||
|
const sentAt = ctx.recentDiscordSentTexts.get(umText);
|
||||||
|
if (!sentAt || (Date.now() - sentAt) > 60_000) {
|
||||||
|
const dedupKey = `user_msg:${umText}`;
|
||||||
|
const lastRelayed = lastSnapshotText.get(dedupKey);
|
||||||
|
if (!lastRelayed || (Date.now() - Number(lastRelayed)) > 30_000) {
|
||||||
|
lastSnapshotText.set(dedupKey, String(Date.now()));
|
||||||
|
const clientType = ui?.clientType || '';
|
||||||
|
const source = clientType.includes('IDE') ? 'AG 직접 입력' : 'API';
|
||||||
|
const truncated = umText.length > 800
|
||||||
|
? umText.substring(0, 800) + '\n\n_(이하 생략)_'
|
||||||
|
: umText;
|
||||||
|
ctx.writeChatSnapshot(`👤 **사용자 (${source})**\n\n${truncated}`);
|
||||||
|
ctx.logToFile(`[HB-CAPTURE] User step=${hIdx} (${umText.length} chars)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
lastKnownStepCount = realStepCount;
|
lastKnownStepCount = realStepCount;
|
||||||
// Trigger RT-CAPTURE by re-entering delta>0 path below
|
} else if (pollCount % 30 === 0) {
|
||||||
// We set currentCount so delta recalculation works
|
ctx.logToFile(`[HEARTBEAT] ok offset=${hbOffset} got=${hbResp.steps.length} real=${realStepCount} known=${lastKnownStepCount}`);
|
||||||
}
|
}
|
||||||
|
} else if (pollCount % 30 === 0) {
|
||||||
|
ctx.logToFile(`[HEARTBEAT] no steps returned for offset=${hbOffset}`);
|
||||||
}
|
}
|
||||||
} catch (hbErr: any) {
|
} catch (hbErr: any) {
|
||||||
// Non-critical — will retry next heartbeat
|
if (pollCount % 10 === 0) ctx.logToFile(`[HEARTBEAT] probe error: ${hbErr.message?.substring(0, 100)}`);
|
||||||
if (pollCount <= 30) ctx.logToFile(`[HEARTBEAT] probe error: ${hbErr.message?.substring(0, 60)}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +570,38 @@ function setupMonitor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v20: Capture USER_INPUT steps for user message relay
|
||||||
|
if (sType.includes('USER_INPUT') && actualIdx > lastUserInputStepIdx) {
|
||||||
|
lastUserInputStepIdx = actualIdx;
|
||||||
|
const ui = s?.userInput;
|
||||||
|
const umText = (ui?.userResponse || ui?.text || s?.plannerResponse?.textContent || '').trim();
|
||||||
|
const clientType = ui?.clientType || '';
|
||||||
|
const isFromIDE = clientType.includes('IDE');
|
||||||
|
ctx.logToFile(`[RT-USER-MSG] step=${actualIdx} client=${clientType} text=${umText.substring(0, 100)}`);
|
||||||
|
|
||||||
|
if (umText.length > 2) {
|
||||||
|
// Skip echo: if text was recently sent from Discord
|
||||||
|
const sentAt = ctx.recentDiscordSentTexts.get(umText);
|
||||||
|
if (sentAt && (Date.now() - sentAt) < 60_000) {
|
||||||
|
ctx.recentDiscordSentTexts.delete(umText);
|
||||||
|
ctx.logToFile(`[RT-USER-MSG] skipped echo relay (Discord origin)`);
|
||||||
|
} else {
|
||||||
|
// Content-based dedup
|
||||||
|
const dedupKey = `user_msg:${umText}`;
|
||||||
|
const lastRelayed = lastSnapshotText.get(dedupKey);
|
||||||
|
if (!lastRelayed || (Date.now() - Number(lastRelayed)) > 30_000) {
|
||||||
|
lastSnapshotText.set(dedupKey, String(Date.now()));
|
||||||
|
const truncated = umText.length > 800
|
||||||
|
? umText.substring(0, 800) + '\n\n_(이하 생략)_'
|
||||||
|
: umText;
|
||||||
|
const source = isFromIDE ? 'AG 직접 입력' : 'API';
|
||||||
|
ctx.writeChatSnapshot(`👤 **사용자 (${source})**\n\n${truncated}`);
|
||||||
|
ctx.logToFile(`[RT-USER-MSG] relayed ${umText.length} chars`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (s?.status === 'CORTEX_STEP_STATUS_WAITING') {
|
if (s?.status === 'CORTEX_STEP_STATUS_WAITING') {
|
||||||
const toolCall = s?.metadata?.toolCall;
|
const toolCall = s?.metadata?.toolCall;
|
||||||
const toolName = toolCall?.name || (sType || '').replace('CORTEX_STEP_TYPE_', '').toLowerCase();
|
const toolName = toolCall?.name || (sType || '').replace('CORTEX_STEP_TYPE_', '').toLowerCase();
|
||||||
|
|||||||
Reference in New Issue
Block a user