Compare commits

..

7 Commits

Author SHA1 Message Date
Variet Worker
139ad3ee93 fix(extension): Retry auto-approve 흐름 복구 + Observer 형제 탐색 + thinking 필터링 (v0.5.79)
- WS response 파일에 _from_ws 마커 추가하여 processResponseFile 삭제 방지
- extractContextFromNearby에 sibling 탐색 추가 (AG Native DOM 구조 대응)
- thinking 블록 (max-h-[200px]) 필터링으로 내부 사고 릴레이 차단
- DOM 탐색 depth 5→10 확대 + pre.font-mono 우선 탐색
- 사용자 메시지 셀렉터 (.select-text.rounded-lg) 추가
2026-04-19 03:46:39 +09:00
Variet Worker
08fd08b9a6 feat(observer): diagnostic log relay via HTTP + auto-approve enrichment fallback (v0.5.63) #task-634 2026-04-18 08:18:35 +09:00
Variet Worker
326454be40 fix(bridge): move Always run auto-approve BEFORE filter chain — no more silent drops (v0.5.60) #task-634 2026-04-18 06:54:15 +09:00
Variet Worker
98b7585e3c fix(observer): text-level markdown table wrapping for Discord — AG Native uses divs not HTML tables (v0.5.59) #task-634 2026-04-18 06:46:21 +09:00
Variet Worker
c7f939ce85 fix(bridge): Always run auto-approve now checks buttons array, not just rawCmd (v0.5.58) #task-634 2026-04-18 06:35:01 +09:00
Variet Worker
7a1675fd5d feat(observer): table-to-codeblock conversion for Discord compatibility (v0.5.57) #task-634 2026-04-18 06:25:55 +09:00
Variet Worker
6b9f1188c3 feat(bridge): DOM Markdown parser restoration (v0.5.56) + code noise filter fix + user msg relay #task-634 2026-04-17 08:06:53 +09:00
19 changed files with 1047 additions and 111 deletions

View File

@@ -21,6 +21,44 @@
> 鍮꾩듂븳 臾몄젣媛 옱諛쒗븯硫 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)
- **증상**: 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 응답 텍스트로 추출됨.

View 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 응답이 마지막 블록만 캡처되는 문제 개선 (전문 캡처)

View File

@@ -0,0 +1,5 @@
# 2026-04-17
| NNN | HH:MM | 작업 설명 | `커밋해시` | 완료 |
|-------|-------|----------|-----------|----------|
| 001 | 08:05 | v18 Observer DOM->Markdown 파서 개선(<a> 파싱 포함) 및 노이즈 필터 부작용(코드 블럭 잘림 방지) 해결, User 구문 추출 연동, v0.5.56 배포 | `미정` | ✅ |

View 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` | ✅ |

View 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를 설치하면 적용됨.

View 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 소비)

View File

@@ -397,6 +397,23 @@ async function activate(context) {
return;
}
// 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)();
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)

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,12 @@
{
"name": "gravity-bridge",
"version": "0.5.34",
"version": "0.5.79",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gravity-bridge",
"version": "0.5.34",
"version": "0.5.79",
"dependencies": {
"cheerio": "^1.2.0",
"ws": "^8.19.0"

View File

@@ -2,7 +2,7 @@
"name": "gravity-bridge",
"displayName": "Gravity Bridge",
"description": "Discord-based unified approval system for Antigravity AI interactions.",
"version": "0.5.54",
"version": "0.5.79",
"publisher": "variet",
"engines": {
"vscode": "^1.100.0"

View 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));

View 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));

View 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));

View 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)));

View File

@@ -205,6 +205,17 @@ async function processResponseFile(filePath: string) {
}
const content = fs.readFileSync(filePath, 'utf-8');
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(',')}]`;
console.log(`Gravity Bridge: ${msg}`);
ctx.logToFile(msg);

View File

@@ -388,6 +388,28 @@ export async function activate(context: vscode.ExtensionContext) {
}
// 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();
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)

View File

@@ -126,6 +126,21 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<numbe
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') {
let dumpBody = '';
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;
let enrichedCmd = rawCmd;
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) {
// Extract the actual command from description (often includes terminal prompt)
// Pattern: "…\project_name > actual_command"
@@ -361,44 +464,8 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
buttons: data.buttons,
step_index: ctx.lastPendingStepIndex >= 0 ? ctx.lastPendingStepIndex : undefined,
};
// v17: "Always run" auto-approve — click button immediately without Discord roundtrip
// rawCmd is the original button text before enrichment. "Always run" means the user
// 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;
}
// v19: "Always run" auto-approve was already handled above (before filter chain)
// No need for duplicate check here.
// File permission: inject multi-choice buttons
const cmdLower = enrichedCmd.toLowerCase();
if (cmdLower.includes('allow') && !pending.buttons) {
@@ -544,8 +611,10 @@ function _handleChatSnapshot(req: any, res: any, ctx: HttpBridgeContext) {
try {
const data = JSON.parse(body);
if (data.text && typeof ctx.writeChatSnapshot === 'function') {
ctx.writeChatSnapshot(`💬 **[DOM 추출] AI 응답**\n\n${data.text}`);
ctx.logToFile(`[HTTP] chat snapshot written (${data.text.length} chars)`);
const isUser = data.role === 'user';
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.end(JSON.stringify({ ok: true }));

View File

@@ -9,7 +9,13 @@ export function generateApprovalObserverScript(_port: number): string {
var THROTTLE_MS=500;
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');
// DIAGNOSTIC BEACON: immediate POST to confirm script execution in renderer
@@ -138,9 +144,11 @@ export function generateApprovalObserverScript(_port: number): string {
var _bestCodeHeader = '';
var _sawCodeEls = false;
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; }
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);
for (var ci = 0; ci < codeEls.length; ci++) {
var codeText = cleanLines((codeEls[ci].textContent || '').trim().substring(0, 500));
@@ -181,6 +189,33 @@ export function generateApprovalObserverScript(_port: number): string {
_lastContextDebug = _debugTrail.join(' > ');
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;
}
if (_sawCodeEls && _allSkipped) {
@@ -338,7 +373,8 @@ export function generateApprovalObserverScript(_port: number): string {
// ══════════════════════════════════════════════════════════════════
var _dumpCount=0;
var MAX_DUMPS=5;
var MAX_DUMPS=8;
var _conversationDumpCount=0;
function walkNode(el, depth, maxDepth, maxChildren) {
if (depth > maxDepth) return {tag:'…',text:'depth limit'};
@@ -484,15 +520,118 @@ export function generateApprovalObserverScript(_port: number): string {
var _lastStepTextSent = false;
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) {
if (!stepEl) return '';
// Clone the step element so we can strip UI elements without affecting the DOM
var clone = stepEl.cloneNode(true);
// v16: Remove style/script/noscript elements FIRST — AG Native markdown injects <style> blocks
// that contain CSS rules (e.g. remark-github-blockquote-alert/alert.css) whose textContent
// gets captured as AI response text
// v16: Remove style/script/noscript elements FIRST
var styleEls = clone.querySelectorAll('style, script, noscript, link[rel="stylesheet"]');
for (var si = 0; si < styleEls.length; 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
// 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;
// Use our custom DOM-to-Markdown parser instead of innerText
var rawText = convertNodeToMarkdown(mdEl).trim();
// v18 FIX: DO NOT apply cleanLines to full markdown content, it destroys valid code blocks
// Safely remove "Thought for X" lines only
rawText = rawText.replace(/Thought for \\d+s?/gi, '');
rawText = rawText.replace(/Thought for a few seconds/gi, '');
// v18 FIX: Temporarily attach to DOM to force layout computation for .innerText
// Without this, .innerText on unattached node behaves exactly like .textContent (loses block newlines)
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;
var rawText = '';
try {
if (targetEl.innerText && targetEl.innerText.trim().length > 10) {
rawText = targetEl.innerText.trim();
} else {
// Fallback: get all text but filter aggressively
rawText = (targetEl.innerText || targetEl.textContent || '').trim();
}
} finally {
if (container.parentNode) container.parentNode.removeChild(container);
// Cleanup multiple empty lines
var lines = rawText.split('\\n');
var finalLines = [];
var lastEmpty = false;
for (var i = 0; i < lines.length; i++) {
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
return cleanLines(rawText).substring(0, 3500);
// v19: Post-process — wrap markdown table patterns in code blocks for Discord
// 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() {
@@ -559,24 +754,103 @@ export function generateApprovalObserverScript(_port: number): string {
if (!cv) {
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) {
// AG Native path: find AI response blocks by class pattern
// DOM structure: #conversation > ... > .leading-relaxed.select-text (AI response text)
var responseBlocks = cv.querySelectorAll('.leading-relaxed.select-text');
// v20: Dump CV structure for first 3 scans to ensure we capture it (even with stale HTML cache)
if (_conversationDumpCount < 3) {
_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) {
// Process the LAST (most recent) response block
var lastBlock = responseBlocks[responseBlocks.length - 1];
// v22: Filter out thinking/reasoning blocks — they have ancestor with max-h-[200px]
// 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
if (lastBlock.dataset.agChatScraped === 'true' || lastBlock.dataset.agChatScraped === 'pending') {
// Check for NEW blocks since last scrape
if (responseBlocks.length > _lastResponseBlockCount) {
if (filteredBlocks.length > _lastResponseBlockCount) {
// New block appeared — process it
for (var rbi = responseBlocks.length - 1; rbi >= 0; rbi--) {
if (responseBlocks[rbi].dataset.agChatScraped !== 'true' && responseBlocks[rbi].dataset.agChatScraped !== 'pending') {
lastBlock = responseBlocks[rbi];
for (var rbi = filteredBlocks.length - 1; rbi >= 0; rbi--) {
if (filteredBlocks[rbi].dataset.agChatScraped !== 'true' && filteredBlocks[rbi].dataset.agChatScraped !== 'pending') {
lastBlock = filteredBlocks[rbi];
break;
}
}
@@ -587,13 +861,26 @@ export function generateApprovalObserverScript(_port: number): string {
}
var blockText = extractCleanStepText(lastBlock);
if (blockText && blockText.length > 30) {
// QUALITY CHECK: Skip if the text is mostly short lines (UI artifacts)
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;
var clsStr = (typeof lastBlock.className === 'string') ? lastBlock.className : '';
// v19: Log block classes for user message selector discovery
var parentCls = lastBlock.parentElement ? ((typeof lastBlock.parentElement.className === 'string') ? lastBlock.parentElement.className : '') : '';
var grandCls = (lastBlock.parentElement && lastBlock.parentElement.parentElement) ? ((typeof lastBlock.parentElement.parentElement.className === 'string') ? lastBlock.parentElement.parentElement.className : '') : '';
log('user-cls-debug block=' + clsStr.substring(0, 150) + ' | parent=' + parentCls.substring(0, 150) + ' | grand=' + grandCls.substring(0, 150) + ' | text=' + (blockText||'').substring(0, 50));
// 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)
@@ -605,22 +892,26 @@ export function generateApprovalObserverScript(_port: number): string {
}
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;
_lastResponseBlockCount = responseBlocks.length;
_lastResponseBlockCount = filteredBlocks.length;
lastBlock.dataset.agChatScraped = 'pending';
log('AG-Native chat relay: blocks=' + responseBlocks.length + ' text=' + blockText.length + ' chars');
(function(el, txt, count) {
log('AG-Native chat relay [' + role + ']: blocks=' + filteredBlocks.length + ' text=' + blockText.length + ' chars');
(function(el, txt, count, r) {
fetch(BASE + '/chat', {
method: 'POST',
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'); })
.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
}

View File

@@ -453,28 +453,70 @@ function setupMonitor() {
// ── v15: Heartbeat probe — detect step changes when summary API is stale ──
// GetAllCascadeTrajectories can return frozen stepCount/lastModifiedTime,
// preventing delta>0 from ever firing. Every 10 polls (~50s), directly
// probe GetCascadeTrajectorySteps to get the REAL latest step count.
if (delta === 0 && ctx.sdk && ctx.activeSessionId && pollCount % 10 === 0) {
// v20: Heartbeat every 3 polls (~15s) — AG API never reports delta for active sessions
if (delta === 0 && ctx.sdk && ctx.activeSessionId && pollCount % 3 === 0) {
try {
const hbOffset = Math.max(0, currentCount - 1);
const hbOffset = Math.max(0, currentCount - 5);
const hbResp = await ctx.sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
cascadeId: ctx.activeSessionId,
stepOffset: hbOffset,
verbosity: 0, // minimal — just need step count
verbosity: 1, // need content for capture
});
if (hbResp?.steps?.length > 0) {
const realStepCount = hbOffset + hbResp.steps.length;
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;
// Trigger RT-CAPTURE by re-entering delta>0 path below
// We set currentCount so delta recalculation works
} else if (pollCount % 30 === 0) {
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) {
// Non-critical — will retry next heartbeat
if (pollCount <= 30) ctx.logToFile(`[HEARTBEAT] probe error: ${hbErr.message?.substring(0, 60)}`);
if (pollCount % 10 === 0) ctx.logToFile(`[HEARTBEAT] probe error: ${hbErr.message?.substring(0, 100)}`);
}
}
@@ -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') {
const toolCall = s?.metadata?.toolCall;
const toolName = toolCall?.name || (sType || '').replace('CORTEX_STEP_TYPE_', '').toLowerCase();