Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a263c67aa2 | ||
|
|
bb5528f0df | ||
|
|
bf61ccd98a | ||
|
|
5603d5695d | ||
|
|
a202069b2f | ||
|
|
f52eff1b68 | ||
|
|
1a967f8bb2 | ||
|
|
66f12b08f1 | ||
|
|
c62346e94c | ||
|
|
4faa92e902 | ||
|
|
e37b8e7a07 | ||
|
|
6baf19e04c | ||
|
|
90a9383a6e | ||
|
|
04699e3f9a | ||
|
|
16620df721 | ||
|
|
de93905ce3 | ||
|
|
bceb7176d9 | ||
|
|
85eebae869 | ||
|
|
abe3ad8b65 | ||
|
|
5d54f72aa9 | ||
|
|
2911113fda | ||
|
|
1374690f9f | ||
|
|
51d6b45667 | ||
|
|
aa8444aff6 | ||
|
|
6772773b40 | ||
|
|
b5a44c8b66 | ||
|
|
0f0def8287 | ||
|
|
71f76cd97e | ||
|
|
c262c6e506 | ||
|
|
7c5557a901 | ||
|
|
b24f0eb76e | ||
|
|
5c76eab8bc | ||
|
|
68df12bae2 | ||
|
|
24bec0883f | ||
|
|
e79d3a853e | ||
|
|
21686d4eda | ||
|
|
1f13590df8 | ||
|
|
4b84ccfcd6 | ||
|
|
dc1c9b4faa | ||
|
|
bbd424bb36 | ||
|
|
f85de497ee | ||
|
|
1533315944 | ||
|
|
0379404940 | ||
|
|
3e90fd6e3a | ||
|
|
e2bd0cacc5 | ||
|
|
1c63e5592e | ||
|
|
0591d31d95 | ||
|
|
0ec82c318b | ||
|
|
b31bcbef56 | ||
|
|
bded43dbce | ||
|
|
733c87313d | ||
|
|
bb1a88abd0 | ||
|
|
a1a355b1d1 | ||
|
|
91a595b822 | ||
|
|
110b91f54c | ||
|
|
85dc2db900 | ||
|
|
c213289abf | ||
|
|
72464665c5 | ||
|
|
7f57e4d2b8 | ||
|
|
2655a5f227 | ||
|
|
1c7679c41e | ||
|
|
3105d4a099 | ||
|
|
5f14961fbb | ||
|
|
36de7695af | ||
|
|
0ae9186728 | ||
|
|
e9531bf4e3 | ||
|
|
46daa52d43 | ||
|
|
780757ca55 | ||
|
|
5bf8d934b4 | ||
|
|
81db870a51 | ||
|
|
4060ce491b | ||
|
|
9032801deb | ||
|
|
8051256382 | ||
|
|
3e380bcec3 | ||
|
|
39fc502513 | ||
|
|
ce2f186bd2 | ||
|
|
7c742c07f4 | ||
|
|
4eb2cf57a8 | ||
|
|
3ab5ae053c | ||
|
|
fe3c0eabdc | ||
|
|
e23fbe51f7 | ||
|
|
42bf555a4e | ||
|
|
8f8f8b5ae2 | ||
|
|
c783b89c35 | ||
|
|
b3f718dfce | ||
|
|
c4424826f9 | ||
|
|
8d5384c1de | ||
|
|
3633c5a6ff | ||
|
|
355f6496fb | ||
|
|
28c75ec6a8 | ||
|
|
22729e5218 | ||
|
|
51f59c44ff | ||
|
|
1c2b9f7915 | ||
|
|
1298aea79a | ||
|
|
1560cd1228 | ||
|
|
c6e3ac5533 | ||
|
|
bb0cd246e8 | ||
|
|
52076f2c25 | ||
|
|
03e081e83a | ||
|
|
3bb7d57a9f | ||
|
|
2ad3d464b4 | ||
|
|
5e270c061d | ||
|
|
bb6ed472be | ||
|
|
610367e6fd | ||
|
|
d268fa9298 | ||
|
|
44fccae452 | ||
|
|
d002cc136c | ||
|
|
4aebce7014 | ||
|
|
2287b6a707 | ||
|
|
8caa647106 | ||
|
|
07b9bf6239 | ||
|
|
5932768548 | ||
|
|
aabc15a382 | ||
|
|
6ca23de63a | ||
|
|
179a5bc718 | ||
|
|
8677e55f30 | ||
|
|
719cd51919 | ||
|
|
ea747cff45 | ||
|
|
3200927f8c | ||
|
|
39cf5950a5 | ||
|
|
1781862fb1 | ||
|
|
cfd53c8142 | ||
|
|
4c5a0bb064 | ||
|
|
11862defbc | ||
|
|
962bc8ce26 |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="extension">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">extension</text>
|
||||
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">🧩 Hanzo Extension: IDE plugin for VS Code compatible IDEs.</text>
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
+123
-15
@@ -11,7 +11,7 @@ jobs:
|
||||
# ─── Unit Tests ───
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
@@ -21,27 +21,116 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# Build shared workspace libraries first — consumers (slack/teams/…) import
|
||||
# @hanzo/cards via its built dist/, so it must exist before their tests run.
|
||||
- name: Build shared libraries
|
||||
run: pnpm --filter @hanzo/cards build
|
||||
|
||||
- name: Test browser extension
|
||||
run: pnpm --filter @hanzo/browser-extension test
|
||||
|
||||
- name: Test ACI package
|
||||
run: pnpm --filter @hanzo/aci test
|
||||
|
||||
- name: Test AI package
|
||||
run: pnpm --filter @hanzo/ai test
|
||||
|
||||
- name: Test auth core
|
||||
run: pnpm --filter @hanzo/auth test
|
||||
|
||||
- name: Test Office add-in
|
||||
run: pnpm --filter @hanzo/office-addin test
|
||||
|
||||
- name: Test Outlook add-in
|
||||
run: pnpm --filter @hanzo/outlook-addin test
|
||||
|
||||
- name: Test Google Workspace add-on
|
||||
run: pnpm --filter @hanzo/gworkspace-addon test
|
||||
|
||||
- name: Test PDF workspace
|
||||
run: pnpm --filter @hanzo/pdf test
|
||||
|
||||
- name: Test Clio integration
|
||||
run: pnpm --filter @hanzo/clio test
|
||||
|
||||
- name: Test Notion integration
|
||||
run: pnpm --filter @hanzo/notion test
|
||||
|
||||
- name: Test DocuSign integration
|
||||
run: pnpm --filter @hanzo/docusign test
|
||||
|
||||
- name: Test Salesforce (pure helpers)
|
||||
run: pnpm --filter @hanzo/salesforce test
|
||||
|
||||
- name: Test Zendesk app
|
||||
run: pnpm --filter @hanzo/zendesk test
|
||||
|
||||
- name: Test HubSpot app
|
||||
run: pnpm --filter @hanzo/hubspot test
|
||||
|
||||
- name: Test Shopify app
|
||||
run: pnpm --filter @hanzo/shopify test
|
||||
|
||||
- name: Test Figma plugin
|
||||
run: pnpm --filter @hanzo/figma test
|
||||
|
||||
- name: Test Sketch plugin
|
||||
run: pnpm --filter @hanzo/sketch test
|
||||
|
||||
- name: Test Canva app
|
||||
run: pnpm --filter @hanzo/canva test
|
||||
|
||||
- name: Test GitHub App
|
||||
run: pnpm --filter @hanzo/github test
|
||||
|
||||
- name: Test Raycast extension
|
||||
run: pnpm --filter hanzo-ai test
|
||||
|
||||
- name: Test JupyterLab extension
|
||||
run: pnpm --filter @hanzo/jupyter test
|
||||
|
||||
- name: Test GitLab integration
|
||||
run: pnpm --filter @hanzo/gitlab test
|
||||
|
||||
- name: Test Desktop app (frontend)
|
||||
run: pnpm --filter @hanzo/desktop test
|
||||
|
||||
- name: Test Procore (construction)
|
||||
run: pnpm --filter @hanzo/procore test
|
||||
|
||||
- name: Test QuickBooks (finance)
|
||||
run: pnpm --filter @hanzo/quickbooks test
|
||||
|
||||
- name: Test cards (shared card-builder)
|
||||
run: pnpm --filter @hanzo/cards test
|
||||
|
||||
- name: Test Slack app
|
||||
run: pnpm --filter @hanzo/slack test
|
||||
|
||||
- name: Test Teams app
|
||||
run: pnpm --filter @hanzo/teams test
|
||||
|
||||
- name: Test Zoom + Meet
|
||||
run: pnpm --filter @hanzo/meetings test
|
||||
|
||||
- name: Test tools package
|
||||
run: pnpm --filter @hanzo/tools test
|
||||
continue-on-error: true
|
||||
run: pnpm --filter @hanzo/cli-tools test
|
||||
|
||||
- name: Test VS Code extension
|
||||
run: pnpm --filter hanzo-ide test
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# The extension host is Electron: it needs a display plus the
|
||||
# chromium-class shared libraries. Arc nodes are minimal (no xvfb,
|
||||
# no libnspr4/libnss3), so provision explicitly (noble t64 names).
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq xvfb libnss3 libnspr4 libatk1.0-0t64 \
|
||||
libatk-bridge2.0-0t64 libcups2t64 libdrm2 libxkbcommon0 \
|
||||
libatspi2.0-0t64 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
|
||||
libgbm1 libasound2t64 libgtk-3-0t64 libxshmfence1 libsecret-1-0 \
|
||||
libxkbfile1
|
||||
xvfb-run -a pnpm --filter hanzo-ide test
|
||||
|
||||
# ─── Playwright E2E ───
|
||||
e2e:
|
||||
name: E2E
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -82,7 +171,7 @@ jobs:
|
||||
# ─── Build all extensions ───
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: [test, e2e]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -106,10 +195,15 @@ jobs:
|
||||
- name: Package browser extensions
|
||||
working-directory: packages/browser
|
||||
run: |
|
||||
VER="${{ steps.version.outputs.ver }}"
|
||||
cd dist/browser-extension/chrome && zip -r /tmp/hanzo-ai-chrome-v${VER}.zip .
|
||||
cd ../firefox && zip -r /tmp/hanzo-ai-firefox-v${VER}.zip .
|
||||
cd ../chrome && zip -r /tmp/hanzo-ai-edge-v${VER}.zip .
|
||||
# web-ext (already used for linting below) does the zipping — the
|
||||
# arc runner image has no `zip` binary. Version comes from the
|
||||
# browser package.json, the single source of truth build.js stamps
|
||||
# into the manifests (the root version can lag it).
|
||||
VER=$(node -p "require('./package.json').version")
|
||||
npx web-ext build --source-dir dist/browser-extension/chrome --artifacts-dir /tmp --filename hanzo-ai-chrome-v${VER}.zip --overwrite-dest
|
||||
npx web-ext build --source-dir dist/browser-extension/firefox --artifacts-dir /tmp --filename hanzo-ai-firefox-v${VER}.zip --overwrite-dest
|
||||
# Edge uses the Chrome zip (Chromium-compatible)
|
||||
cp /tmp/hanzo-ai-chrome-v${VER}.zip /tmp/hanzo-ai-edge-v${VER}.zip
|
||||
|
||||
- name: Lint Firefox
|
||||
working-directory: packages/browser
|
||||
@@ -148,6 +242,20 @@ jobs:
|
||||
VER="${{ steps.version.outputs.ver }}"
|
||||
cp dist/hanzoai-*.dxt /tmp/hanzo-ai-claude-v${VER}.dxt 2>/dev/null || true
|
||||
|
||||
# ── Microsoft Office add-in (Word/Excel/PowerPoint) ──
|
||||
- name: Build Office add-in
|
||||
run: |
|
||||
VER="${{ steps.version.outputs.ver }}"
|
||||
# Two builds, two zips (node-based bestzip — the arc image has no `zip`):
|
||||
# - production: manifest points at office.hanzo.ai (AppSource / admin deploy)
|
||||
# - localhost: manifest points at https://localhost:3000 + serve.mjs +
|
||||
# SIDELOAD-WINDOWS.md — the self-contained "test on my machine" kit
|
||||
pnpm --filter @hanzo/office-addin build
|
||||
(cd packages/office/dist && npx --yes bestzip /tmp/hanzo-ai-office-v${VER}.zip .)
|
||||
HANZO_OFFICE_BASE=https://localhost:3000 pnpm --filter @hanzo/office-addin build
|
||||
(cd packages/office/dist && npx --yes bestzip /tmp/hanzo-ai-office-localhost-v${VER}.zip .)
|
||||
continue-on-error: true
|
||||
|
||||
# ── MCP npm package ──
|
||||
- name: Build MCP server
|
||||
run: pnpm --filter @hanzo/mcp run build
|
||||
@@ -211,7 +319,7 @@ jobs:
|
||||
# ─── JetBrains ───
|
||||
build-jetbrains:
|
||||
name: JetBrains
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: [test, e2e]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -243,7 +351,7 @@ jobs:
|
||||
# ─── GitHub Release (on tag push) ───
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: [build, build-safari, build-jetbrains]
|
||||
if: >-
|
||||
always() &&
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
extension-e2e:
|
||||
name: Extension E2E (${{ matrix.browser }})
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
summary:
|
||||
name: Parity Summary
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: [e2e-matrix, extension-e2e]
|
||||
if: always()
|
||||
steps:
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-deploy-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
+435
-16
@@ -20,7 +20,7 @@ jobs:
|
||||
# ─── Check available secrets ───
|
||||
secrets:
|
||||
name: Load KMS Secrets
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
outputs:
|
||||
has-chrome: ${{ steps.check.outputs.has-chrome }}
|
||||
has-firefox: ${{ steps.check.outputs.has-firefox }}
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
# ─── Chrome + Firefox ───
|
||||
browser:
|
||||
name: Chrome + Firefox
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -58,12 +58,17 @@ jobs:
|
||||
- name: Package
|
||||
working-directory: packages/browser
|
||||
run: |
|
||||
# web-ext (already used for linting below) does the zipping — the
|
||||
# arc runner image has no `zip` binary. The old `zip -r ../../../…`
|
||||
# also escaped dist/, so the artifact globs and the store upload
|
||||
# never saw the files. The Safari bundle comes from the dedicated
|
||||
# macOS job — the Linux build has no Xcode and emits no safari
|
||||
# manifest, so packaging it here would ship junk.
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
cd dist/browser-extension/chrome && zip -r ../../../hanzo-ai-chrome-v${VERSION}.zip .
|
||||
cd ../../browser-extension/firefox && zip -r ../../../hanzo-ai-firefox-v${VERSION}.zip .
|
||||
cd ../../browser-extension/safari && zip -r ../../../hanzo-ai-safari-v${VERSION}.zip .
|
||||
# Edge uses Chrome zip (Chromium-compatible)
|
||||
cd ../../.. && cp hanzo-ai-chrome-v${VERSION}.zip hanzo-ai-edge-v${VERSION}.zip
|
||||
npx web-ext build --source-dir dist/browser-extension/chrome --artifacts-dir dist --filename hanzo-ai-chrome-v${VERSION}.zip --overwrite-dest
|
||||
npx web-ext build --source-dir dist/browser-extension/firefox --artifacts-dir dist --filename hanzo-ai-firefox-v${VERSION}.zip --overwrite-dest
|
||||
# Edge uses the Chrome zip (Chromium-compatible)
|
||||
cp dist/hanzo-ai-chrome-v${VERSION}.zip dist/hanzo-ai-edge-v${VERSION}.zip
|
||||
|
||||
- name: Upload browser zips
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -73,7 +78,6 @@ jobs:
|
||||
packages/browser/dist/hanzo-ai-chrome-v*.zip
|
||||
packages/browser/dist/hanzo-ai-edge-v*.zip
|
||||
packages/browser/dist/hanzo-ai-firefox-v*.zip
|
||||
packages/browser/dist/hanzo-ai-safari-v*.zip
|
||||
|
||||
- name: Lint Firefox
|
||||
working-directory: packages/browser
|
||||
@@ -89,11 +93,12 @@ jobs:
|
||||
-d "client_secret=${{ secrets.CHROME_CLIENT_SECRET }}" \
|
||||
-d "refresh_token=${{ secrets.CHROME_REFRESH_TOKEN }}" \
|
||||
-d "grant_type=refresh_token" | jq -r '.access_token')
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
curl -sf -X PUT \
|
||||
"https://www.googleapis.com/upload/chromewebstore/v1.1/items/${{ secrets.CHROME_EXTENSION_ID }}" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "x-goog-api-version: 2" \
|
||||
-T dist/hanzo-ai-chrome.zip
|
||||
-T dist/hanzo-ai-chrome-v${VERSION}.zip
|
||||
curl -sf -X POST \
|
||||
"https://www.googleapis.com/chromewebstore/v1.1/items/${{ secrets.CHROME_EXTENSION_ID }}/publish" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
@@ -149,10 +154,35 @@ jobs:
|
||||
-derivedDataPath dist/safari-build-ios
|
||||
continue-on-error: true
|
||||
|
||||
# Package the built .app(s) so the GitHub Release Safari link resolves — the
|
||||
# unsigned bundle a developer sideloads (source + built products).
|
||||
- name: Package Safari for release
|
||||
working-directory: packages/browser
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
cd dist
|
||||
# Zip the whole Safari output tree: the Xcode project (build.js always
|
||||
# emits dist/safari — a developer opens it to build+sign+sideload) plus any
|
||||
# built .app products. Guard so a `zip` exit code (e.g. 12 "nothing to do")
|
||||
# never blanks the release asset; only skip if there is genuinely nothing.
|
||||
TARGETS=$(ls -d safari safari-build-macos safari-build-ios 2>/dev/null || true)
|
||||
if [ -n "$TARGETS" ]; then
|
||||
zip -r -y "/tmp/hanzo-ai-safari-v${VERSION}.zip" $TARGETS || true
|
||||
fi
|
||||
ls -la /tmp/hanzo-ai-safari-v*.zip 2>/dev/null || echo "no Safari output produced"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Safari bundle
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-safari
|
||||
path: /tmp/hanzo-ai-safari-v*.zip
|
||||
continue-on-error: true
|
||||
|
||||
# ─── VS Code + Cursor + Windsurf + Antigravity + Open VSX ───
|
||||
vscode:
|
||||
name: VS Code Marketplace
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -213,7 +243,7 @@ jobs:
|
||||
# ─── npm (@hanzo/mcp) ───
|
||||
npm:
|
||||
name: npm
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
if: needs.secrets.outputs.has-npm == 'true'
|
||||
steps:
|
||||
@@ -236,9 +266,8 @@ jobs:
|
||||
# ─── JetBrains Marketplace ───
|
||||
jetbrains:
|
||||
name: JetBrains
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
if: needs.secrets.outputs.has-jetbrains == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
@@ -246,18 +275,351 @@ jobs:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Build and publish
|
||||
# Always build the downloadable plugin zip so the GitHub Release link works
|
||||
# even when the Marketplace token is absent (publish below is token-gated).
|
||||
- name: Build plugin
|
||||
working-directory: packages/jetbrains
|
||||
run: ./gradlew buildPlugin
|
||||
|
||||
- name: Package for release
|
||||
working-directory: packages/jetbrains
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
ZIP=$(ls build/distributions/*.zip | head -1)
|
||||
cp "$ZIP" "/tmp/hanzo-ai-jetbrains-v${VERSION}.zip"
|
||||
|
||||
- name: Upload JetBrains plugin
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-jetbrains
|
||||
path: /tmp/hanzo-ai-jetbrains-v*.zip
|
||||
|
||||
- name: Publish to JetBrains Marketplace
|
||||
if: needs.secrets.outputs.has-jetbrains == 'true'
|
||||
working-directory: packages/jetbrains
|
||||
run: ./gradlew publishPlugin
|
||||
env:
|
||||
PUBLISH_TOKEN: ${{ secrets.JETBRAINS_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
# ─── Microsoft Office add-in (Word / Excel / PowerPoint) ───
|
||||
office:
|
||||
name: Office Add-in
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build + package
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
pnpm --filter @hanzo/office-addin build
|
||||
cd packages/office/dist && npx --yes bestzip "/tmp/hanzo-ai-office-v${VERSION}.zip" .
|
||||
|
||||
- name: Upload Office add-in
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-office
|
||||
path: /tmp/hanzo-ai-office-v*.zip
|
||||
|
||||
# ─── Microsoft Outlook mail add-in ───
|
||||
outlook:
|
||||
name: Outlook Add-in
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build + package
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
pnpm --filter @hanzo/outlook-addin build
|
||||
cd packages/outlook/dist && npx --yes bestzip "/tmp/hanzo-ai-outlook-v${VERSION}.zip" .
|
||||
|
||||
- name: Upload Outlook add-in
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-outlook
|
||||
path: /tmp/hanzo-ai-outlook-v*.zip
|
||||
|
||||
# ─── Figma plugin ───
|
||||
figma:
|
||||
name: Figma Plugin
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build + package
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
pnpm --filter @hanzo/cards build
|
||||
pnpm --filter @hanzo/figma build
|
||||
# dist/ = manifest.json + code.js + ui.html. Import via
|
||||
# Figma → Plugins → Development → Import plugin from manifest.
|
||||
cd packages/figma/dist && npx --yes bestzip "/tmp/hanzo-ai-figma-v${VERSION}.zip" .
|
||||
|
||||
- name: Upload Figma plugin
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-figma
|
||||
path: /tmp/hanzo-ai-figma-v*.zip
|
||||
|
||||
# ─── Sketch plugin ───
|
||||
sketch:
|
||||
name: Sketch Plugin
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build + package
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
pnpm --filter @hanzo/cards build
|
||||
pnpm --filter @hanzo/sketch build
|
||||
# dist/ contains the "Hanzo AI.sketchplugin" bundle; zip from dist so the
|
||||
# bundle sits at the archive root (double-click to install into Sketch).
|
||||
cd packages/sketch/dist && npx --yes bestzip "/tmp/hanzo-ai-sketch-v${VERSION}.zip" .
|
||||
|
||||
- name: Upload Sketch plugin
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-sketch
|
||||
path: /tmp/hanzo-ai-sketch-v*.zip
|
||||
|
||||
# ─── Microsoft Teams app ───
|
||||
teams:
|
||||
name: Teams App
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build + package
|
||||
env:
|
||||
# The manifest builder substitutes the bot app id into the sideloadable
|
||||
# package and errors on unset placeholders. Use the registered bot id when
|
||||
# available; otherwise a nil-GUID placeholder so the release asset always
|
||||
# builds (sideloaders replace it with their own Azure Bot registration).
|
||||
MICROSOFT_APP_ID: ${{ secrets.MICROSOFT_APP_ID != '' && secrets.MICROSOFT_APP_ID || '00000000-0000-0000-0000-000000000000' }}
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
pnpm --filter @hanzo/cards build
|
||||
pnpm --filter @hanzo/teams build
|
||||
# The teams `package` script emits dist/hanzo-teams.zip (manifest + icons).
|
||||
pnpm --filter @hanzo/teams package
|
||||
cp packages/teams/dist/hanzo-teams.zip "/tmp/hanzo-ai-teams-v${VERSION}.zip"
|
||||
|
||||
- name: Upload Teams app
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-teams
|
||||
path: /tmp/hanzo-ai-teams-v*.zip
|
||||
|
||||
# ─── Zendesk Support app ───
|
||||
zendesk:
|
||||
name: Zendesk App
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build + package
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
pnpm --filter @hanzo/cards build
|
||||
pnpm --filter @hanzo/zendesk build
|
||||
# dist/ is the ZAF v2 app (manifest.json + assets/ + translations/).
|
||||
cd packages/zendesk/dist && npx --yes bestzip "/tmp/hanzo-ai-zendesk-v${VERSION}.zip" .
|
||||
|
||||
- name: Upload Zendesk app
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-zendesk
|
||||
path: /tmp/hanzo-ai-zendesk-v*.zip
|
||||
|
||||
# ─── Desktop app — per-OS Tauri installers ───
|
||||
desktop:
|
||||
name: Desktop (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: secrets
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest, ubuntu-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Tauri Linux deps
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libgtk-3-dev \
|
||||
libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- run: pnpm install
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm --filter @hanzo/desktop build
|
||||
|
||||
- name: Build Tauri installers
|
||||
run: pnpm --filter @hanzo/desktop tauri:build
|
||||
continue-on-error: true
|
||||
|
||||
# Collect the native installer(s) and rename to a stable, per-OS release name.
|
||||
# Signing/notarization is future work — a per-OS failure must not block others.
|
||||
- name: Package for release (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
mkdir -p desktop-artifacts
|
||||
DMG=$(ls packages/desktop/src-tauri/target/release/bundle/dmg/*.dmg 2>/dev/null | head -1)
|
||||
if [ -n "$DMG" ]; then cp "$DMG" "desktop-artifacts/hanzo-ai-desktop-macos-v${VERSION}.dmg"; fi
|
||||
ls -la desktop-artifacts/ || echo "no macOS installer produced"
|
||||
|
||||
- name: Package for release (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
mkdir -p desktop-artifacts
|
||||
MSI=$(ls packages/desktop/src-tauri/target/release/bundle/msi/*.msi 2>/dev/null | head -1)
|
||||
EXE=$(ls packages/desktop/src-tauri/target/release/bundle/nsis/*.exe 2>/dev/null | head -1)
|
||||
if [ -n "$MSI" ]; then cp "$MSI" "desktop-artifacts/hanzo-ai-desktop-windows-v${VERSION}.msi"; fi
|
||||
if [ -n "$EXE" ]; then cp "$EXE" "desktop-artifacts/hanzo-ai-desktop-windows-v${VERSION}.exe"; fi
|
||||
ls -la desktop-artifacts/ || echo "no Windows installer produced"
|
||||
|
||||
- name: Package for release (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
mkdir -p desktop-artifacts
|
||||
APPIMAGE=$(ls packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage 2>/dev/null | head -1)
|
||||
DEB=$(ls packages/desktop/src-tauri/target/release/bundle/deb/*.deb 2>/dev/null | head -1)
|
||||
if [ -n "$APPIMAGE" ]; then cp "$APPIMAGE" "desktop-artifacts/hanzo-ai-desktop-linux-v${VERSION}.AppImage"; fi
|
||||
if [ -n "$DEB" ]; then cp "$DEB" "desktop-artifacts/hanzo-ai-desktop-linux-v${VERSION}.deb"; fi
|
||||
ls -la desktop-artifacts/ || echo "no Linux installer produced"
|
||||
|
||||
- name: Upload Desktop installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-desktop-${{ runner.os }}
|
||||
path: desktop-artifacts/*
|
||||
if-no-files-found: ignore
|
||||
continue-on-error: true
|
||||
|
||||
# ─── JupyterLab extension (Python wheel) ───
|
||||
jupyter:
|
||||
name: JupyterLab Extension
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# JupyterLab provides `jlpm` (the yarn wrapper the labextension build calls) and
|
||||
# the `build` frontend. Install it BEFORE the labextension build, else
|
||||
# `jlpm build:lib` fails with `jlpm: not found` and skips the whole release.
|
||||
- name: Install JupyterLab toolchain
|
||||
continue-on-error: true
|
||||
run: python -m pip install --upgrade jupyterlab build
|
||||
|
||||
# Build the PROD labextension via pnpm (NOT jlpm). jlpm is yarn, which refuses to
|
||||
# `install` inside this pnpm monorepo ("not part of the project"). Running the
|
||||
# scripts through pnpm uses the already-installed node_modules and produces
|
||||
# hanzo_jupyter/labextension/static/style.js — the hook's skip-if-exists target,
|
||||
# so the wheel build below skips jlpm entirely.
|
||||
- name: Build labextension (prod, via pnpm)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
pnpm --filter @hanzo/jupyter run build:lib:prod
|
||||
pnpm --filter @hanzo/jupyter run build:labextension
|
||||
|
||||
- name: Build wheel
|
||||
continue-on-error: true
|
||||
working-directory: packages/jupyter
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
# skip-if-exists (hanzo_jupyter/labextension/static/style.js) is satisfied by
|
||||
# the prod labextension above, so hatch-jupyter-builder does NOT invoke jlpm.
|
||||
python -m build --wheel
|
||||
mkdir -p /tmp/jupyter
|
||||
WHL=$(ls dist/hanzo_jupyter-*.whl dist/hanzo_ai_jupyter-*.whl 2>/dev/null | head -1)
|
||||
if [ -n "$WHL" ]; then
|
||||
# Rename to the tag version so the advertised release link resolves even
|
||||
# if pyproject's version has not been synced to the tag.
|
||||
cp "$WHL" "/tmp/jupyter/hanzo_jupyter-${VERSION}-py3-none-any.whl"
|
||||
fi
|
||||
ls -la /tmp/jupyter/ || echo "no wheel produced"
|
||||
|
||||
- name: Upload JupyterLab wheel
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-jupyter
|
||||
path: /tmp/jupyter/*.whl
|
||||
if-no-files-found: ignore
|
||||
continue-on-error: true
|
||||
|
||||
# ─── GitHub Release with downloadable artifacts ───
|
||||
release:
|
||||
name: GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [browser, safari, vscode]
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: [browser, safari, vscode, jetbrains, office, outlook, figma, sketch, teams, zendesk, desktop, jupyter]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -286,6 +648,15 @@ jobs:
|
||||
| **Claude Desktop/Code** | Windows / macOS / Linux | [`hanzo-ai-claude-v${{ steps.version.outputs.version }}.dxt`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-claude-v${{ steps.version.outputs.version }}.dxt) |
|
||||
| **JetBrains** | Windows / macOS / Linux | [`hanzo-ai-jetbrains-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-jetbrains-v${{ steps.version.outputs.version }}.zip) |
|
||||
|
||||
## Microsoft Office
|
||||
|
||||
| App | Platform | Download |
|
||||
|-----|----------|----------|
|
||||
| **Word · Excel · PowerPoint** | Windows / macOS / Web / iPad | [`hanzo-ai-office-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-office-v${{ steps.version.outputs.version }}.zip) |
|
||||
| **Outlook** | Windows / macOS / Web | [`hanzo-ai-outlook-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-outlook-v${{ steps.version.outputs.version }}.zip) |
|
||||
|
||||
> Office: unzip and sideload `manifest.xml` via the M365 admin center, or Insert → Add-ins → Upload My Add-in.
|
||||
|
||||
## Browser Extensions
|
||||
|
||||
| Browser | Platform | Download |
|
||||
@@ -298,8 +669,56 @@ jobs:
|
||||
> Chrome zip also works for Brave, Opera, Vivaldi, Arc.
|
||||
> Cursor, Windsurf, and Antigravity use the VS Code VSIX format.
|
||||
|
||||
## Design Tools
|
||||
|
||||
| App | Platform | Download |
|
||||
|-----|----------|----------|
|
||||
| **Figma** | Windows / macOS / Web | [`hanzo-ai-figma-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-figma-v${{ steps.version.outputs.version }}.zip) |
|
||||
| **Sketch** | macOS | [`hanzo-ai-sketch-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-sketch-v${{ steps.version.outputs.version }}.zip) |
|
||||
|
||||
> Figma: unzip and import `manifest.json` via Figma → Plugins → Development → Import plugin from manifest.
|
||||
> Sketch: unzip and double-click `Hanzo AI.sketchplugin`.
|
||||
|
||||
## Team Apps
|
||||
|
||||
| App | Platform | Download |
|
||||
|-----|----------|----------|
|
||||
| **Microsoft Teams** | Windows / macOS / Web | [`hanzo-ai-teams-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-teams-v${{ steps.version.outputs.version }}.zip) |
|
||||
| **Zendesk** | Web | [`hanzo-ai-zendesk-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-zendesk-v${{ steps.version.outputs.version }}.zip) |
|
||||
|
||||
> Teams: sideload the zip via Teams → Apps → Manage your apps → Upload an app. Set your own Azure Bot app id before publishing.
|
||||
> Zendesk: upload the zip via the Zendesk Admin Center → Apps and integrations → Upload private app.
|
||||
|
||||
## Desktop App
|
||||
|
||||
| OS | Format | Download |
|
||||
|----|--------|----------|
|
||||
| **macOS** | `.dmg` | [`hanzo-ai-desktop-macos-v${{ steps.version.outputs.version }}.dmg`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-desktop-macos-v${{ steps.version.outputs.version }}.dmg) |
|
||||
| **Windows** | `.msi` / `.exe` | [`hanzo-ai-desktop-windows-v${{ steps.version.outputs.version }}.msi`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-desktop-windows-v${{ steps.version.outputs.version }}.msi) · [`.exe`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-desktop-windows-v${{ steps.version.outputs.version }}.exe) |
|
||||
| **Linux** | `.AppImage` / `.deb` | [`hanzo-ai-desktop-linux-v${{ steps.version.outputs.version }}.AppImage`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-desktop-linux-v${{ steps.version.outputs.version }}.AppImage) · [`.deb`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-desktop-linux-v${{ steps.version.outputs.version }}.deb) |
|
||||
|
||||
> Desktop installers are unsigned for now — macOS: right-click → Open; Windows: "More info" → Run anyway.
|
||||
|
||||
## Data Science
|
||||
|
||||
| App | Platform | Download |
|
||||
|-----|----------|----------|
|
||||
| **JupyterLab** | Windows / macOS / Linux | [`hanzo_jupyter-${{ steps.version.outputs.version }}-py3-none-any.whl`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo_jupyter-${{ steps.version.outputs.version }}-py3-none-any.whl) |
|
||||
|
||||
> Jupyter: `pip install hanzo_jupyter-*.whl` then restart JupyterLab.
|
||||
|
||||
**Install**: `code --install-extension hanzo-ai-vscode-v${{ steps.version.outputs.version }}.vsix`
|
||||
**Antigravity**: `antigravity --install-extension hanzo-ai-antigravity-v${{ steps.version.outputs.version }}.vsix`
|
||||
files: |
|
||||
artifacts/hanzo-ai-browser/*
|
||||
artifacts/hanzo-ai-ide/*
|
||||
artifacts/hanzo-ai-jetbrains/*
|
||||
artifacts/hanzo-ai-office/*
|
||||
artifacts/hanzo-ai-outlook/*
|
||||
artifacts/hanzo-ai-safari/*
|
||||
artifacts/hanzo-ai-figma/*
|
||||
artifacts/hanzo-ai-sketch/*
|
||||
artifacts/hanzo-ai-teams/*
|
||||
artifacts/hanzo-ai-zendesk/*
|
||||
artifacts/hanzo-ai-desktop-*/*
|
||||
artifacts/hanzo-ai-jupyter/*
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
out/
|
||||
dist/
|
||||
*.vsix
|
||||
*.xpi
|
||||
*.crx
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
# LLM.md - Hanzo Extension
|
||||
# Hanzo Extension
|
||||
|
||||
## Overview
|
||||
Hanzo AI Development Platform Monorepo
|
||||
Browser extension (`packages/browser`) that joins the shared local **zapd** fabric.
|
||||
One native primitive — no WebSocket, no localhost port, no mDNS, no CDP bridge.
|
||||
Ships Chrome / Firefox / Safari. Build: esbuild (`src/build.js`, `pnpm build`);
|
||||
tests: vitest.
|
||||
|
||||
## Tech Stack
|
||||
- **Language**: TypeScript/JavaScript
|
||||
## Architecture (native ZAP)
|
||||
|
||||
## Build & Run
|
||||
```bash
|
||||
pnpm install && pnpm build
|
||||
pnpm test
|
||||
```
|
||||
extension ─connectNative("ai.hanzo.zap")─► native host ─UDS─► zapd ◄─ hanzo-mcp
|
||||
|
||||
## Structure
|
||||
```
|
||||
extension/
|
||||
LICENSE
|
||||
LLM.md
|
||||
Makefile
|
||||
PUBLISHING.md
|
||||
README.md
|
||||
apps/
|
||||
benchmark/
|
||||
docs/
|
||||
examples/
|
||||
hanzo-ai-chrome-1.7.12.zip
|
||||
hanzo-ai-firefox-1.7.12.zip
|
||||
images/
|
||||
package.json
|
||||
packages/
|
||||
pnpm-lock.yaml
|
||||
```
|
||||
- `shared/native-zap.ts` — the ONE transport: `connectNativeZap()` opens the native
|
||||
port (**singleton** — one port; the router does evict-and-replace), registers as
|
||||
provider `browser:<engine>/<host>/default`, dispatches inbound ROUTE commands.
|
||||
Cross-browser (`browser ∥ chrome`).
|
||||
- `background.ts` (Chrome) — dispatch via `chrome.debugger` (CDP→tab; shows the
|
||||
"debugging this browser" banner; WebKit-incompatible). **To be removed** — see WXT plan.
|
||||
- `background-firefox.ts` — native `browser.*` dispatch (no banner). The correct model.
|
||||
- `browser-dispatch.ts` — `chrome.debugger` actuation (Chrome-only).
|
||||
- `shared/evaluable.ts` — the ONE evaluate rule: `pickEvaluable` (code-param
|
||||
aliases) + `wrapEvaluable` (bare expression passes through; statement bodies
|
||||
→ async IIFE with the trailing value auto-returned). Both dispatch paths
|
||||
consume it, so Chrome and Firefox accept identical caller JS.
|
||||
|
||||
## Key Files
|
||||
- `README.md` -- Project documentation
|
||||
- `package.json` -- Dependencies and scripts
|
||||
- `Makefile` -- Build automation
|
||||
Wire to zapd: the binary ZAP router envelope (`zap-proto/zapd/src/frame.rs`); the
|
||||
browser↔host hop is native-messaging JSON, base64'd, quarantined in the host. Host +
|
||||
per-browser manifests come from `zapd install-host` (`zap-proto/zapd`).
|
||||
|
||||
## Versioning
|
||||
Patch only (X.Y.Z+1), never major. `package.json` is the source of truth; `build.js`
|
||||
stamps it into every manifest (chrome/firefox/safari).
|
||||
|
||||
## Cross-platform — WXT migration (canonical plan, not yet executed)
|
||||
|
||||
One WebExtension codebase, every engine. **WXT** = build/SDK layer (build matrix,
|
||||
manifest gen, targets, MV3, Vite/TS); `webextension-polyfill` / `@wxt-dev/browser` =
|
||||
`browser.*` normalization. **Custom (keep):** `shared/native-zap.ts`, and the Safari
|
||||
`SafariWebExtensionHandler.swift` → `~/.zap/run/zapd.sock` bridge. WXT builds Safari
|
||||
but does NOT give WebKit `chrome.debugger` — so dispatch must become portable
|
||||
`browser.*` (the Firefox model is already correct).
|
||||
|
||||
1. Adopt WXT build matrix (chrome/firefox/safari) — replaces `build.js`.
|
||||
2. Collapse forks → one `background.ts`; delete `background-firefox.ts`; runtime
|
||||
`browser` adapter.
|
||||
3. Collapse dispatch → delete `chrome.debugger` / `browser-dispatch.ts`; actuate over
|
||||
`tabs`/`scripting`/`webNavigation`/`browser.*` (portable, no banner, Safari-capable).
|
||||
4. Transport stays per-platform: Chrome/Firefox `connectNative` → host → `zapd.sock`;
|
||||
Safari `SafariWebExtensionHandler.swift` → `zapd.sock`.
|
||||
5. CI guards (last; go red until 1–4 land): fail if `chrome.debugger`, the `debugger`
|
||||
permission, or `background-firefox.ts` appears.
|
||||
|
||||
Large, multi-phase — do it as a focused pass, never half-merged.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="extension" width="880"></p>
|
||||
|
||||
# Dev - Ship Up to 100X Faster with Parallel AI Agents 🚀
|
||||
|
||||
[](https://github.com/hanzoai/dev/actions/workflows/vscode-extension.yml)
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.1",
|
||||
"terser": "^5.43.1",
|
||||
"vite": "^5.4.19"
|
||||
"vite": "^6.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ VS Code extensions are signed automatically when published to the marketplace.
|
||||
- `VSCODE_ENV`: Set to 'development' or 'production'
|
||||
- `HANZO_WORKSPACE`: Default workspace for MCP operations
|
||||
- `HANZO_ANONYMOUS`: Set to 'true' for anonymous mode
|
||||
- `HANZO_IAM_ENDPOINT`: Custom IAM endpoint (default: https://iam.hanzo.ai)
|
||||
- `IAM_ENDPOINT`: Custom IAM endpoint (default: https://iam.hanzo.ai)
|
||||
|
||||
## Testing Builds
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ The extension will generate `.windsurfrules` file.
|
||||
- `HANZO_ANONYMOUS` - Set to 'true' for anonymous mode
|
||||
- `MCP_TRANSPORT` - Transport method (stdio or tcp)
|
||||
- `MCP_PORT` - Port for TCP transport (default: 3000)
|
||||
- `HANZO_IAM_ENDPOINT` - Custom IAM endpoint (default: https://iam.hanzo.ai)
|
||||
- `IAM_ENDPOINT` - Custom IAM endpoint (default: https://iam.hanzo.ai)
|
||||
|
||||
### Tool Configuration
|
||||
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
# Comprehensive Tool Testing Report
|
||||
|
||||
## Overview
|
||||
|
||||
All core tools have been implemented, tested, and benchmarked. The extension now supports 56 tools total, with 27 enabled by default.
|
||||
|
||||
## Tool Implementation Status
|
||||
|
||||
### ✅ Fully Implemented and Tested Tools
|
||||
|
||||
#### File System Tools (6/6)
|
||||
- ✅ **read** - Read file contents with line numbers
|
||||
- ✅ **write** - Write content to files
|
||||
- ✅ **edit** - Edit files by replacing patterns
|
||||
- ✅ **multi_edit** - Multiple edits in one operation
|
||||
- ✅ **directory_tree** - Display directory structure
|
||||
- ✅ **find_files** - Find files matching patterns
|
||||
|
||||
#### Search Tools (4/4)
|
||||
- ✅ **grep** - Pattern search using ripgrep
|
||||
- ✅ **search** - Unified search across files/symbols/git
|
||||
- ✅ **symbols** - Search code symbols
|
||||
- ✅ **unified_search** - Parallel search across all types
|
||||
|
||||
#### Shell Tools (3/3)
|
||||
- ✅ **run_command** - Execute shell commands
|
||||
- ✅ **open** - Open files/URLs
|
||||
- ✅ **process** - Background process management with logging
|
||||
|
||||
#### Development Tools (5/5)
|
||||
- ✅ **todo_read** - Read todo list
|
||||
- ✅ **todo_write** - Write todo items
|
||||
- ✅ **todo_unified** - Unified todo management
|
||||
- ✅ **think** - Structured thinking space
|
||||
- ✅ **critic** - Code review and analysis
|
||||
|
||||
#### Configuration Tools (3/3)
|
||||
- ✅ **config** - Git-style configuration
|
||||
- ✅ **rules** - Project conventions (.cursorrules, .clauderc)
|
||||
- ✅ **palette** - Tool personality switching
|
||||
|
||||
#### Database & AI Tools (6/6)
|
||||
- ✅ **graph_db** - Graph database with AST integration
|
||||
- ✅ **vector_index** - Index documents for vector search
|
||||
- ✅ **vector_search** - Semantic search with embeddings
|
||||
- ✅ **vector_similar** - Find similar documents
|
||||
- ✅ **document_store** - Chat document management
|
||||
- ✅ **zen** - Hanzo Zen1 AI model (local/cloud)
|
||||
|
||||
#### Utility Tools (2/2)
|
||||
- ✅ **batch** - Batch operations
|
||||
- ✅ **web_fetch** - Fetch and analyze web content
|
||||
|
||||
## Test Results
|
||||
|
||||
### Functionality Tests
|
||||
|
||||
All key tools passed functionality tests:
|
||||
- ✅ think - Thought recording works
|
||||
- ✅ critic - Code analysis works
|
||||
- ✅ unified_search - Parallel search works
|
||||
- ✅ graph_db - Node/edge operations work
|
||||
- ✅ vector_index - Document indexing works
|
||||
- ✅ document_store - Document management works
|
||||
- ✅ web_fetch - Web content fetching works
|
||||
- ✅ palette - Tool switching works
|
||||
- ✅ config - Configuration management works
|
||||
- ✅ process - Background process management works
|
||||
|
||||
### Performance Benchmarks
|
||||
|
||||
#### Graph Database
|
||||
- **Add nodes**: 6,768 nodes/ms (excellent)
|
||||
- **Query performance**: < 0.1ms for most operations
|
||||
- **Path finding**: < 0.01ms average
|
||||
- **Connected components**: 0.42ms for full analysis
|
||||
|
||||
#### Vector Store
|
||||
- **Index documents**: 211 documents/ms
|
||||
- **Search performance**: 0.49ms average (🟢 Fast)
|
||||
- **Filtered search**: 0.24ms average
|
||||
- **Metadata search**: 0.11ms average
|
||||
|
||||
#### AST Index
|
||||
- **File indexing**: ~5,610 files/second
|
||||
- **Symbol search**: < 0.02ms
|
||||
- **Reference finding**: < 0.01ms
|
||||
- **Call hierarchy**: 0.02ms average
|
||||
|
||||
#### Document Store
|
||||
- **Add documents**: 13ms per batch
|
||||
- **Search documents**: < 0.01ms
|
||||
- **Session save**: 1.64ms average
|
||||
|
||||
## Backend Abstraction
|
||||
|
||||
### Local vs Cloud Support
|
||||
|
||||
✅ **Local Backend**
|
||||
- In-memory graph database
|
||||
- Local vector store with mock embeddings
|
||||
- File-based document persistence
|
||||
- Support for Ollama and LM Studio
|
||||
- Hanzo local model support
|
||||
|
||||
✅ **Cloud Backend**
|
||||
- API-based operations
|
||||
- Real embeddings from cloud
|
||||
- Persistent storage
|
||||
- Authentication via API key
|
||||
- Unified interface with local
|
||||
|
||||
### AI Model Support
|
||||
|
||||
✅ **Local AI Providers**
|
||||
- Ollama (auto-detected at localhost:11434)
|
||||
- LM Studio (auto-detected at localhost:1234)
|
||||
- Hanzo Local Models (zen1, zen1-mini, zen1-code)
|
||||
|
||||
✅ **Cloud AI Providers**
|
||||
- Hanzo Cloud API
|
||||
- Fallback to OpenAI/Anthropic
|
||||
- Unified LLM interface
|
||||
|
||||
## Critic Tool Capabilities
|
||||
|
||||
The critic tool provides comprehensive code analysis:
|
||||
|
||||
1. **Security Analysis**
|
||||
- SQL injection detection
|
||||
- XSS vulnerability checks
|
||||
- Authentication/authorization issues
|
||||
- Sensitive data exposure
|
||||
|
||||
2. **Performance Analysis**
|
||||
- Algorithm complexity
|
||||
- Database query optimization
|
||||
- Memory leak detection
|
||||
- Unnecessary computations
|
||||
|
||||
3. **Code Quality**
|
||||
- Naming conventions
|
||||
- Code organization
|
||||
- Documentation coverage
|
||||
- Error handling
|
||||
|
||||
4. **Correctness**
|
||||
- Logic errors
|
||||
- Edge case handling
|
||||
- Type safety
|
||||
- Test coverage
|
||||
|
||||
## Missing/Partial Implementations
|
||||
|
||||
### Tools Not Yet Implemented
|
||||
- ❌ AST analyzer (compilation issues with TypeScript parser)
|
||||
- ❌ Tree-sitter analyzer (module resolution issues)
|
||||
- ❌ Jupyter notebook tools (notebook_read, notebook_edit)
|
||||
- ❌ SQL database tools (sql_query, sql_search)
|
||||
- ❌ LLM consensus tool
|
||||
- ❌ Agent dispatch tool
|
||||
- ❌ Some system tools (memory, date, copy, move, delete)
|
||||
|
||||
### Limitations
|
||||
- Vector store uses mock embeddings (real embeddings need API integration)
|
||||
- Graph database is in-memory only for local mode
|
||||
- Document store requires file system access
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Enable More Tools by Default**
|
||||
- Consider enabling graph_db, vector tools, and zen by default
|
||||
- These are now fully tested and performant
|
||||
|
||||
2. **Real Embeddings**
|
||||
- Integrate with OpenAI/Cohere for real embeddings
|
||||
- Or use local sentence-transformers
|
||||
|
||||
3. **Persistent Storage**
|
||||
- Add SQLite backend option for local persistence
|
||||
- Implement proper backup/restore
|
||||
|
||||
4. **Complete AST Integration**
|
||||
- Fix TypeScript compilation issues
|
||||
- Add tree-sitter support for more languages
|
||||
|
||||
## Summary
|
||||
|
||||
✅ **27 tools** fully implemented and tested
|
||||
✅ **Excellent performance** across all subsystems
|
||||
✅ **Local and cloud** backend abstraction working
|
||||
✅ **AI model integration** for Ollama, LM Studio, and Hanzo
|
||||
✅ **Comprehensive testing** with benchmarks
|
||||
|
||||
The extension is production-ready with all core functionality working as expected.
|
||||
+87
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "hanzo-ai-monorepo",
|
||||
"version": "1.8.8",
|
||||
"name": "@hanzo/extension",
|
||||
"version": "1.9.31",
|
||||
"private": true,
|
||||
"description": "Hanzo AI Development Platform Monorepo",
|
||||
"description": "Hanzo AI Extension",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -29,5 +29,89 @@
|
||||
"build:jetbrains": "cd packages/jetbrains && ./gradlew build",
|
||||
"package:vscode": "pnpm --filter @hanzo/extension run package",
|
||||
"package:dxt": "pnpm --filter @hanzo/dxt run package"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@babel/core@<=7.29.0": "7.29.6",
|
||||
"@grpc/grpc-js@<1.9.16": "1.9.16",
|
||||
"@isaacs/brace-expansion@<=5.0.0": "5.0.1",
|
||||
"@modelcontextprotocol/sdk@<1.24.0": "1.24.0",
|
||||
"@modelcontextprotocol/sdk@<1.25.2": "1.25.2",
|
||||
"@modelcontextprotocol/sdk@>=1.10.0 <=1.25.3": "1.26.0",
|
||||
"@opentelemetry/core@<2.8.0": "2.8.0",
|
||||
"@protobufjs/utf8@<=1.1.0": "1.1.1",
|
||||
"ajv@<6.14.0": "6.14.0",
|
||||
"ajv@>=7.0.0-alpha.0 <8.18.0": "8.18.0",
|
||||
"axios@>=1.0.0 <=1.13.4": "1.13.5",
|
||||
"axios@>=1.0.0 <1.12.0": "1.12.0",
|
||||
"axios@>=1.0.0 <1.15.0": "1.15.0",
|
||||
"axios@>=1.0.0 <1.15.1": "1.15.1",
|
||||
"axios@>=1.0.0 <1.15.2": "1.15.2",
|
||||
"axios@>=1.0.0 <1.16.0": "1.16.0",
|
||||
"axios@>=1.7.0 <1.16.0": "1.16.0",
|
||||
"body-parser@>=2.2.0 <2.2.1": "2.2.1",
|
||||
"brace-expansion@<1.1.13": "1.1.13",
|
||||
"brace-expansion@>=2.0.0 <2.0.3": "2.0.3",
|
||||
"diff@>=6.0.0 <8.0.3": "8.0.3",
|
||||
"esbuild@<=0.24.2": "0.25.0",
|
||||
"fast-uri@<=3.1.0": "3.1.1",
|
||||
"fast-uri@<=3.1.1": "3.1.2",
|
||||
"file-type@<21.3.2": "21.3.2",
|
||||
"flatted@<3.4.2": "3.4.2",
|
||||
"follow-redirects@<=1.15.11": "1.16.0",
|
||||
"form-data@>=4.0.0 <4.0.4": "4.0.4",
|
||||
"form-data@>=4.0.0 <4.0.6": "4.0.6",
|
||||
"glob@>=10.2.0 <10.5.0": "10.5.0",
|
||||
"handlebars@>=4.0.0 <=4.7.8": "4.7.9",
|
||||
"handlebars@>=4.0.0 <4.7.9": "4.7.9",
|
||||
"handlebars@>=4.6.0 <=4.7.8": "4.7.9",
|
||||
"ip-address@<=10.1.0": "10.1.1",
|
||||
"js-yaml@<3.15.0": "3.15.0",
|
||||
"js-yaml@>=4.0.0 <4.2.0": "4.2.0",
|
||||
"jws@<3.2.3": "3.2.3",
|
||||
"linkify-it@<=5.0.0": "5.0.1",
|
||||
"lodash@<=4.17.23": "4.18.0",
|
||||
"lodash@>=4.0.0 <=4.17.22": "4.17.23",
|
||||
"lodash@>=4.0.0 <=4.17.23": "4.18.0",
|
||||
"markdown-it@<=14.1.1": "14.2.0",
|
||||
"min-document@<=2.19.0": "2.19.1",
|
||||
"minimatch@<3.1.3": "3.1.3",
|
||||
"minimatch@<3.1.4": "3.1.4",
|
||||
"minimatch@>=10.0.0 <10.2.1": "10.2.1",
|
||||
"minimatch@>=10.0.0 <10.2.3": "10.2.3",
|
||||
"minimatch@>=5.0.0 <5.1.7": "5.1.7",
|
||||
"minimatch@>=5.0.0 <5.1.8": "5.1.8",
|
||||
"minimatch@>=9.0.0 <9.0.6": "9.0.6",
|
||||
"minimatch@>=9.0.0 <9.0.7": "9.0.7",
|
||||
"path-to-regexp@>=8.0.0 <8.4.0": "8.4.0",
|
||||
"picomatch@<2.3.2": "2.3.2",
|
||||
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
|
||||
"playwright@<1.55.1": "1.55.1",
|
||||
"postcss@<8.5.10": "8.5.10",
|
||||
"protobufjs@<7.6.3": "7.6.3",
|
||||
"qs@<6.15.2": "6.15.2",
|
||||
"rollup@>=4.0.0 <4.59.0": "4.59.0",
|
||||
"serialize-javascript@<=7.0.2": "7.0.3",
|
||||
"serialize-javascript@>=5.0.0 <7.0.5": "7.0.5",
|
||||
"tar-fs@>=2.0.0 <2.1.4": "2.1.4",
|
||||
"tar-fs@>=3.0.0 <3.1.1": "3.1.1",
|
||||
"tar@<7.5.16": "7.5.16",
|
||||
"tmp@<0.2.7": "0.2.7",
|
||||
"underscore@<=1.13.7": "1.13.8",
|
||||
"undici@>=7.0.0 <7.18.2": "7.18.2",
|
||||
"undici@>=7.0.0 <7.24.0": "7.24.0",
|
||||
"undici@>=7.0.0 <7.28.0": "7.28.0",
|
||||
"uuid@<11.1.1": "11.1.1",
|
||||
"validator@<13.15.20": "13.15.20",
|
||||
"validator@<13.15.22": "13.15.22",
|
||||
"vite@<6.4.3": "6.4.3",
|
||||
"vitest@<3.2.6": "3.2.6",
|
||||
"ws@>=8.0.0 <8.20.1": "8.20.1",
|
||||
"ws@>=8.0.0 <8.21.0": "8.21.0",
|
||||
"yaml@>=2.0.0 <2.8.3": "2.8.3"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
@@ -32,7 +32,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.6.0",
|
||||
"vitest": "^3.2.6",
|
||||
"eslint": "^8.56.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0"
|
||||
@@ -48,4 +48,4 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/hanzoai/aci/issues"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
Copyright 2025 Hanzo Industries Inc
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,236 +0,0 @@
|
||||
# @hanzo/ai
|
||||
|
||||
The AI Toolkit for TypeScript - AgentKit with Model Context Protocol (MCP) support.
|
||||
|
||||
## Features
|
||||
|
||||
- 🤖 **Multi-Provider Support**: OpenAI, Anthropic, Google, Mistral, Bedrock, Vertex AI, Cohere, and more
|
||||
- 🔧 **MCP Integration**: Full Model Context Protocol support for tool calling
|
||||
- 🌐 **Agent Networks**: Create and orchestrate multiple AI agents
|
||||
- 📊 **Observability**: Built-in telemetry with OpenTelemetry support
|
||||
- 🔄 **Streaming**: First-class streaming support for all providers
|
||||
- 📝 **Type Safety**: Full TypeScript support with comprehensive types
|
||||
- 🎯 **Unified API**: Consistent interface across all providers
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @hanzo/ai
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Text Generation
|
||||
|
||||
```typescript
|
||||
import { generateText, openai } from '@hanzo/ai';
|
||||
|
||||
const result = await generateText({
|
||||
model: openai({ apiKey: process.env.OPENAI_API_KEY })('gpt-4'),
|
||||
prompt: 'What is the meaning of life?',
|
||||
maxTokens: 500
|
||||
});
|
||||
|
||||
console.log(result.text);
|
||||
```
|
||||
|
||||
### Creating an Agent
|
||||
|
||||
```typescript
|
||||
import { createAgent, anthropic } from '@hanzo/ai';
|
||||
|
||||
const agent = createAgent({
|
||||
name: 'Assistant',
|
||||
model: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })('claude-3-sonnet'),
|
||||
instructions: 'You are a helpful assistant.',
|
||||
tools: [
|
||||
{
|
||||
name: 'calculate',
|
||||
description: 'Perform mathematical calculations',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
expression: { type: 'string' }
|
||||
}
|
||||
},
|
||||
handler: async ({ expression }) => {
|
||||
return { result: eval(expression) };
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const response = await agent.run('What is 2 + 2?');
|
||||
```
|
||||
|
||||
### Agent Networks
|
||||
|
||||
```typescript
|
||||
import { createNetwork, createAgent, openai, anthropic } from '@hanzo/ai';
|
||||
|
||||
const network = createNetwork({
|
||||
agents: [
|
||||
createAgent({
|
||||
name: 'researcher',
|
||||
model: openai({ apiKey: process.env.OPENAI_API_KEY })('gpt-4'),
|
||||
instructions: 'You are a research specialist.'
|
||||
}),
|
||||
createAgent({
|
||||
name: 'writer',
|
||||
model: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })('claude-3-sonnet'),
|
||||
instructions: 'You are a creative writer.'
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Agents can collaborate
|
||||
const result = await network.run('Research and write a short story about AI');
|
||||
```
|
||||
|
||||
### Streaming
|
||||
|
||||
```typescript
|
||||
import { generateStream, google } from '@hanzo/ai';
|
||||
|
||||
const stream = await generateStream({
|
||||
model: google({ apiKey: process.env.GOOGLE_API_KEY })('gemini-pro'),
|
||||
prompt: 'Tell me a story',
|
||||
maxTokens: 1000
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
process.stdout.write(chunk.text);
|
||||
}
|
||||
```
|
||||
|
||||
### Object Generation
|
||||
|
||||
```typescript
|
||||
import { generateObject, openai } from '@hanzo/ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
interests: z.array(z.string())
|
||||
});
|
||||
|
||||
const result = await generateObject({
|
||||
model: openai({ apiKey: process.env.OPENAI_API_KEY })('gpt-4'),
|
||||
prompt: 'Generate a random person profile',
|
||||
schema
|
||||
});
|
||||
|
||||
console.log(result.object); // Typed as { name: string, age: number, interests: string[] }
|
||||
```
|
||||
|
||||
## MCP Support
|
||||
|
||||
@hanzo/ai includes full Model Context Protocol support:
|
||||
|
||||
```typescript
|
||||
import { createAgent } from '@hanzo/ai';
|
||||
import { createMCPClient } from '@hanzo/ai/mcp';
|
||||
|
||||
// Connect to an MCP server
|
||||
const mcpClient = await createMCPClient({
|
||||
command: 'node',
|
||||
args: ['path/to/mcp-server.js']
|
||||
});
|
||||
|
||||
// Use MCP tools with an agent
|
||||
const agent = createAgent({
|
||||
name: 'mcp-agent',
|
||||
model: openai({ apiKey: process.env.OPENAI_API_KEY })('gpt-4'),
|
||||
tools: await mcpClient.getTools()
|
||||
});
|
||||
```
|
||||
|
||||
## Providers
|
||||
|
||||
Supported providers with their respective models:
|
||||
|
||||
- **OpenAI**: GPT-4, GPT-3.5, o1-preview, o1-mini
|
||||
- **Anthropic**: Claude 3 (Opus, Sonnet, Haiku), Claude 2
|
||||
- **Google**: Gemini Pro, Gemini Ultra, PaLM
|
||||
- **Mistral**: Mistral Large, Medium, Small, Mixtral
|
||||
- **AWS Bedrock**: Access to multiple models
|
||||
- **Google Vertex AI**: Enterprise Google AI
|
||||
- **Cohere**: Command, Generate, Embed
|
||||
- **Hanzo**: Custom Hanzo models
|
||||
|
||||
## Telemetry
|
||||
|
||||
Built-in telemetry support with OpenTelemetry:
|
||||
|
||||
```typescript
|
||||
import { createHanzoCloudTelemetry } from '@hanzo/ai/telemetry';
|
||||
|
||||
// Initialize telemetry
|
||||
const telemetry = createHanzoCloudTelemetry({
|
||||
apiKey: process.env.HANZO_API_KEY,
|
||||
serviceName: 'my-ai-app'
|
||||
});
|
||||
|
||||
// All AI operations are automatically traced
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom Providers
|
||||
|
||||
```typescript
|
||||
import { createProvider } from '@hanzo/ai';
|
||||
|
||||
const customProvider = createProvider({
|
||||
name: 'custom',
|
||||
generateText: async ({ prompt, maxTokens }) => {
|
||||
// Your implementation
|
||||
return { text: 'Response' };
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Tool Creation
|
||||
|
||||
```typescript
|
||||
import { createTool } from '@hanzo/ai';
|
||||
|
||||
const weatherTool = createTool({
|
||||
name: 'get_weather',
|
||||
description: 'Get current weather for a location',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: { type: 'string' }
|
||||
},
|
||||
required: ['location']
|
||||
},
|
||||
handler: async ({ location }) => {
|
||||
// Implementation
|
||||
return { temperature: 72, condition: 'sunny' };
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
```typescript
|
||||
import { createState } from '@hanzo/ai';
|
||||
|
||||
const state = createState({
|
||||
messages: [],
|
||||
context: {}
|
||||
});
|
||||
|
||||
// Use with agents for conversation memory
|
||||
const agent = createAgent({
|
||||
name: 'stateful-agent',
|
||||
model: openai({ apiKey: process.env.OPENAI_API_KEY })('gpt-4'),
|
||||
state
|
||||
});
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT © Hanzo AI
|
||||
@@ -1,369 +0,0 @@
|
||||
# Telemetry & Observability
|
||||
|
||||
@hanzo/ai includes comprehensive telemetry and observability features that integrate seamlessly with Hanzo Cloud's monitoring platform.
|
||||
|
||||
## Overview
|
||||
|
||||
The telemetry system provides:
|
||||
- Distributed tracing with OpenTelemetry
|
||||
- Structured logging with Winston
|
||||
- Metrics collection (counters, gauges, histograms)
|
||||
- Session tracking
|
||||
- Error tracking and reporting
|
||||
- Performance monitoring
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Local Telemetry
|
||||
|
||||
```typescript
|
||||
import { createAgent, Telemetry } from '@hanzo/ai';
|
||||
|
||||
// Create a basic telemetry instance
|
||||
const telemetry = new Telemetry({
|
||||
serviceName: 'my-ai-service',
|
||||
enabled: true
|
||||
});
|
||||
|
||||
// Use with agents
|
||||
const agent = createAgent({
|
||||
name: 'my-agent',
|
||||
// ... other config
|
||||
});
|
||||
|
||||
const result = await agent.run({
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
context: { telemetry }
|
||||
});
|
||||
```
|
||||
|
||||
### Hanzo Cloud Integration
|
||||
|
||||
```typescript
|
||||
import { createHanzoCloudTelemetry } from '@hanzo/ai/telemetry/hanzo-cloud';
|
||||
|
||||
// Initialize Hanzo Cloud telemetry
|
||||
const telemetry = createHanzoCloudTelemetry({
|
||||
cloudUrl: 'https://cloud.hanzo.ai',
|
||||
apiKey: process.env.HANZO_CLOUD_API_KEY!,
|
||||
projectId: process.env.HANZO_PROJECT_ID!,
|
||||
environment: 'production',
|
||||
serviceName: 'customer-support-ai',
|
||||
serviceVersion: '1.0.0'
|
||||
});
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Distributed Tracing
|
||||
|
||||
Track execution flow across agents and networks:
|
||||
|
||||
```typescript
|
||||
// Trace a custom operation
|
||||
const result = await telemetry.trace(
|
||||
'process_order',
|
||||
async (span) => {
|
||||
span.setAttributes({
|
||||
'order.id': orderId,
|
||||
'order.amount': amount
|
||||
});
|
||||
|
||||
// Your logic here
|
||||
return processOrder(orderId);
|
||||
},
|
||||
{
|
||||
kind: SpanKind.CLIENT,
|
||||
attributes: {
|
||||
'service.operation': 'order_processing'
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Structured Logging
|
||||
|
||||
Log with automatic trace context:
|
||||
|
||||
```typescript
|
||||
telemetry.log('info', 'Processing customer request', {
|
||||
customerId: '12345',
|
||||
requestType: 'support'
|
||||
});
|
||||
|
||||
telemetry.log('error', 'Failed to process request', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
```
|
||||
|
||||
### Metrics Collection
|
||||
|
||||
Track key performance indicators:
|
||||
|
||||
```typescript
|
||||
// Counters
|
||||
telemetry.increment('api.requests', 1, {
|
||||
endpoint: '/chat',
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
// Gauges
|
||||
telemetry.gauge('queue.size', queueLength, {
|
||||
queue: 'processing'
|
||||
});
|
||||
|
||||
// Histograms
|
||||
telemetry.histogram('response.time', responseTime, {
|
||||
endpoint: '/chat'
|
||||
});
|
||||
```
|
||||
|
||||
### Agent & Network Metrics
|
||||
|
||||
Automatic metrics for agent executions:
|
||||
|
||||
- `agent.executions` - Count of agent runs
|
||||
- `agent.execution.duration` - Agent execution time
|
||||
- `agent.tokens.used` - Token usage per agent
|
||||
- `tool.executions` - Tool usage statistics
|
||||
- `network.iterations` - Network iteration count
|
||||
- `network.execution.duration` - Total network execution time
|
||||
|
||||
### Session Tracking
|
||||
|
||||
Track related executions:
|
||||
|
||||
```typescript
|
||||
// Create a session
|
||||
const sessionId = telemetry.createSession();
|
||||
|
||||
// All subsequent operations will be linked to this session
|
||||
const result = await network.run({
|
||||
messages: [...],
|
||||
telemetry
|
||||
});
|
||||
```
|
||||
|
||||
### Error Tracking
|
||||
|
||||
Automatic error capture with context:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await agent.run({ messages, telemetry });
|
||||
} catch (error) {
|
||||
// Errors are automatically recorded with span context
|
||||
telemetry.recordException(error);
|
||||
}
|
||||
```
|
||||
|
||||
## Network Telemetry
|
||||
|
||||
Networks provide additional telemetry:
|
||||
|
||||
```typescript
|
||||
const network = createNetwork({
|
||||
name: 'support_network',
|
||||
agents: [classifier, techSupport, billing],
|
||||
// ...
|
||||
});
|
||||
|
||||
// Automatic tracking of:
|
||||
// - Router decisions
|
||||
// - Agent handoffs
|
||||
// - State changes
|
||||
// - Iteration count
|
||||
// - Total execution time
|
||||
|
||||
const result = await network.run({
|
||||
messages: [...],
|
||||
telemetry
|
||||
});
|
||||
```
|
||||
|
||||
## MCP Server Telemetry
|
||||
|
||||
Track MCP server connections:
|
||||
|
||||
```typescript
|
||||
// Automatic tracking when MCP servers are connected
|
||||
const agent = createAgent({
|
||||
name: 'mcp-agent',
|
||||
mcpServers: [{
|
||||
name: 'file-system',
|
||||
transport: { type: 'stdio', command: 'mcp-fs' }
|
||||
}]
|
||||
});
|
||||
|
||||
// Metrics tracked:
|
||||
// - mcp.connections
|
||||
// - mcp.servers.active
|
||||
// - mcp.tool.calls
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Structured Attributes
|
||||
|
||||
```typescript
|
||||
span.setAttributes({
|
||||
'user.id': userId,
|
||||
'user.tier': 'premium',
|
||||
'request.type': 'chat',
|
||||
'request.model': 'gpt-4'
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Create Meaningful Spans
|
||||
|
||||
```typescript
|
||||
await telemetry.trace('user_request', async () => {
|
||||
await telemetry.trace('validate_input', validateInput);
|
||||
await telemetry.trace('process_with_ai', processAI);
|
||||
await telemetry.trace('format_response', formatResponse);
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Track Business Metrics
|
||||
|
||||
```typescript
|
||||
// Track business-relevant metrics
|
||||
telemetry.increment('revenue.processed', amount, {
|
||||
currency: 'USD',
|
||||
paymentMethod: 'stripe'
|
||||
});
|
||||
|
||||
telemetry.gauge('customer.satisfaction', score, {
|
||||
surveyType: 'nps'
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Use Session Context
|
||||
|
||||
```typescript
|
||||
// Link related operations
|
||||
const sessionId = telemetry.createSession();
|
||||
|
||||
for (const message of conversation) {
|
||||
await handleMessage(message, telemetry);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Proper Cleanup
|
||||
|
||||
```typescript
|
||||
// Always flush telemetry before shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
await telemetry.shutdown();
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
## Viewing Telemetry Data
|
||||
|
||||
### Hanzo Cloud Console
|
||||
|
||||
Access your telemetry data at:
|
||||
- Traces: `https://cloud.hanzo.ai/projects/{projectId}/traces`
|
||||
- Metrics: `https://cloud.hanzo.ai/projects/{projectId}/metrics`
|
||||
- Logs: `https://cloud.hanzo.ai/projects/{projectId}/logs`
|
||||
|
||||
### Local Development
|
||||
|
||||
In development, telemetry outputs to console with structured JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-15T10:30:45.123Z",
|
||||
"level": "info",
|
||||
"message": "Agent executed",
|
||||
"trace_id": "abc123",
|
||||
"span_id": "def456",
|
||||
"agent": "classifier",
|
||||
"duration": 245,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Impact
|
||||
|
||||
The telemetry system is designed for minimal overhead:
|
||||
- Async span processing
|
||||
- Batched metric collection
|
||||
- Configurable sampling
|
||||
- Automatic span pruning
|
||||
- Efficient memory usage
|
||||
|
||||
Disable in performance-critical paths:
|
||||
|
||||
```typescript
|
||||
const telemetry = new Telemetry({
|
||||
enabled: process.env.NODE_ENV === 'production'
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing Traces
|
||||
|
||||
Check:
|
||||
1. Telemetry is enabled
|
||||
2. API key is valid
|
||||
3. Network connectivity to Hanzo Cloud
|
||||
4. Proper span nesting
|
||||
|
||||
### High Memory Usage
|
||||
|
||||
- Reduce span attributes
|
||||
- Enable sampling
|
||||
- Decrease batch size
|
||||
- Check for span leaks
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging:
|
||||
|
||||
```typescript
|
||||
const telemetry = createHanzoCloudTelemetry({
|
||||
// ...
|
||||
logLevel: 'debug'
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Custom Span Processors
|
||||
|
||||
```typescript
|
||||
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
||||
|
||||
// Add custom processing
|
||||
const processor = new BatchSpanProcessor(customExporter);
|
||||
telemetry.addSpanProcessor(processor);
|
||||
```
|
||||
|
||||
### Context Propagation
|
||||
|
||||
```typescript
|
||||
// Get trace context for external services
|
||||
const headers = telemetry.getTraceContext();
|
||||
|
||||
// Make external request with trace context
|
||||
await fetch(url, {
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Multi-Region Support
|
||||
|
||||
```typescript
|
||||
const telemetry = createHanzoCloudTelemetry({
|
||||
cloudUrl: process.env.HANZO_CLOUD_URL || 'https://cloud.hanzo.ai',
|
||||
// Auto-detected from environment
|
||||
region: process.env.HANZO_CLOUD_REGION
|
||||
});
|
||||
```
|
||||
@@ -1,263 +0,0 @@
|
||||
/**
|
||||
* Example: Using Hanzo AI with Cloud Telemetry
|
||||
*
|
||||
* This example shows how to integrate agents and networks with Hanzo Cloud's
|
||||
* observability platform for comprehensive monitoring and debugging.
|
||||
*/
|
||||
|
||||
import { createAgent, createNetwork } from '@hanzo/ai';
|
||||
import { createHanzoCloudTelemetry } from '@hanzo/ai/telemetry/hanzo-cloud';
|
||||
import { commonTools } from '@hanzo/ai/tools';
|
||||
import { OpenAIProvider } from '@hanzo/ai/providers/openai';
|
||||
|
||||
// Initialize Hanzo Cloud telemetry
|
||||
const telemetry = createHanzoCloudTelemetry({
|
||||
cloudUrl: process.env.HANZO_CLOUD_URL || 'https://cloud.hanzo.ai',
|
||||
apiKey: process.env.HANZO_CLOUD_API_KEY!,
|
||||
projectId: process.env.HANZO_PROJECT_ID!,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
serviceName: 'customer-support-ai',
|
||||
serviceVersion: '1.0.0',
|
||||
logLevel: 'info'
|
||||
});
|
||||
|
||||
// Create a session for tracking related executions
|
||||
const sessionId = telemetry.createSession();
|
||||
console.log(`Started telemetry session: ${sessionId}`);
|
||||
|
||||
// Create agents with telemetry integration
|
||||
const classifierAgent = createAgent({
|
||||
name: 'classifier',
|
||||
description: 'Classifies customer inquiries',
|
||||
system: `You are a customer inquiry classifier. Analyze the customer's message and classify it into one of these categories:
|
||||
- technical_support
|
||||
- billing
|
||||
- product_info
|
||||
- complaint
|
||||
- other`,
|
||||
tools: [
|
||||
commonTools.done(),
|
||||
commonTools.handoff()
|
||||
]
|
||||
});
|
||||
|
||||
const techSupportAgent = createAgent({
|
||||
name: 'tech_support',
|
||||
description: 'Handles technical support issues',
|
||||
system: 'You are a technical support specialist. Help customers resolve technical issues with our products.',
|
||||
tools: [
|
||||
commonTools.done(),
|
||||
commonTools.askUser()
|
||||
]
|
||||
});
|
||||
|
||||
const billingAgent = createAgent({
|
||||
name: 'billing',
|
||||
description: 'Handles billing inquiries',
|
||||
system: 'You are a billing specialist. Help customers with payment, subscription, and invoice questions.',
|
||||
tools: [
|
||||
commonTools.done(),
|
||||
commonTools.remember(),
|
||||
commonTools.recall()
|
||||
]
|
||||
});
|
||||
|
||||
// Create network with telemetry
|
||||
const supportNetwork = createNetwork({
|
||||
name: 'customer_support',
|
||||
agents: [classifierAgent, techSupportAgent, billingAgent],
|
||||
defaultModel: new OpenAIProvider({
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: 'gpt-4'
|
||||
}),
|
||||
router: (context) => {
|
||||
// First iteration: always start with classifier
|
||||
if (context.iteration === 0) {
|
||||
return context.network.getAgent('classifier');
|
||||
}
|
||||
|
||||
// Check if classifier has determined the category
|
||||
const category = context.state.get('category');
|
||||
const nextAgent = context.state.get('nextAgent');
|
||||
|
||||
if (nextAgent) {
|
||||
context.state.delete('nextAgent'); // Clear for next iteration
|
||||
return context.network.getAgent(nextAgent);
|
||||
}
|
||||
|
||||
if (category === 'technical_support') {
|
||||
return context.network.getAgent('tech_support');
|
||||
} else if (category === 'billing') {
|
||||
return context.network.getAgent('billing');
|
||||
}
|
||||
|
||||
return undefined; // No more agents to run
|
||||
}
|
||||
});
|
||||
|
||||
// Example: Process customer inquiry with full telemetry
|
||||
async function handleCustomerInquiry(message: string) {
|
||||
// Create a span for the entire operation
|
||||
return telemetry.trace(
|
||||
'customer_inquiry',
|
||||
async (span) => {
|
||||
// Add customer context
|
||||
span.setAttributes({
|
||||
'customer.message.length': message.length,
|
||||
'customer.session.id': sessionId
|
||||
});
|
||||
|
||||
try {
|
||||
// Log the inquiry
|
||||
telemetry.log('info', 'Processing customer inquiry', {
|
||||
messagePreview: message.substring(0, 100)
|
||||
});
|
||||
|
||||
// Run the support network
|
||||
const result = await supportNetwork.run({
|
||||
messages: [
|
||||
{ role: 'user', content: message }
|
||||
],
|
||||
telemetry // Pass telemetry instance
|
||||
});
|
||||
|
||||
// Record success metrics
|
||||
telemetry.increment('customer.inquiries.processed', 1, {
|
||||
status: 'success',
|
||||
category: result.state.category || 'unknown'
|
||||
});
|
||||
|
||||
// Log the resolution
|
||||
telemetry.log('info', 'Customer inquiry resolved', {
|
||||
iterations: result.iterations,
|
||||
finalAgent: result.history[result.history.length - 1]?.agent,
|
||||
category: result.state.category
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Record failure metrics
|
||||
telemetry.increment('customer.inquiries.processed', 1, {
|
||||
status: 'error'
|
||||
});
|
||||
|
||||
telemetry.log('error', 'Failed to process customer inquiry', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
attributes: {
|
||||
'inquiry.type': 'customer_support'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Example: Monitor streaming responses
|
||||
async function handleStreamingInquiry(message: string) {
|
||||
const stream = supportNetwork.stream({
|
||||
messages: [
|
||||
{ role: 'user', content: message }
|
||||
],
|
||||
telemetry
|
||||
});
|
||||
|
||||
let totalTokens = 0;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// Track streaming metrics
|
||||
if (chunk.type === 'content') {
|
||||
totalTokens += chunk.content.length;
|
||||
telemetry.gauge('streaming.tokens.current', totalTokens, {
|
||||
agent: chunk.agent
|
||||
});
|
||||
} else if (chunk.type === 'agent:start') {
|
||||
telemetry.log('debug', `Agent ${chunk.agent} started at iteration ${chunk.iteration}`);
|
||||
} else if (chunk.type === 'agent:complete') {
|
||||
telemetry.histogram('agent.streaming.duration', chunk.duration, {
|
||||
agent: chunk.agent
|
||||
});
|
||||
}
|
||||
|
||||
// Process chunk...
|
||||
console.log(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
// Example: Batch processing with telemetry
|
||||
async function processBatchInquiries(inquiries: string[]) {
|
||||
telemetry.log('info', `Starting batch processing of ${inquiries.length} inquiries`);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
inquiries.map((inquiry, index) =>
|
||||
telemetry.trace(
|
||||
`batch_inquiry_${index}`,
|
||||
() => handleCustomerInquiry(inquiry),
|
||||
{
|
||||
attributes: {
|
||||
'batch.index': index,
|
||||
'batch.total': inquiries.length
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Analyze results
|
||||
const successful = results.filter(r => r.status === 'fulfilled').length;
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
|
||||
telemetry.gauge('batch.success.rate', successful / inquiries.length, {
|
||||
batchSize: inquiries.length
|
||||
});
|
||||
|
||||
telemetry.log('info', 'Batch processing complete', {
|
||||
total: inquiries.length,
|
||||
successful,
|
||||
failed
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Example usage
|
||||
async function main() {
|
||||
try {
|
||||
// Single inquiry
|
||||
const result = await handleCustomerInquiry(
|
||||
"I'm having trouble logging into my account. It says my password is incorrect but I'm sure it's right."
|
||||
);
|
||||
|
||||
console.log('Result:', result);
|
||||
|
||||
// Streaming inquiry
|
||||
await handleStreamingInquiry(
|
||||
"My last bill seems higher than usual. Can you explain the charges?"
|
||||
);
|
||||
|
||||
// Batch processing
|
||||
const batchResults = await processBatchInquiries([
|
||||
"How do I reset my password?",
|
||||
"What are your business hours?",
|
||||
"I want to cancel my subscription",
|
||||
"The app keeps crashing on startup"
|
||||
]);
|
||||
|
||||
console.log(`Processed ${batchResults.length} inquiries`);
|
||||
|
||||
} finally {
|
||||
// Ensure telemetry is flushed before exit
|
||||
await telemetry.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the example
|
||||
if (require.main === module) {
|
||||
main().catch(console.error);
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
export { handleCustomerInquiry, supportNetwork, telemetry };
|
||||
Generated
-5062
File diff suppressed because it is too large
Load Diff
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"name": "@hanzo/ai",
|
||||
"version": "0.1.1",
|
||||
"description": "The AI Toolkit for TypeScript - AgentKit with MCP support",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.mjs",
|
||||
"require": "./dist/server/index.js"
|
||||
},
|
||||
"./react": {
|
||||
"types": "./dist/react/index.d.ts",
|
||||
"import": "./dist/react/index.mjs",
|
||||
"require": "./dist/react/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"test": "vitest",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"zod": "^3.22.0",
|
||||
"eventsource-parser": "^1.0.0",
|
||||
"nanoid": "^5.0.0",
|
||||
"@opentelemetry/api": "^1.7.0",
|
||||
"@opentelemetry/resources": "^1.19.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.19.0",
|
||||
"@opentelemetry/instrumentation": "^0.46.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.19.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.19.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.46.0",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"zod": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vitest": "^1.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"llm",
|
||||
"agents",
|
||||
"mcp",
|
||||
"typescript",
|
||||
"hanzo"
|
||||
],
|
||||
"author": "Hanzo AI",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/ai.git"
|
||||
}
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
/**
|
||||
* Agent implementation with MCP support
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { Tool } from './tool';
|
||||
import { MCPServer } from '../mcp/types';
|
||||
import { ModelInterface } from '../types';
|
||||
import { Telemetry, SpanKind } from '../telemetry';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export interface AgentConfig {
|
||||
name: string;
|
||||
description?: string;
|
||||
system?: string;
|
||||
tools?: Tool[];
|
||||
mcpServers?: MCPServer[];
|
||||
model?: ModelInterface;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AgentContext {
|
||||
agent: Agent;
|
||||
network?: any; // Will be Network type
|
||||
state?: any;
|
||||
telemetry: Telemetry;
|
||||
}
|
||||
|
||||
export interface AgentRunOptions {
|
||||
messages: any[];
|
||||
model?: ModelInterface;
|
||||
context?: Partial<AgentContext>;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export class Agent {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly system?: string;
|
||||
readonly tools: Map<string, Tool>;
|
||||
readonly mcpServers: MCPServer[];
|
||||
readonly model?: ModelInterface;
|
||||
readonly temperature?: number;
|
||||
readonly maxTokens?: number;
|
||||
readonly metadata: Record<string, any>;
|
||||
|
||||
private mcpTools: Map<string, any> = new Map();
|
||||
private initialized = false;
|
||||
|
||||
constructor(config: AgentConfig) {
|
||||
this.id = nanoid();
|
||||
this.name = config.name;
|
||||
this.description = config.description;
|
||||
this.system = config.system;
|
||||
this.tools = new Map();
|
||||
this.mcpServers = config.mcpServers || [];
|
||||
this.model = config.model;
|
||||
this.temperature = config.temperature;
|
||||
this.maxTokens = config.maxTokens;
|
||||
this.metadata = config.metadata || {};
|
||||
|
||||
// Register tools
|
||||
if (config.tools) {
|
||||
for (const tool of config.tools) {
|
||||
this.tools.set(tool.name, tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Initialize MCP servers
|
||||
for (const server of this.mcpServers) {
|
||||
await this.connectMCPServer(server);
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
private async connectMCPServer(server: MCPServer): Promise<void> {
|
||||
// Import MCP client dynamically
|
||||
const { MCPClient } = await import('../mcp/client');
|
||||
const client = new MCPClient();
|
||||
|
||||
await client.connect({
|
||||
name: server.name,
|
||||
transport: server.transport
|
||||
});
|
||||
|
||||
// Get available tools from MCP server
|
||||
const tools = await client.listTools();
|
||||
|
||||
// Register MCP tools
|
||||
for (const tool of tools) {
|
||||
this.mcpTools.set(`${server.name}:${tool.name}`, {
|
||||
server,
|
||||
client,
|
||||
tool
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async run(options: AgentRunOptions): Promise<any> {
|
||||
await this.initialize();
|
||||
|
||||
const model = options.model || this.model;
|
||||
if (!model) {
|
||||
throw new Error(`No model specified for agent ${this.name}`);
|
||||
}
|
||||
|
||||
// Build context
|
||||
const context: AgentContext = {
|
||||
agent: this,
|
||||
network: options.context?.network,
|
||||
state: options.context?.state,
|
||||
telemetry: options.context?.telemetry || new Telemetry()
|
||||
};
|
||||
|
||||
// Prepare tools for the model
|
||||
const availableTools = this.getAllTools();
|
||||
|
||||
// Add system message if specified
|
||||
const messages = [...options.messages];
|
||||
if (this.system) {
|
||||
messages.unshift({
|
||||
role: 'system',
|
||||
content: this.system
|
||||
});
|
||||
}
|
||||
|
||||
// Execute with telemetry
|
||||
return context.telemetry.trace(
|
||||
`agent.${this.name}`,
|
||||
async (span) => {
|
||||
// Add agent metadata to span
|
||||
span.setAttributes({
|
||||
'agent.name': this.name,
|
||||
'agent.id': this.id,
|
||||
'agent.model': model.name || 'unknown',
|
||||
'agent.tools.count': this.getAllTools().size,
|
||||
'agent.mcp.servers': this.mcpServers.length,
|
||||
'agent.messages.count': messages.length
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (options.stream) {
|
||||
result = await this.runStream({ ...options, messages, model, context });
|
||||
} else {
|
||||
result = await this.runComplete({ ...options, messages, model, context });
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Record metrics
|
||||
if (context.telemetry && 'recordAgentExecution' in context.telemetry) {
|
||||
(context.telemetry as any).recordAgentExecution(
|
||||
this.name,
|
||||
duration,
|
||||
true,
|
||||
{
|
||||
model: model.name,
|
||||
messageCount: messages.length,
|
||||
toolsUsed: result.toolCalls?.length || 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Record failed execution
|
||||
if (context.telemetry && 'recordAgentExecution' in context.telemetry) {
|
||||
(context.telemetry as any).recordAgentExecution(
|
||||
this.name,
|
||||
duration,
|
||||
false,
|
||||
{
|
||||
model: model.name,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: SpanKind.CLIENT,
|
||||
attributes: {
|
||||
'agent.type': 'llm'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async runComplete(options: any): Promise<any> {
|
||||
const { messages, model, context } = options;
|
||||
|
||||
const response = await model.complete({
|
||||
messages,
|
||||
tools: Array.from(this.getAllTools().values()),
|
||||
temperature: this.temperature,
|
||||
maxTokens: this.maxTokens
|
||||
});
|
||||
|
||||
// Handle tool calls
|
||||
if (response.toolCalls && response.toolCalls.length > 0) {
|
||||
const toolResults = await this.executeToolCalls(response.toolCalls, context);
|
||||
|
||||
// Add tool results to messages
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: response.content,
|
||||
toolCalls: response.toolCalls
|
||||
});
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
toolResults
|
||||
});
|
||||
|
||||
// Recursively call for the next response
|
||||
return this.runComplete({ ...options, messages });
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async *runStream(options: any): AsyncIterableIterator<any> {
|
||||
const { messages, model, context } = options;
|
||||
|
||||
const stream = await model.stream({
|
||||
messages,
|
||||
tools: Array.from(this.getAllTools().values()),
|
||||
temperature: this.temperature,
|
||||
maxTokens: this.maxTokens
|
||||
});
|
||||
|
||||
// Handle streaming with tool calls
|
||||
return this.handleStreamWithTools(stream, messages, context, options);
|
||||
}
|
||||
|
||||
private async *handleStreamWithTools(
|
||||
stream: AsyncIterableIterator<any>,
|
||||
messages: any[],
|
||||
context: AgentContext,
|
||||
options: any
|
||||
): AsyncIterableIterator<any> {
|
||||
let content = '';
|
||||
const toolCalls: any[] = [];
|
||||
|
||||
for await (const chunk of stream) {
|
||||
yield chunk;
|
||||
|
||||
if (chunk.type === 'content') {
|
||||
content += chunk.content;
|
||||
} else if (chunk.type === 'tool_call') {
|
||||
toolCalls.push(chunk.toolCall);
|
||||
} else if (chunk.type === 'done' && toolCalls.length > 0) {
|
||||
// Execute tool calls
|
||||
const toolResults = await this.executeToolCalls(toolCalls, context);
|
||||
|
||||
// Add to messages
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content,
|
||||
toolCalls
|
||||
});
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
toolResults
|
||||
});
|
||||
|
||||
// Continue with next iteration
|
||||
const nextStream = await this.runStream({ ...options, messages });
|
||||
for await (const nextChunk of nextStream) {
|
||||
yield nextChunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeToolCalls(toolCalls: any[], context: AgentContext): Promise<any[]> {
|
||||
const results = [];
|
||||
|
||||
for (const call of toolCalls) {
|
||||
try {
|
||||
const result = await this.executeTool(call.name, call.arguments, context);
|
||||
results.push({
|
||||
id: call.id,
|
||||
result
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
id: call.id,
|
||||
error: String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async executeTool(name: string, args: any, context: AgentContext): Promise<any> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
let result;
|
||||
let toolType: 'local' | 'mcp' = 'local';
|
||||
|
||||
// Check local tools first
|
||||
if (this.tools.has(name)) {
|
||||
const tool = this.tools.get(name)!;
|
||||
result = await tool.handler(args, context);
|
||||
} else {
|
||||
// Check MCP tools
|
||||
let found = false;
|
||||
for (const [key, mcpTool] of this.mcpTools) {
|
||||
const [serverName, toolName] = key.split(':');
|
||||
if (toolName === name || key === name) {
|
||||
toolType = 'mcp';
|
||||
result = await mcpTool.client.callTool({
|
||||
name: toolName,
|
||||
arguments: args
|
||||
});
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
throw new Error(`Tool '${name}' not found`);
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Record tool usage metric
|
||||
if (context.telemetry && 'recordToolUsage' in context.telemetry) {
|
||||
(context.telemetry as any).recordToolUsage(
|
||||
name,
|
||||
this.name,
|
||||
duration,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// Add telemetry event
|
||||
context.telemetry.recordEvent({
|
||||
name: 'tool.executed',
|
||||
attributes: {
|
||||
'tool.name': name,
|
||||
'tool.type': toolType,
|
||||
'tool.duration': duration,
|
||||
'agent.name': this.name
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Record failed tool execution
|
||||
if (context.telemetry && 'recordToolUsage' in context.telemetry) {
|
||||
(context.telemetry as any).recordToolUsage(
|
||||
name,
|
||||
this.name,
|
||||
duration,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private getAllTools(): Map<string, any> {
|
||||
const allTools = new Map();
|
||||
|
||||
// Add local tools
|
||||
for (const [name, tool] of this.tools) {
|
||||
allTools.set(name, {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters
|
||||
});
|
||||
}
|
||||
|
||||
// Add MCP tools
|
||||
for (const [key, mcpTool] of this.mcpTools) {
|
||||
const [serverName, toolName] = key.split(':');
|
||||
allTools.set(toolName, {
|
||||
name: toolName,
|
||||
description: mcpTool.tool.description,
|
||||
parameters: mcpTool.tool.inputSchema
|
||||
});
|
||||
}
|
||||
|
||||
return allTools;
|
||||
}
|
||||
|
||||
clone(overrides?: Partial<AgentConfig>): Agent {
|
||||
return new Agent({
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
system: this.system,
|
||||
tools: Array.from(this.tools.values()),
|
||||
mcpServers: this.mcpServers,
|
||||
model: this.model,
|
||||
temperature: this.temperature,
|
||||
maxTokens: this.maxTokens,
|
||||
metadata: this.metadata,
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createAgent(config: AgentConfig): Agent {
|
||||
return new Agent(config);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export async function embed(params: any): Promise<any> { return { embeddings: [] }; }
|
||||
@@ -1 +0,0 @@
|
||||
export async function generateObject(params: any): Promise<any> { return {}; }
|
||||
@@ -1 +0,0 @@
|
||||
export async function* generateStream(params: any): AsyncIterableIterator<any> { yield { type: 'done' }; }
|
||||
@@ -1 +0,0 @@
|
||||
export async function generateText(params: any): Promise<any> { return { text: '' }; }
|
||||
@@ -1,418 +0,0 @@
|
||||
/**
|
||||
* Network implementation for agent collaboration
|
||||
*/
|
||||
|
||||
import { Agent } from './agent';
|
||||
import { State } from './state';
|
||||
import { Router } from './router';
|
||||
import { ModelInterface } from '../types';
|
||||
import { Telemetry, SpanKind } from '../telemetry';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export interface NetworkConfig {
|
||||
name: string;
|
||||
agents: Agent[];
|
||||
defaultModel?: ModelInterface;
|
||||
router?: Router | ((context: RouterContext) => Agent | undefined);
|
||||
state?: State;
|
||||
maxIterations?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface RouterContext {
|
||||
network: Network;
|
||||
state: State;
|
||||
messages: any[];
|
||||
iteration: number;
|
||||
history: ExecutionHistory[];
|
||||
}
|
||||
|
||||
export interface ExecutionHistory {
|
||||
agent: string;
|
||||
input: any;
|
||||
output: any;
|
||||
timestamp: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface NetworkRunOptions {
|
||||
messages: any[];
|
||||
stream?: boolean;
|
||||
onIteration?: (context: RouterContext) => void;
|
||||
telemetry?: Telemetry;
|
||||
}
|
||||
|
||||
export class Network extends EventEmitter {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly agents: Map<string, Agent>;
|
||||
readonly defaultModel?: ModelInterface;
|
||||
readonly router: Router;
|
||||
readonly state: State;
|
||||
readonly maxIterations: number;
|
||||
readonly metadata: Record<string, any>;
|
||||
|
||||
private initialized = false;
|
||||
|
||||
constructor(config: NetworkConfig) {
|
||||
super();
|
||||
this.id = nanoid();
|
||||
this.name = config.name;
|
||||
this.agents = new Map();
|
||||
this.defaultModel = config.defaultModel;
|
||||
this.state = config.state || new State();
|
||||
this.maxIterations = config.maxIterations || 10;
|
||||
this.metadata = config.metadata || {};
|
||||
|
||||
// Register agents
|
||||
for (const agent of config.agents) {
|
||||
this.agents.set(agent.name, agent);
|
||||
}
|
||||
|
||||
// Setup router
|
||||
if (typeof config.router === 'function') {
|
||||
this.router = new Router({ handler: config.router });
|
||||
} else if (config.router) {
|
||||
this.router = config.router;
|
||||
} else {
|
||||
// Default router - just use first agent
|
||||
this.router = new Router({
|
||||
handler: () => config.agents[0]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Initialize all agents
|
||||
const initPromises = Array.from(this.agents.values()).map(agent =>
|
||||
agent.initialize()
|
||||
);
|
||||
|
||||
await Promise.all(initPromises);
|
||||
|
||||
this.initialized = true;
|
||||
this.emit('initialized');
|
||||
}
|
||||
|
||||
async run(options: NetworkRunOptions): Promise<any> {
|
||||
await this.initialize();
|
||||
|
||||
const telemetry = options.telemetry || new Telemetry();
|
||||
const history: ExecutionHistory[] = [];
|
||||
let messages = [...options.messages];
|
||||
|
||||
return telemetry.trace(
|
||||
`network.${this.name}`,
|
||||
async (span) => {
|
||||
const networkStartTime = Date.now();
|
||||
const agentExecutions = new Map<string, number>();
|
||||
|
||||
// Add network metadata to span
|
||||
span.setAttributes({
|
||||
'network.name': this.name,
|
||||
'network.id': this.id,
|
||||
'network.agents.count': this.agents.size,
|
||||
'network.maxIterations': this.maxIterations,
|
||||
'network.messages.initial': messages.length
|
||||
});
|
||||
|
||||
try {
|
||||
for (let iteration = 0; iteration < this.maxIterations; iteration++) {
|
||||
// Build router context
|
||||
const context: RouterContext = {
|
||||
network: this,
|
||||
state: this.state,
|
||||
messages,
|
||||
iteration,
|
||||
history
|
||||
};
|
||||
|
||||
// Call iteration callback if provided
|
||||
if (options.onIteration) {
|
||||
options.onIteration(context);
|
||||
}
|
||||
|
||||
// Get next agent from router with telemetry
|
||||
const nextAgent = await telemetry.trace(
|
||||
`router.${this.name}`,
|
||||
async () => this.router.route(context),
|
||||
{
|
||||
kind: SpanKind.INTERNAL,
|
||||
attributes: { 'router.iteration': iteration }
|
||||
}
|
||||
);
|
||||
|
||||
if (!nextAgent) {
|
||||
// No more agents to run
|
||||
this.emit('complete', { history, state: this.state });
|
||||
telemetry.recordEvent({
|
||||
name: 'network.complete',
|
||||
attributes: {
|
||||
'network.name': this.name,
|
||||
'network.iterations': iteration,
|
||||
'network.reason': 'no_next_agent'
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Track agent executions
|
||||
agentExecutions.set(
|
||||
nextAgent.name,
|
||||
(agentExecutions.get(nextAgent.name) || 0) + 1
|
||||
);
|
||||
|
||||
this.emit('agent:start', { agent: nextAgent.name, iteration });
|
||||
|
||||
// Run agent
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const agentContext = {
|
||||
network: this,
|
||||
state: this.state,
|
||||
telemetry
|
||||
};
|
||||
|
||||
const result = await nextAgent.run({
|
||||
messages,
|
||||
model: this.defaultModel,
|
||||
context: agentContext,
|
||||
stream: options.stream
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Add to history
|
||||
const historyEntry: ExecutionHistory = {
|
||||
agent: nextAgent.name,
|
||||
input: messages[messages.length - 1],
|
||||
output: result,
|
||||
timestamp: Date.now(),
|
||||
duration
|
||||
};
|
||||
|
||||
history.push(historyEntry);
|
||||
|
||||
// Update messages
|
||||
if (result.content) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: result.content,
|
||||
metadata: {
|
||||
agent: nextAgent.name
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.emit('agent:complete', {
|
||||
agent: nextAgent.name,
|
||||
iteration,
|
||||
result,
|
||||
duration
|
||||
});
|
||||
|
||||
// Check for completion
|
||||
if (this.state.kv.get('complete') === true) {
|
||||
this.emit('complete', { history, state: this.state });
|
||||
telemetry.recordEvent({
|
||||
name: 'network.complete',
|
||||
attributes: {
|
||||
'network.name': this.name,
|
||||
'network.iterations': iteration + 1,
|
||||
'network.reason': 'state_complete'
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.emit('agent:error', {
|
||||
agent: nextAgent.name,
|
||||
iteration,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const networkDuration = Date.now() - networkStartTime;
|
||||
|
||||
// Record network execution metrics
|
||||
if (telemetry && 'recordNetworkExecution' in telemetry) {
|
||||
(telemetry as any).recordNetworkExecution(
|
||||
this.name,
|
||||
history.length,
|
||||
networkDuration,
|
||||
agentExecutions
|
||||
);
|
||||
}
|
||||
|
||||
// Return final result
|
||||
return {
|
||||
messages,
|
||||
history,
|
||||
state: this.state.toJSON(),
|
||||
iterations: history.length
|
||||
};
|
||||
} catch (error) {
|
||||
const networkDuration = Date.now() - networkStartTime;
|
||||
|
||||
// Record failed network execution
|
||||
telemetry.recordEvent({
|
||||
name: 'network.error',
|
||||
attributes: {
|
||||
'network.name': this.name,
|
||||
'network.duration': networkDuration,
|
||||
'network.iterations': history.length,
|
||||
'error.message': error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: SpanKind.SERVER,
|
||||
attributes: {
|
||||
'network.type': 'agent_network'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async *stream(options: NetworkRunOptions): AsyncIterableIterator<any> {
|
||||
await this.initialize();
|
||||
|
||||
const telemetry = options.telemetry || new Telemetry();
|
||||
const history: ExecutionHistory[] = [];
|
||||
let messages = [...options.messages];
|
||||
|
||||
for (let iteration = 0; iteration < this.maxIterations; iteration++) {
|
||||
// Build router context
|
||||
const context: RouterContext = {
|
||||
network: this,
|
||||
state: this.state,
|
||||
messages,
|
||||
iteration,
|
||||
history
|
||||
};
|
||||
|
||||
// Get next agent from router
|
||||
const nextAgent = await this.router.route(context);
|
||||
|
||||
if (!nextAgent) {
|
||||
// No more agents to run
|
||||
yield { type: 'complete', history, state: this.state.toJSON() };
|
||||
break;
|
||||
}
|
||||
|
||||
yield { type: 'agent:start', agent: nextAgent.name, iteration };
|
||||
|
||||
// Run agent
|
||||
const startTime = Date.now();
|
||||
const agentContext = {
|
||||
network: this,
|
||||
state: this.state,
|
||||
telemetry
|
||||
};
|
||||
|
||||
// Stream from agent
|
||||
const stream = await nextAgent.run({
|
||||
messages,
|
||||
model: this.defaultModel,
|
||||
context: agentContext,
|
||||
stream: true
|
||||
});
|
||||
|
||||
let content = '';
|
||||
|
||||
for await (const chunk of stream) {
|
||||
yield { ...chunk, agent: nextAgent.name };
|
||||
|
||||
if (chunk.type === 'content') {
|
||||
content += chunk.content;
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Add to history
|
||||
const historyEntry: ExecutionHistory = {
|
||||
agent: nextAgent.name,
|
||||
input: messages[messages.length - 1],
|
||||
output: { content },
|
||||
timestamp: Date.now(),
|
||||
duration
|
||||
};
|
||||
|
||||
history.push(historyEntry);
|
||||
|
||||
// Update messages
|
||||
if (content) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content,
|
||||
metadata: {
|
||||
agent: nextAgent.name
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'agent:complete',
|
||||
agent: nextAgent.name,
|
||||
iteration,
|
||||
duration
|
||||
};
|
||||
|
||||
// Check for completion
|
||||
if (this.state.kv.get('complete') === true) {
|
||||
yield { type: 'complete', history, state: this.state.toJSON() };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAgent(name: string): Agent | undefined {
|
||||
return this.agents.get(name);
|
||||
}
|
||||
|
||||
addAgent(agent: Agent): void {
|
||||
this.agents.set(agent.name, agent);
|
||||
this.emit('agent:added', { agent: agent.name });
|
||||
}
|
||||
|
||||
removeAgent(name: string): void {
|
||||
if (this.agents.delete(name)) {
|
||||
this.emit('agent:removed', { agent: name });
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state.reset();
|
||||
this.emit('reset');
|
||||
}
|
||||
|
||||
getMetrics(): {
|
||||
totalIterations: number;
|
||||
agentExecutions: Map<string, number>;
|
||||
averageDuration: number;
|
||||
errors: number;
|
||||
} {
|
||||
// This would be populated from telemetry in a real implementation
|
||||
return {
|
||||
totalIterations: 0,
|
||||
agentExecutions: new Map(),
|
||||
averageDuration: 0,
|
||||
errors: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createNetwork(config: NetworkConfig): Network {
|
||||
return new Network(config);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Router implementation for agent networks
|
||||
*/
|
||||
|
||||
import { Agent } from './agent';
|
||||
import { Network } from './network';
|
||||
import { State } from './state';
|
||||
|
||||
export interface RouterConfig {
|
||||
handler: (context: RouterContext) => Agent | undefined | Promise<Agent | undefined>;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface RouterContext {
|
||||
network: Network;
|
||||
state: State;
|
||||
messages: any[];
|
||||
iteration: number;
|
||||
history: any[];
|
||||
}
|
||||
|
||||
export class Router {
|
||||
private handler: RouterConfig['handler'];
|
||||
readonly metadata: Record<string, any>;
|
||||
|
||||
constructor(config: RouterConfig) {
|
||||
this.handler = config.handler;
|
||||
this.metadata = config.metadata || {};
|
||||
}
|
||||
|
||||
async route(context: RouterContext): Promise<Agent | undefined> {
|
||||
return Promise.resolve(this.handler(context));
|
||||
}
|
||||
}
|
||||
|
||||
export function createRouter(config: RouterConfig): Router {
|
||||
return new Router(config);
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
/**
|
||||
* State management for agent networks
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface StateConfig {
|
||||
schema?: z.ZodSchema;
|
||||
initial?: Record<string, any>;
|
||||
persistent?: boolean;
|
||||
}
|
||||
|
||||
export interface StateChange {
|
||||
key: string;
|
||||
oldValue: any;
|
||||
newValue: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class State extends EventEmitter {
|
||||
readonly kv: Map<string, any>;
|
||||
readonly history: StateChange[];
|
||||
readonly schema?: z.ZodSchema;
|
||||
private readonly persistent: boolean;
|
||||
|
||||
constructor(config: StateConfig = {}) {
|
||||
super();
|
||||
this.kv = new Map();
|
||||
this.history = [];
|
||||
this.schema = config.schema;
|
||||
this.persistent = config.persistent || false;
|
||||
|
||||
// Initialize with initial values
|
||||
if (config.initial) {
|
||||
for (const [key, value] of Object.entries(config.initial)) {
|
||||
this.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Load from persistence if enabled
|
||||
if (this.persistent) {
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
set(key: string, value: any): void {
|
||||
// Validate against schema if provided
|
||||
if (this.schema) {
|
||||
const result = this.schema.safeParse({ ...this.toJSON(), [key]: value });
|
||||
if (!result.success) {
|
||||
throw new Error(`State validation failed: ${result.error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const oldValue = this.kv.get(key);
|
||||
this.kv.set(key, value);
|
||||
|
||||
// Record change
|
||||
const change: StateChange = {
|
||||
key,
|
||||
oldValue,
|
||||
newValue: value,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.history.push(change);
|
||||
|
||||
// Emit change event
|
||||
this.emit('change', change);
|
||||
this.emit(`change:${key}`, { oldValue, newValue: value });
|
||||
|
||||
// Persist if enabled
|
||||
if (this.persistent) {
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
get(key: string): any {
|
||||
return this.kv.get(key);
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.kv.has(key);
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
const oldValue = this.kv.get(key);
|
||||
const deleted = this.kv.delete(key);
|
||||
|
||||
if (deleted) {
|
||||
const change: StateChange = {
|
||||
key,
|
||||
oldValue,
|
||||
newValue: undefined,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.history.push(change);
|
||||
this.emit('change', change);
|
||||
this.emit(`change:${key}`, { oldValue, newValue: undefined });
|
||||
|
||||
if (this.persistent) {
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
const oldState = this.toJSON();
|
||||
this.kv.clear();
|
||||
|
||||
// Record all deletions
|
||||
for (const key of Object.keys(oldState)) {
|
||||
const change: StateChange = {
|
||||
key,
|
||||
oldValue: oldState[key],
|
||||
newValue: undefined,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
this.history.push(change);
|
||||
}
|
||||
|
||||
this.emit('clear', { oldState });
|
||||
|
||||
if (this.persistent) {
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.clear();
|
||||
this.history.length = 0;
|
||||
this.emit('reset');
|
||||
}
|
||||
|
||||
toJSON(): Record<string, any> {
|
||||
const obj: Record<string, any> = {};
|
||||
for (const [key, value] of this.kv) {
|
||||
obj[key] = value;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
fromJSON(data: Record<string, any>): void {
|
||||
this.clear();
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
this.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
getHistory(key?: string): StateChange[] {
|
||||
if (key) {
|
||||
return this.history.filter(change => change.key === key);
|
||||
}
|
||||
return [...this.history];
|
||||
}
|
||||
|
||||
rollback(steps: number = 1): void {
|
||||
if (steps > this.history.length) {
|
||||
throw new Error('Cannot rollback more steps than history length');
|
||||
}
|
||||
|
||||
// Get changes to rollback
|
||||
const changesToRollback = this.history.slice(-steps);
|
||||
|
||||
// Apply rollback
|
||||
for (const change of changesToRollback.reverse()) {
|
||||
if (change.oldValue === undefined) {
|
||||
this.kv.delete(change.key);
|
||||
} else {
|
||||
this.kv.set(change.key, change.oldValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove rolled back changes from history
|
||||
this.history.length = this.history.length - steps;
|
||||
|
||||
this.emit('rollback', { steps, changes: changesToRollback });
|
||||
|
||||
if (this.persistent) {
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
// Persistence methods
|
||||
private save(): void {
|
||||
// Only available in browser environments
|
||||
if (typeof globalThis !== 'undefined' && 'localStorage' in globalThis) {
|
||||
const data = {
|
||||
state: this.toJSON(),
|
||||
history: this.history
|
||||
};
|
||||
(globalThis as any).localStorage.setItem('hanzo-ai-state', JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
// Only available in browser environments
|
||||
if (typeof globalThis !== 'undefined' && 'localStorage' in globalThis) {
|
||||
const stored = (globalThis as any).localStorage.getItem('hanzo-ai-state');
|
||||
if (stored) {
|
||||
try {
|
||||
const data = JSON.parse(stored);
|
||||
this.fromJSON(data.state);
|
||||
this.history.push(...(data.history || []));
|
||||
} catch (error) {
|
||||
console.error('Failed to load state from storage:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
compute<T>(key: string, fn: (state: Record<string, any>) => T): T {
|
||||
const state = this.toJSON();
|
||||
const result = fn(state);
|
||||
|
||||
// Cache computed value
|
||||
this.set(`_computed_${key}`, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
watch(key: string, callback: (value: any) => void): () => void {
|
||||
const handler = ({ newValue }: any) => callback(newValue);
|
||||
this.on(`change:${key}`, handler);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.off(`change:${key}`, handler);
|
||||
};
|
||||
}
|
||||
|
||||
// State machine helpers
|
||||
transition(from: string, to: string, stateKey: string = 'state'): boolean {
|
||||
const current = this.get(stateKey);
|
||||
|
||||
if (current === from) {
|
||||
this.set(stateKey, to);
|
||||
this.emit('transition', { from, to, stateKey });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
inState(state: string, stateKey: string = 'state'): boolean {
|
||||
return this.get(stateKey) === state;
|
||||
}
|
||||
}
|
||||
|
||||
export function createState(config?: StateConfig): State {
|
||||
return new State(config);
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* Tool implementation for agents
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { AgentContext } from './agent';
|
||||
|
||||
export interface ToolConfig<TParams = any, TResult = any> {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: z.ZodSchema<TParams>;
|
||||
handler: (params: TParams, context: AgentContext) => Promise<TResult> | TResult;
|
||||
examples?: Array<{
|
||||
input: TParams;
|
||||
output: TResult;
|
||||
description?: string;
|
||||
}>;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class Tool<TParams = any, TResult = any> {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly parameters: z.ZodSchema<TParams>;
|
||||
readonly handler: (params: TParams, context: AgentContext) => Promise<TResult> | TResult;
|
||||
readonly examples?: ToolConfig<TParams, TResult>['examples'];
|
||||
readonly metadata: Record<string, any>;
|
||||
|
||||
constructor(config: ToolConfig<TParams, TResult>) {
|
||||
this.name = config.name;
|
||||
this.description = config.description;
|
||||
this.parameters = config.parameters;
|
||||
this.handler = config.handler;
|
||||
this.examples = config.examples;
|
||||
this.metadata = config.metadata || {};
|
||||
}
|
||||
|
||||
async execute(params: any, context: AgentContext): Promise<TResult> {
|
||||
// Validate parameters
|
||||
const result = this.parameters.safeParse(params);
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid parameters for tool '${this.name}': ${result.error.message}`);
|
||||
}
|
||||
|
||||
// Execute handler
|
||||
return Promise.resolve(this.handler(result.data, context));
|
||||
}
|
||||
|
||||
getSchema(): any {
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
parameters: this.zodToJsonSchema(this.parameters)
|
||||
};
|
||||
}
|
||||
|
||||
private zodToJsonSchema(schema: z.ZodSchema): any {
|
||||
// This is a simplified version - in production you'd use a proper converter
|
||||
if (schema instanceof z.ZodObject) {
|
||||
const shape = schema.shape;
|
||||
const properties: any = {};
|
||||
const required: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(shape)) {
|
||||
properties[key] = this.zodToJsonSchema(value as z.ZodSchema);
|
||||
|
||||
// Check if field is required
|
||||
if (!(value as any).isOptional()) {
|
||||
required.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties,
|
||||
required: required.length > 0 ? required : undefined
|
||||
};
|
||||
} else if (schema instanceof z.ZodString) {
|
||||
return { type: 'string' };
|
||||
} else if (schema instanceof z.ZodNumber) {
|
||||
return { type: 'number' };
|
||||
} else if (schema instanceof z.ZodBoolean) {
|
||||
return { type: 'boolean' };
|
||||
} else if (schema instanceof z.ZodArray) {
|
||||
return {
|
||||
type: 'array',
|
||||
items: this.zodToJsonSchema((schema as any)._def.type)
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return { type: 'any' };
|
||||
}
|
||||
}
|
||||
|
||||
export function createTool<TParams = any, TResult = any>(
|
||||
config: ToolConfig<TParams, TResult>
|
||||
): Tool<TParams, TResult> {
|
||||
return new Tool(config);
|
||||
}
|
||||
|
||||
// Common tool patterns
|
||||
export const commonTools = {
|
||||
done: (onDone?: (result: any, context: AgentContext) => void) =>
|
||||
createTool({
|
||||
name: 'done',
|
||||
description: 'Call this tool when you are finished with the task.',
|
||||
parameters: z.object({
|
||||
answer: z.string().describe("Final answer or result"),
|
||||
summary: z.string().optional().describe("Brief summary of what was accomplished")
|
||||
}),
|
||||
handler: async (params, context) => {
|
||||
context.network?.state.set('complete', true);
|
||||
context.network?.state.set('answer', params.answer);
|
||||
if (params.summary) {
|
||||
context.network?.state.set('summary', params.summary);
|
||||
}
|
||||
|
||||
if (onDone) {
|
||||
onDone(params, context);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}),
|
||||
|
||||
handoff: () =>
|
||||
createTool({
|
||||
name: 'handoff',
|
||||
description: 'Hand off the conversation to another agent.',
|
||||
parameters: z.object({
|
||||
agent: z.string().describe("Name of the agent to hand off to"),
|
||||
context: z.string().describe("Context to provide to the next agent"),
|
||||
priority: z.boolean().optional().describe("Whether this is a priority handoff")
|
||||
}),
|
||||
handler: async (params, context) => {
|
||||
if (!context.network) {
|
||||
throw new Error('Handoff requires a network context');
|
||||
}
|
||||
|
||||
context.network.state.set('nextAgent', params.agent);
|
||||
context.network.state.set('handoffContext', params.context);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
agent: params.agent
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
askUser: () =>
|
||||
createTool({
|
||||
name: 'ask_user',
|
||||
description: 'Ask the user for additional information or clarification.',
|
||||
parameters: z.object({
|
||||
question: z.string().describe("The question to ask the user"),
|
||||
context: z.string().optional().describe("Additional context for the question"),
|
||||
options: z.array(z.string()).optional().describe("Multiple choice options if applicable")
|
||||
}),
|
||||
handler: async (params, context) => {
|
||||
context.network?.state.set('waitingForUser', true);
|
||||
context.network?.state.set('userQuestion', params);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
waiting: true
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
remember: () =>
|
||||
createTool({
|
||||
name: 'remember',
|
||||
description: 'Store information in long-term memory.',
|
||||
parameters: z.object({
|
||||
key: z.string().describe("Memory key"),
|
||||
value: z.any().describe("Value to remember"),
|
||||
category: z.string().optional().describe("Category for organization"),
|
||||
ttl: z.number().optional().describe("Time to live in seconds")
|
||||
}),
|
||||
handler: async (params, context) => {
|
||||
const memory = context.network?.state.get('memory') || {};
|
||||
memory[params.key] = {
|
||||
value: params.value,
|
||||
category: params.category,
|
||||
timestamp: Date.now(),
|
||||
ttl: params.ttl
|
||||
};
|
||||
|
||||
context.network?.state.set('memory', memory);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}),
|
||||
|
||||
recall: () =>
|
||||
createTool({
|
||||
name: 'recall',
|
||||
description: 'Retrieve information from long-term memory.',
|
||||
parameters: z.object({
|
||||
key: z.string().optional().describe("Specific memory key"),
|
||||
category: z.string().optional().describe("Category to search"),
|
||||
query: z.string().optional().describe("Search query")
|
||||
}),
|
||||
handler: async (params, context) => {
|
||||
const memory = context.network?.state.get('memory') || {};
|
||||
|
||||
if (params.key) {
|
||||
return memory[params.key]?.value || null;
|
||||
}
|
||||
|
||||
// Search by category or query
|
||||
const results: any[] = [];
|
||||
for (const [key, item] of Object.entries(memory)) {
|
||||
const memItem = item as any;
|
||||
|
||||
// Check TTL
|
||||
if (memItem.ttl && Date.now() - memItem.timestamp > memItem.ttl * 1000) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (params.category && memItem.category === params.category) {
|
||||
results.push({ key, ...memItem });
|
||||
} else if (params.query) {
|
||||
const searchStr = JSON.stringify(memItem.value).toLowerCase();
|
||||
if (searchStr.includes(params.query.toLowerCase())) {
|
||||
results.push({ key, ...memItem });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
})
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Error types for @hanzo/ai
|
||||
*/
|
||||
|
||||
export class HanzoAIError extends Error {
|
||||
constructor(message: string, public code?: string) {
|
||||
super(message);
|
||||
this.name = 'HanzoAIError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentError extends HanzoAIError {
|
||||
constructor(message: string, public agentName: string) {
|
||||
super(message, 'AGENT_ERROR');
|
||||
this.name = 'AgentError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends HanzoAIError {
|
||||
constructor(message: string, public networkName: string) {
|
||||
super(message, 'NETWORK_ERROR');
|
||||
this.name = 'NetworkError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolError extends HanzoAIError {
|
||||
constructor(message: string, public toolName: string) {
|
||||
super(message, 'TOOL_ERROR');
|
||||
this.name = 'ToolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class MCPError extends HanzoAIError {
|
||||
constructor(message: string, public serverName?: string) {
|
||||
super(message, 'MCP_ERROR');
|
||||
this.name = 'MCPError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends HanzoAIError {
|
||||
constructor(message: string, public field?: string) {
|
||||
super(message, 'VALIDATION_ERROR');
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* @hanzo/ai - The AI Toolkit for TypeScript
|
||||
* AgentKit with MCP support and multi-provider integration
|
||||
*/
|
||||
|
||||
// Core exports
|
||||
export { createAgent } from './core/agent';
|
||||
export { createNetwork } from './core/network';
|
||||
export { createTool } from './core/tool';
|
||||
export { createRouter } from './core/router';
|
||||
export { createState } from './core/state';
|
||||
|
||||
// Provider exports
|
||||
export { anthropic } from './providers/anthropic';
|
||||
export { openai } from './providers/openai';
|
||||
export { google } from './providers/google';
|
||||
export { mistral } from './providers/mistral';
|
||||
export { bedrock } from './providers/bedrock';
|
||||
export { vertex } from './providers/vertex';
|
||||
export { cohere } from './providers/cohere';
|
||||
export { hanzo } from './providers/hanzo';
|
||||
|
||||
// Core functionality
|
||||
export { generateText } from './core/generate/text';
|
||||
export { generateStream } from './core/generate/stream';
|
||||
export { generateObject } from './core/generate/object';
|
||||
export { embed } from './core/embed';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Utilities
|
||||
export { createCompletionId } from './utils/id';
|
||||
export { parseStreamPart } from './utils/stream';
|
||||
export { validateSchema } from './utils/schema';
|
||||
|
||||
// Errors
|
||||
export * from './errors';
|
||||
|
||||
// MCP exports
|
||||
export * from './mcp';
|
||||
|
||||
// Tracing
|
||||
export * from './telemetry';
|
||||
export { createHanzoCloudTelemetry, HanzoCloudTelemetry } from './telemetry/hanzo-cloud';
|
||||
@@ -1,461 +0,0 @@
|
||||
/**
|
||||
* MCP Server wrapper for Agents and Networks
|
||||
* Allows any agent or network to be exposed as an MCP server
|
||||
*/
|
||||
|
||||
import { Server as MCPServer } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { Agent } from '../core/agent';
|
||||
import { Network } from '../core/network';
|
||||
import { Tool } from '../core/tool';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface AgentServerConfig {
|
||||
agent?: Agent;
|
||||
network?: Network;
|
||||
name?: string;
|
||||
version?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class AgentMCPServer {
|
||||
private server: MCPServer;
|
||||
private agent?: Agent;
|
||||
private network?: Network;
|
||||
|
||||
constructor(config: AgentServerConfig) {
|
||||
if (!config.agent && !config.network) {
|
||||
throw new Error('Either agent or network must be provided');
|
||||
}
|
||||
|
||||
this.agent = config.agent;
|
||||
this.network = config.network;
|
||||
|
||||
const name = config.name || this.agent?.name || this.network?.name || 'hanzo-agent';
|
||||
const version = config.version || '1.0.0';
|
||||
|
||||
this.server = new MCPServer({
|
||||
name,
|
||||
version,
|
||||
metadata: config.metadata
|
||||
});
|
||||
|
||||
this.setupTools();
|
||||
this.setupResources();
|
||||
this.setupPrompts();
|
||||
}
|
||||
|
||||
private setupTools(): void {
|
||||
if (this.agent) {
|
||||
this.setupAgentTools();
|
||||
} else if (this.network) {
|
||||
this.setupNetworkTools();
|
||||
}
|
||||
}
|
||||
|
||||
private setupAgentTools(): void {
|
||||
if (!this.agent) return;
|
||||
|
||||
// Expose agent's run method as a tool
|
||||
this.server.setRequestHandler('tools/list', async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: `${this.agent.name}_chat`,
|
||||
description: `Chat with ${this.agent.name} agent`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Message to send to the agent'
|
||||
},
|
||||
context: {
|
||||
type: 'object',
|
||||
description: 'Optional context',
|
||||
properties: {
|
||||
history: {
|
||||
type: 'array',
|
||||
items: { type: 'object' },
|
||||
description: 'Previous messages'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['message']
|
||||
}
|
||||
},
|
||||
// Expose all agent tools
|
||||
...Array.from(this.agent.tools.values()).map(tool => ({
|
||||
name: `${this.agent!.name}_${tool.name}`,
|
||||
description: tool.description,
|
||||
inputSchema: this.toolToJsonSchema(tool)
|
||||
}))
|
||||
]
|
||||
}));
|
||||
|
||||
// Handle tool calls
|
||||
this.server.setRequestHandler('tools/call', async (request) => {
|
||||
const { name, arguments: args } = request.params as any;
|
||||
|
||||
if (name === `${this.agent!.name}_chat`) {
|
||||
const result = await this.agent!.run({
|
||||
messages: [
|
||||
{ role: 'user', content: args.message }
|
||||
],
|
||||
context: args.context
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result.content || JSON.stringify(result)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's one of the agent's tools
|
||||
const toolName = name.replace(`${this.agent!.name}_`, '');
|
||||
const tool = this.agent!.tools.get(toolName);
|
||||
|
||||
if (tool) {
|
||||
const result = await tool.execute(args, {
|
||||
agent: this.agent!,
|
||||
telemetry: {} as any
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Tool '${name}' not found`);
|
||||
});
|
||||
}
|
||||
|
||||
private setupNetworkTools(): void {
|
||||
if (!this.network) return;
|
||||
|
||||
this.server.setRequestHandler('tools/list', async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: `${this.network.name}_run`,
|
||||
description: `Run a task through the ${this.network.name} network`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Task or message for the network'
|
||||
},
|
||||
stream: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to stream responses'
|
||||
}
|
||||
},
|
||||
required: ['message']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: `${this.network.name}_state_get`,
|
||||
description: `Get a value from the network's state`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
description: 'State key to retrieve'
|
||||
}
|
||||
},
|
||||
required: ['key']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: `${this.network.name}_state_set`,
|
||||
description: `Set a value in the network's state`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
description: 'State key'
|
||||
},
|
||||
value: {
|
||||
description: 'Value to set'
|
||||
}
|
||||
},
|
||||
required: ['key', 'value']
|
||||
}
|
||||
},
|
||||
// Expose individual agents as tools
|
||||
...Array.from(this.network.agents.values()).map(agent => ({
|
||||
name: `${this.network!.name}_agent_${agent.name}`,
|
||||
description: `Run task through ${agent.name} agent in the network`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Message for the agent'
|
||||
}
|
||||
},
|
||||
required: ['message']
|
||||
}
|
||||
}))
|
||||
]
|
||||
}));
|
||||
|
||||
this.server.setRequestHandler('tools/call', async (request) => {
|
||||
const { name, arguments: args } = request.params as any;
|
||||
|
||||
if (name === `${this.network!.name}_run`) {
|
||||
const result = await this.network!.run({
|
||||
messages: [
|
||||
{ role: 'user', content: args.message }
|
||||
],
|
||||
stream: args.stream
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (name === `${this.network!.name}_state_get`) {
|
||||
const value = this.network!.state.get(args.key);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(value)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (name === `${this.network!.name}_state_set`) {
|
||||
this.network!.state.set(args.key, args.value);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'State updated'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// Check for agent-specific tools
|
||||
const agentMatch = name.match(new RegExp(`^${this.network!.name}_agent_(.+)$`));
|
||||
if (agentMatch) {
|
||||
const agentName = agentMatch[1];
|
||||
const agent = this.network!.getAgent(agentName);
|
||||
|
||||
if (agent) {
|
||||
const result = await agent.run({
|
||||
messages: [
|
||||
{ role: 'user', content: args.message }
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result.content || JSON.stringify(result)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Tool '${name}' not found`);
|
||||
});
|
||||
}
|
||||
|
||||
private setupResources(): void {
|
||||
this.server.setRequestHandler('resources/list', async () => ({
|
||||
resources: [
|
||||
{
|
||||
uri: `agent://${this.agent?.name || this.network?.name}/state`,
|
||||
name: 'State',
|
||||
description: 'Current state of the agent/network',
|
||||
mimeType: 'application/json'
|
||||
},
|
||||
{
|
||||
uri: `agent://${this.agent?.name || this.network?.name}/metrics`,
|
||||
name: 'Metrics',
|
||||
description: 'Performance metrics',
|
||||
mimeType: 'application/json'
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
this.server.setRequestHandler('resources/read', async (request) => {
|
||||
const { uri } = request.params as any;
|
||||
|
||||
if (uri.endsWith('/state')) {
|
||||
const state = this.network?.state.toJSON() || {
|
||||
agent: this.agent?.name,
|
||||
metadata: this.agent?.metadata
|
||||
};
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(state, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (uri.endsWith('/metrics')) {
|
||||
const metrics = this.network?.getMetrics() || {
|
||||
agent: this.agent?.name,
|
||||
calls: 0 // Would be tracked in real implementation
|
||||
};
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(metrics, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Resource '${uri}' not found`);
|
||||
});
|
||||
}
|
||||
|
||||
private setupPrompts(): void {
|
||||
const prompts = [];
|
||||
|
||||
if (this.agent && this.agent.system) {
|
||||
prompts.push({
|
||||
name: `${this.agent.name}_system`,
|
||||
description: `System prompt for ${this.agent.name}`,
|
||||
arguments: []
|
||||
});
|
||||
}
|
||||
|
||||
this.server.setRequestHandler('prompts/list', async () => ({
|
||||
prompts
|
||||
}));
|
||||
|
||||
this.server.setRequestHandler('prompts/get', async (request) => {
|
||||
const { name } = request.params as any;
|
||||
|
||||
if (name === `${this.agent?.name}_system`) {
|
||||
return {
|
||||
description: `System prompt for ${this.agent.name}`,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: this.agent!.system!
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Prompt '${name}' not found`);
|
||||
});
|
||||
}
|
||||
|
||||
private toolToJsonSchema(tool: Tool): any {
|
||||
// Convert Zod schema to JSON Schema
|
||||
// This is simplified - would use a proper converter in production
|
||||
return {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
};
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const transport = new StdioServerTransport();
|
||||
await this.server.connect(transport);
|
||||
}
|
||||
|
||||
async startHttp(port: number = 3000): Promise<void> {
|
||||
// HTTP transport implementation
|
||||
const { createServer } = await import('http');
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const request = JSON.parse(body);
|
||||
const response = await this.handleHttpRequest(request);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(response));
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: String(error) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port);
|
||||
console.log(`Agent MCP server listening on http://localhost:${port}`);
|
||||
}
|
||||
|
||||
private async handleHttpRequest(request: any): Promise<any> {
|
||||
// Route to appropriate handler based on request method
|
||||
const [namespace, method] = request.method.split('/');
|
||||
|
||||
if (namespace === 'tools') {
|
||||
if (method === 'list') {
|
||||
return this.server.handleRequest({ ...request, method: 'tools/list' });
|
||||
} else if (method === 'call') {
|
||||
return this.server.handleRequest({ ...request, method: 'tools/call' });
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unknown method: ${request.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create and start an MCP server for an agent/network
|
||||
export function exposeAsMCP(
|
||||
agentOrNetwork: Agent | Network,
|
||||
options?: {
|
||||
transport?: 'stdio' | 'http';
|
||||
port?: number;
|
||||
name?: string;
|
||||
}
|
||||
): AgentMCPServer {
|
||||
const server = new AgentMCPServer({
|
||||
agent: agentOrNetwork instanceof Agent ? agentOrNetwork : undefined,
|
||||
network: agentOrNetwork instanceof Network ? agentOrNetwork : undefined,
|
||||
name: options?.name
|
||||
});
|
||||
|
||||
if (options?.transport === 'http') {
|
||||
server.startHttp(options.port);
|
||||
} else {
|
||||
server.start();
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* MCP Client implementation
|
||||
*/
|
||||
|
||||
import { MCPTransport, MCPTool, MCPResource, MCPPrompt } from './types';
|
||||
|
||||
export interface MCPClientConfig {
|
||||
name: string;
|
||||
transport: MCPTransport;
|
||||
}
|
||||
|
||||
export class MCPClient {
|
||||
private config: MCPClientConfig;
|
||||
|
||||
constructor(config?: MCPClientConfig) {
|
||||
this.config = config || { name: 'default', transport: { type: 'stdio' } };
|
||||
}
|
||||
|
||||
async connect(config: MCPClientConfig): Promise<void> {
|
||||
this.config = config;
|
||||
// Implementation would connect to MCP server
|
||||
}
|
||||
|
||||
async listTools(): Promise<MCPTool[]> {
|
||||
// Implementation would fetch tools from server
|
||||
return [];
|
||||
}
|
||||
|
||||
async listResources(): Promise<MCPResource[]> {
|
||||
// Implementation would fetch resources from server
|
||||
return [];
|
||||
}
|
||||
|
||||
async listPrompts(): Promise<MCPPrompt[]> {
|
||||
// Implementation would fetch prompts from server
|
||||
return [];
|
||||
}
|
||||
|
||||
async callTool(params: { name: string; arguments: any }): Promise<any> {
|
||||
// Implementation would call tool on server
|
||||
return {};
|
||||
}
|
||||
|
||||
async readResource(uri: string): Promise<any> {
|
||||
// Implementation would read resource from server
|
||||
return {};
|
||||
}
|
||||
|
||||
async getPrompt(name: string, args?: any): Promise<any> {
|
||||
// Implementation would get prompt from server
|
||||
return {};
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
// Implementation would disconnect from server
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* MCP exports
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export * from './client';
|
||||
export * from './agent-server';
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* MCP types for @hanzo/ai
|
||||
*/
|
||||
|
||||
export interface MCPServer {
|
||||
name: string;
|
||||
transport: MCPTransport;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface MCPTransport {
|
||||
type: 'stdio' | 'http' | 'websocket';
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MCPTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: any; // JSON Schema
|
||||
}
|
||||
|
||||
export interface MCPResource {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface MCPPrompt {
|
||||
name: string;
|
||||
description: string;
|
||||
arguments?: any[];
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Anthropic provider
|
||||
*/
|
||||
|
||||
import { ModelInterface } from '../types';
|
||||
|
||||
export const anthropic = (config: { apiKey: string; model?: string }): ModelInterface => {
|
||||
return {
|
||||
name: config.model || 'claude-3-opus-20240229',
|
||||
async complete(params) {
|
||||
// Implementation would call Anthropic API
|
||||
return { content: 'Claude response' };
|
||||
},
|
||||
async *stream(params) {
|
||||
// Implementation would stream from Anthropic
|
||||
yield { type: 'content', content: 'Claude' };
|
||||
yield { type: 'done' };
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export const bedrock = (config: any) => ({ name: 'bedrock', async complete(p: any) { return { content: '' }; }, async *stream(p: any) { yield { type: 'done' as const }; } });
|
||||
@@ -1 +0,0 @@
|
||||
export const cohere = (config: any) => ({ name: 'cohere', async complete(p: any) { return { content: '' }; }, async *stream(p: any) { yield { type: 'done' as const }; } });
|
||||
@@ -1 +0,0 @@
|
||||
export const google = (config: any) => ({ name: 'gemini', async complete(p: any) { return { content: '' }; }, async *stream(p: any) { yield { type: 'done' as const }; } });
|
||||
@@ -1 +0,0 @@
|
||||
export const hanzo = (config: any) => ({ name: 'hanzo', async complete(p: any) { return { content: '' }; }, async *stream(p: any) { yield { type: 'done' as const }; } });
|
||||
@@ -1 +0,0 @@
|
||||
export const mistral = (config: any) => ({ name: 'mistral', async complete(p: any) { return { content: '' }; }, async *stream(p: any) { yield { type: 'done' as const }; } });
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* OpenAI provider
|
||||
*/
|
||||
|
||||
import { ModelInterface } from '../types';
|
||||
|
||||
export const openai = (config: { apiKey: string; model?: string }): ModelInterface => {
|
||||
return {
|
||||
name: config.model || 'gpt-4',
|
||||
async complete(params) {
|
||||
// Implementation would call OpenAI API
|
||||
return { content: 'OpenAI response' };
|
||||
},
|
||||
async *stream(params) {
|
||||
// Implementation would stream from OpenAI
|
||||
yield { type: 'content', content: 'OpenAI' };
|
||||
yield { type: 'done' };
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export const vertex = (config: any) => ({ name: 'vertex', async complete(p: any) { return { content: '' }; }, async *stream(p: any) { yield { type: 'done' as const }; } });
|
||||
@@ -1,18 +0,0 @@
|
||||
// Server exports for @hanzo/ai
|
||||
// This module provides server-side specific functionality
|
||||
|
||||
export { Agent } from '../core/agent'
|
||||
export { Network } from '../core/network'
|
||||
export { Router, type RouterContext } from '../core/router'
|
||||
export { State } from '../core/state'
|
||||
export { AgentMCPServer } from '../mcp/agent-server'
|
||||
|
||||
// Re-export providers for server use
|
||||
export * from '../providers/anthropic'
|
||||
export * from '../providers/openai'
|
||||
export * from '../providers/google'
|
||||
export * from '../providers/bedrock'
|
||||
export * from '../providers/vertex'
|
||||
export * from '../providers/cohere'
|
||||
export * from '../providers/mistral'
|
||||
export * from '../providers/hanzo'
|
||||
@@ -1,319 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HanzoCloudTelemetry } from '../hanzo-cloud';
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('HanzoCloudTelemetry', () => {
|
||||
let telemetry: HanzoCloudTelemetry;
|
||||
const mockConfig = {
|
||||
cloudUrl: 'https://test.hanzo.ai',
|
||||
apiKey: 'test-api-key',
|
||||
projectId: 'test-project',
|
||||
environment: 'test',
|
||||
serviceName: 'test-service'
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({})
|
||||
});
|
||||
|
||||
telemetry = new HanzoCloudTelemetry(mockConfig);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await telemetry.shutdown();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with config', () => {
|
||||
expect(telemetry).toBeInstanceOf(HanzoCloudTelemetry);
|
||||
});
|
||||
|
||||
it('should log initialization', async () => {
|
||||
// Just verify that telemetry initializes without errors
|
||||
// The log output is visible in stdout which confirms it works
|
||||
const newTelemetry = new HanzoCloudTelemetry(mockConfig);
|
||||
expect(newTelemetry).toBeInstanceOf(HanzoCloudTelemetry);
|
||||
|
||||
await newTelemetry.shutdown();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logging', () => {
|
||||
it('should log with trace context', async () => {
|
||||
await telemetry.trace('test-operation', async () => {
|
||||
telemetry.log('info', 'Test message', { custom: 'data' });
|
||||
});
|
||||
});
|
||||
|
||||
it('should support all log levels', () => {
|
||||
telemetry.log('debug', 'Debug message');
|
||||
telemetry.log('info', 'Info message');
|
||||
telemetry.log('warn', 'Warning message');
|
||||
telemetry.log('error', 'Error message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent metrics', () => {
|
||||
it('should record agent execution', () => {
|
||||
const incrementSpy = vi.spyOn(telemetry, 'increment');
|
||||
const histogramSpy = vi.spyOn(telemetry, 'histogram');
|
||||
const logSpy = vi.spyOn(telemetry, 'log');
|
||||
|
||||
telemetry.recordAgentExecution('test-agent', 1000, true, {
|
||||
model: 'gpt-4',
|
||||
tokens: 150
|
||||
});
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('agent.executions', 1, {
|
||||
agent: 'test-agent',
|
||||
success: 'true'
|
||||
});
|
||||
|
||||
expect(histogramSpy).toHaveBeenCalledWith('agent.execution.duration', 1000, {
|
||||
agent: 'test-agent'
|
||||
});
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('agent.tokens.used', 150, {
|
||||
agent: 'test-agent',
|
||||
model: 'gpt-4'
|
||||
});
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Agent test-agent executed',
|
||||
expect.objectContaining({
|
||||
agent: 'test-agent',
|
||||
duration: 1000,
|
||||
success: true,
|
||||
model: 'gpt-4',
|
||||
tokens: 150
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should record failed agent execution', () => {
|
||||
const incrementSpy = vi.spyOn(telemetry, 'increment');
|
||||
|
||||
telemetry.recordAgentExecution('test-agent', 500, false, {
|
||||
error: 'Test error'
|
||||
});
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('agent.executions', 1, {
|
||||
agent: 'test-agent',
|
||||
success: 'false'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('network metrics', () => {
|
||||
it('should record network execution', () => {
|
||||
const incrementSpy = vi.spyOn(telemetry, 'increment');
|
||||
const histogramSpy = vi.spyOn(telemetry, 'histogram');
|
||||
const logSpy = vi.spyOn(telemetry, 'log');
|
||||
|
||||
const agentExecutions = new Map([
|
||||
['agent1', 3],
|
||||
['agent2', 2]
|
||||
]);
|
||||
|
||||
telemetry.recordNetworkExecution('test-network', 5, 3000, agentExecutions);
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('network.executions', 1, {
|
||||
network: 'test-network'
|
||||
});
|
||||
|
||||
expect(histogramSpy).toHaveBeenCalledWith('network.iterations', 5, {
|
||||
network: 'test-network'
|
||||
});
|
||||
|
||||
expect(histogramSpy).toHaveBeenCalledWith('network.execution.duration', 3000, {
|
||||
network: 'test-network'
|
||||
});
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('network.agent.executions', 3, {
|
||||
network: 'test-network',
|
||||
agent: 'agent1'
|
||||
});
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('network.agent.executions', 2, {
|
||||
network: 'test-network',
|
||||
agent: 'agent2'
|
||||
});
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Network test-network completed',
|
||||
expect.objectContaining({
|
||||
network: 'test-network',
|
||||
iterations: 5,
|
||||
duration: 3000,
|
||||
agents: { agent1: 3, agent2: 2 }
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool metrics', () => {
|
||||
it('should record tool usage', () => {
|
||||
const incrementSpy = vi.spyOn(telemetry, 'increment');
|
||||
const histogramSpy = vi.spyOn(telemetry, 'histogram');
|
||||
|
||||
telemetry.recordToolUsage('search', 'agent1', 100, true);
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('tool.executions', 1, {
|
||||
tool: 'search',
|
||||
agent: 'agent1',
|
||||
success: 'true'
|
||||
});
|
||||
|
||||
expect(histogramSpy).toHaveBeenCalledWith('tool.execution.duration', 100, {
|
||||
tool: 'search',
|
||||
agent: 'agent1'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP metrics', () => {
|
||||
it('should record MCP connection', () => {
|
||||
const incrementSpy = vi.spyOn(telemetry, 'increment');
|
||||
const gaugeSpy = vi.spyOn(telemetry, 'gauge');
|
||||
const logSpy = vi.spyOn(telemetry, 'log');
|
||||
|
||||
telemetry.recordMCPConnection('file-system', true, {
|
||||
transport: 'stdio'
|
||||
});
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('mcp.connections', 1, {
|
||||
server: 'file-system',
|
||||
success: 'true'
|
||||
});
|
||||
|
||||
expect(gaugeSpy).toHaveBeenCalledWith('mcp.servers.active', 1, {
|
||||
server: 'file-system'
|
||||
});
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'MCP server file-system connection established',
|
||||
expect.objectContaining({
|
||||
server: 'file-system',
|
||||
transport: 'stdio'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should record failed MCP connection', () => {
|
||||
const incrementSpy = vi.spyOn(telemetry, 'increment');
|
||||
const gaugeSpy = vi.spyOn(telemetry, 'gauge');
|
||||
|
||||
telemetry.recordMCPConnection('file-system', false);
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('mcp.connections', 1, {
|
||||
server: 'file-system',
|
||||
success: 'false'
|
||||
});
|
||||
|
||||
expect(gaugeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessions', () => {
|
||||
it('should create sessions', () => {
|
||||
const setAttributesSpy = vi.spyOn(telemetry, 'setAttributes');
|
||||
|
||||
const sessionId = telemetry.createSession();
|
||||
|
||||
expect(sessionId).toMatch(/^session_\d+_[a-z0-9]+$/);
|
||||
expect(setAttributesSpy).toHaveBeenCalledWith({
|
||||
'hanzo.session.id': sessionId
|
||||
});
|
||||
});
|
||||
|
||||
it('should use provided session ID', () => {
|
||||
const setAttributesSpy = vi.spyOn(telemetry, 'setAttributes');
|
||||
|
||||
const sessionId = telemetry.createSession('custom-session-123');
|
||||
|
||||
expect(sessionId).toBe('custom-session-123');
|
||||
expect(setAttributesSpy).toHaveBeenCalledWith({
|
||||
'hanzo.session.id': 'custom-session-123'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('metrics flushing', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should buffer and flush metrics', async () => {
|
||||
// Record some metrics
|
||||
telemetry.increment('test.counter', 1);
|
||||
telemetry.increment('test.counter', 2);
|
||||
telemetry.gauge('test.gauge', 10);
|
||||
telemetry.gauge('test.gauge', 20);
|
||||
telemetry.histogram('test.histogram', 100);
|
||||
telemetry.histogram('test.histogram', 200);
|
||||
|
||||
// Manually trigger flush
|
||||
await (telemetry as any).flushMetrics(mockConfig);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://test.hanzo.ai/v1/metrics',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': 'test-api-key',
|
||||
'x-project-id': 'test-project'
|
||||
}),
|
||||
body: expect.stringContaining('metrics')
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle metrics send failure', async () => {
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error'
|
||||
});
|
||||
|
||||
// Add some metrics to flush
|
||||
telemetry.increment('test.counter', 1);
|
||||
|
||||
// Trigger metrics flush - should not throw
|
||||
await expect(
|
||||
(telemetry as any).flushMetrics(mockConfig)
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// The error log is visible in stdout which confirms error handling works
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
// Add some metrics to flush
|
||||
telemetry.increment('test.counter', 1);
|
||||
|
||||
// Trigger metrics flush - should not throw
|
||||
await expect(
|
||||
(telemetry as any).flushMetrics(mockConfig)
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// The error log is visible in stdout which confirms error handling works
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,225 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { Telemetry } from '../index';
|
||||
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
|
||||
|
||||
describe('Telemetry', () => {
|
||||
let telemetry: Telemetry;
|
||||
|
||||
beforeEach(() => {
|
||||
telemetry = new Telemetry({
|
||||
serviceName: 'test-service',
|
||||
enabled: true
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await telemetry.shutdown();
|
||||
});
|
||||
|
||||
describe('span operations', () => {
|
||||
it('should create and end spans', () => {
|
||||
const span = telemetry.startSpan('test-span', {
|
||||
kind: SpanKind.INTERNAL,
|
||||
attributes: { 'test.attr': 'value' }
|
||||
});
|
||||
|
||||
expect(span).toBeDefined();
|
||||
expect(span.end).toBeInstanceOf(Function);
|
||||
|
||||
telemetry.endSpan('test-span', { code: SpanStatusCode.OK });
|
||||
});
|
||||
|
||||
it('should trace async operations', async () => {
|
||||
const result = await telemetry.trace(
|
||||
'async-operation',
|
||||
async (span) => {
|
||||
span.setAttribute('operation.type', 'test');
|
||||
return 'success';
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBe('success');
|
||||
});
|
||||
|
||||
it('should trace sync operations', () => {
|
||||
const result = telemetry.traceSync(
|
||||
'sync-operation',
|
||||
(span) => {
|
||||
span.setAttribute('operation.type', 'test');
|
||||
return 42;
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('should handle errors in traced operations', async () => {
|
||||
const error = new Error('Test error');
|
||||
|
||||
await expect(
|
||||
telemetry.trace('failing-operation', async () => {
|
||||
throw error;
|
||||
})
|
||||
).rejects.toThrow('Test error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('events and attributes', () => {
|
||||
it('should record events', () => {
|
||||
const span = telemetry.startSpan('test-span');
|
||||
|
||||
telemetry.recordEvent({
|
||||
name: 'test.event',
|
||||
attributes: { 'event.type': 'test' },
|
||||
timestamp: Date.now()
|
||||
}, span);
|
||||
|
||||
telemetry.endSpan('test-span');
|
||||
});
|
||||
|
||||
it('should set attributes', () => {
|
||||
const span = telemetry.startSpan('test-span');
|
||||
|
||||
telemetry.setAttributes({
|
||||
'attr1': 'value1',
|
||||
'attr2': 42,
|
||||
'attr3': true
|
||||
}, span);
|
||||
|
||||
telemetry.endSpan('test-span');
|
||||
});
|
||||
|
||||
it('should record exceptions', () => {
|
||||
const span = telemetry.startSpan('test-span');
|
||||
const error = new Error('Test exception');
|
||||
|
||||
telemetry.recordException(error, span);
|
||||
|
||||
telemetry.endSpan('test-span');
|
||||
});
|
||||
});
|
||||
|
||||
describe('metrics', () => {
|
||||
it('should emit metric events', () => {
|
||||
const metricHandler = vi.fn();
|
||||
telemetry.on('metric', metricHandler);
|
||||
|
||||
telemetry.increment('test.counter', 1, { tag: 'value' });
|
||||
|
||||
expect(metricHandler).toHaveBeenCalledWith({
|
||||
name: 'test.counter',
|
||||
value: 1,
|
||||
type: 'counter',
|
||||
tags: expect.objectContaining({ tag: 'value' }),
|
||||
timestamp: expect.any(Number)
|
||||
});
|
||||
});
|
||||
|
||||
it('should record gauges', () => {
|
||||
const metricHandler = vi.fn();
|
||||
telemetry.on('metric', metricHandler);
|
||||
|
||||
telemetry.gauge('test.gauge', 42);
|
||||
|
||||
expect(metricHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'test.gauge',
|
||||
value: 42,
|
||||
type: 'gauge'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should record histograms', () => {
|
||||
const metricHandler = vi.fn();
|
||||
telemetry.on('metric', metricHandler);
|
||||
|
||||
telemetry.histogram('test.histogram', 123);
|
||||
|
||||
expect(metricHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'test.histogram',
|
||||
value: 123,
|
||||
type: 'histogram'
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('context propagation', () => {
|
||||
it('should get trace context', async () => {
|
||||
await telemetry.trace('test-op', async () => {
|
||||
const context = telemetry.getTraceContext();
|
||||
|
||||
expect(context).toBeInstanceOf(Object);
|
||||
// In test environment with noop tracer, context might be empty
|
||||
// This is expected behavior
|
||||
});
|
||||
});
|
||||
|
||||
it('should create child telemetry instances', () => {
|
||||
const child = telemetry.createChild('child-service', {
|
||||
'child.attr': 'value'
|
||||
});
|
||||
|
||||
expect(child).toBeInstanceOf(Telemetry);
|
||||
expect(child).not.toBe(telemetry);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('should flush pending data', async () => {
|
||||
const flushHandler = vi.fn();
|
||||
telemetry.on('flush', flushHandler);
|
||||
|
||||
// Create some spans
|
||||
telemetry.startSpan('span1');
|
||||
telemetry.startSpan('span2');
|
||||
|
||||
await telemetry.flush();
|
||||
|
||||
expect(flushHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should shutdown cleanly', async () => {
|
||||
const span = telemetry.startSpan('test-span');
|
||||
|
||||
await telemetry.shutdown();
|
||||
|
||||
// Should not throw
|
||||
telemetry.endSpan('test-span');
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled telemetry', () => {
|
||||
it('should not create real spans when disabled', () => {
|
||||
const disabledTelemetry = new Telemetry({
|
||||
serviceName: 'test',
|
||||
enabled: false
|
||||
});
|
||||
|
||||
const span = disabledTelemetry.startSpan('test-span');
|
||||
|
||||
// Should return a no-op span
|
||||
expect(span).toBeDefined();
|
||||
expect(span.end).toBeInstanceOf(Function);
|
||||
|
||||
// Should not throw
|
||||
span.end();
|
||||
});
|
||||
|
||||
it('should not emit metrics when disabled', () => {
|
||||
const disabledTelemetry = new Telemetry({
|
||||
serviceName: 'test',
|
||||
enabled: false
|
||||
});
|
||||
|
||||
const metricHandler = vi.fn();
|
||||
disabledTelemetry.on('metric', metricHandler);
|
||||
|
||||
disabledTelemetry.increment('test.counter');
|
||||
|
||||
expect(metricHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,363 +0,0 @@
|
||||
/**
|
||||
* Hanzo Cloud telemetry integration
|
||||
* Connects to Hanzo Cloud's observability platform for centralized monitoring
|
||||
*/
|
||||
|
||||
import { Telemetry, TelemetryConfig } from './index';
|
||||
import * as opentelemetry from '@opentelemetry/api';
|
||||
import { DiagConsoleLogger, DiagLogLevel, diag } from '@opentelemetry/api';
|
||||
import { Resource } from '@opentelemetry/resources';
|
||||
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
|
||||
import { registerInstrumentations } from '@opentelemetry/instrumentation';
|
||||
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
||||
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
||||
import winston from 'winston';
|
||||
|
||||
export interface HanzoCloudConfig extends TelemetryConfig {
|
||||
cloudUrl: string;
|
||||
apiKey: string;
|
||||
projectId: string;
|
||||
environment?: string;
|
||||
serviceName?: string;
|
||||
serviceVersion?: string;
|
||||
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||
}
|
||||
|
||||
export class HanzoCloudTelemetry extends Telemetry {
|
||||
private logger: winston.Logger;
|
||||
private exporter?: OTLPTraceExporter;
|
||||
private provider?: NodeTracerProvider;
|
||||
private metricsBuffer: Map<string, any[]> = new Map();
|
||||
private flushInterval?: NodeJS.Timeout;
|
||||
|
||||
constructor(config: HanzoCloudConfig) {
|
||||
super(config);
|
||||
|
||||
// Setup Winston logger with Hanzo Cloud format
|
||||
this.logger = this.createLogger(config);
|
||||
|
||||
// Initialize OpenTelemetry
|
||||
this.initializeOpenTelemetry(config);
|
||||
|
||||
// Start metrics flush interval
|
||||
this.startMetricsFlush(config);
|
||||
|
||||
// Log initialization
|
||||
this.logger.info('Hanzo Cloud telemetry initialized', {
|
||||
projectId: config.projectId,
|
||||
environment: config.environment || 'development',
|
||||
serviceName: config.serviceName || 'hanzo-ai'
|
||||
});
|
||||
}
|
||||
|
||||
private createLogger(config: HanzoCloudConfig): winston.Logger {
|
||||
// Tracing format that adds trace context to logs
|
||||
const tracingFormat = winston.format((info) => {
|
||||
const span = opentelemetry.trace.getActiveSpan();
|
||||
if (span) {
|
||||
const { spanId, traceId } = span.spanContext();
|
||||
info['trace_id'] = traceId;
|
||||
info['span_id'] = spanId;
|
||||
info['project_id'] = config.projectId;
|
||||
info['service.name'] = config.serviceName || 'hanzo-ai';
|
||||
}
|
||||
return info;
|
||||
});
|
||||
|
||||
return winston.createLogger({
|
||||
level: config.logLevel || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.timestamp(),
|
||||
tracingFormat(),
|
||||
winston.format.json()
|
||||
),
|
||||
defaultMeta: {
|
||||
service: config.serviceName || 'hanzo-ai',
|
||||
environment: config.environment || 'development',
|
||||
projectId: config.projectId
|
||||
},
|
||||
transports: [
|
||||
new winston.transports.Console(),
|
||||
// Could add HTTP transport to send logs to Hanzo Cloud
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private initializeOpenTelemetry(config: HanzoCloudConfig): void {
|
||||
// Enable OpenTelemetry debugging in development
|
||||
if (config.environment === 'development') {
|
||||
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO);
|
||||
}
|
||||
|
||||
// Create resource
|
||||
const resource = new Resource({
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: config.serviceName || 'hanzo-ai',
|
||||
[SemanticResourceAttributes.SERVICE_VERSION]: config.serviceVersion || '1.0.0',
|
||||
[SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: config.environment || 'development',
|
||||
'hanzo.project.id': config.projectId,
|
||||
'hanzo.cloud.region': process.env.HANZO_CLOUD_REGION || 'us-east-1'
|
||||
});
|
||||
|
||||
// Create OTLP exporter
|
||||
this.exporter = new OTLPTraceExporter({
|
||||
url: `${config.cloudUrl}/v1/traces`,
|
||||
headers: {
|
||||
'x-api-key': config.apiKey,
|
||||
'x-project-id': config.projectId
|
||||
}
|
||||
});
|
||||
|
||||
// Create provider
|
||||
this.provider = new NodeTracerProvider({
|
||||
resource
|
||||
});
|
||||
|
||||
// Add batch processor
|
||||
this.provider.addSpanProcessor(
|
||||
new BatchSpanProcessor(this.exporter, {
|
||||
maxQueueSize: 1000,
|
||||
maxExportBatchSize: 512,
|
||||
scheduledDelayMillis: 5000,
|
||||
exportTimeoutMillis: 30000
|
||||
})
|
||||
);
|
||||
|
||||
// Register as global provider
|
||||
this.provider.register();
|
||||
|
||||
// Register instrumentations for automatic tracing
|
||||
registerInstrumentations({
|
||||
instrumentations: [
|
||||
// Add instrumentations as needed
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private startMetricsFlush(config: HanzoCloudConfig): void {
|
||||
// Flush metrics every 30 seconds
|
||||
this.flushInterval = setInterval(() => {
|
||||
this.flushMetrics(config);
|
||||
}, 30000);
|
||||
|
||||
// Listen for metric events
|
||||
this.on('metric', (metric) => {
|
||||
const key = `${metric.name}:${metric.type}`;
|
||||
if (!this.metricsBuffer.has(key)) {
|
||||
this.metricsBuffer.set(key, []);
|
||||
}
|
||||
this.metricsBuffer.get(key)!.push(metric);
|
||||
});
|
||||
}
|
||||
|
||||
private async flushMetrics(config: HanzoCloudConfig): Promise<void> {
|
||||
if (this.metricsBuffer.size === 0) return;
|
||||
|
||||
const metrics = Array.from(this.metricsBuffer.entries()).map(([key, values]) => {
|
||||
const [name, type] = key.split(':');
|
||||
|
||||
// Aggregate metrics
|
||||
if (type === 'counter') {
|
||||
const sum = values.reduce((acc, m) => acc + m.value, 0);
|
||||
return { name, type, value: sum, tags: values[0].tags };
|
||||
} else if (type === 'gauge') {
|
||||
// Use latest value for gauges
|
||||
return values[values.length - 1];
|
||||
} else if (type === 'histogram') {
|
||||
// Calculate percentiles for histograms
|
||||
const sorted = values.map(m => m.value).sort((a, b) => a - b);
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
value: {
|
||||
count: sorted.length,
|
||||
min: sorted[0],
|
||||
max: sorted[sorted.length - 1],
|
||||
p50: sorted[Math.floor(sorted.length * 0.5)],
|
||||
p95: sorted[Math.floor(sorted.length * 0.95)],
|
||||
p99: sorted[Math.floor(sorted.length * 0.99)]
|
||||
},
|
||||
tags: values[0].tags
|
||||
};
|
||||
}
|
||||
return values[0];
|
||||
});
|
||||
|
||||
// Send to Hanzo Cloud
|
||||
try {
|
||||
const response = await fetch(`${config.cloudUrl}/v1/metrics`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': config.apiKey,
|
||||
'x-project-id': config.projectId
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metrics,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.error('Failed to send metrics to Hanzo Cloud', {
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
});
|
||||
}
|
||||
|
||||
// Clear buffer after successful send
|
||||
this.metricsBuffer.clear();
|
||||
} catch (error) {
|
||||
this.logger.error('Error sending metrics to Hanzo Cloud', { error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message with trace context
|
||||
*/
|
||||
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, meta?: any): void {
|
||||
this.logger[level](message, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an agent execution
|
||||
*/
|
||||
recordAgentExecution(agentName: string, duration: number, success: boolean, metadata?: any): void {
|
||||
this.increment('agent.executions', 1, {
|
||||
agent: agentName,
|
||||
success: success ? 'true' : 'false'
|
||||
});
|
||||
|
||||
this.histogram('agent.execution.duration', duration, {
|
||||
agent: agentName
|
||||
});
|
||||
|
||||
if (metadata?.tokens) {
|
||||
this.increment('agent.tokens.used', metadata.tokens, {
|
||||
agent: agentName,
|
||||
model: metadata.model || 'unknown'
|
||||
});
|
||||
}
|
||||
|
||||
this.log('info', `Agent ${agentName} executed`, {
|
||||
agent: agentName,
|
||||
duration,
|
||||
success,
|
||||
...metadata
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a network execution
|
||||
*/
|
||||
recordNetworkExecution(
|
||||
networkName: string,
|
||||
iterations: number,
|
||||
duration: number,
|
||||
agentExecutions: Map<string, number>
|
||||
): void {
|
||||
this.increment('network.executions', 1, {
|
||||
network: networkName
|
||||
});
|
||||
|
||||
this.histogram('network.iterations', iterations, {
|
||||
network: networkName
|
||||
});
|
||||
|
||||
this.histogram('network.execution.duration', duration, {
|
||||
network: networkName
|
||||
});
|
||||
|
||||
// Record per-agent metrics within the network
|
||||
for (const [agent, count] of agentExecutions) {
|
||||
this.increment('network.agent.executions', count, {
|
||||
network: networkName,
|
||||
agent
|
||||
});
|
||||
}
|
||||
|
||||
this.log('info', `Network ${networkName} completed`, {
|
||||
network: networkName,
|
||||
iterations,
|
||||
duration,
|
||||
agents: Object.fromEntries(agentExecutions)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record tool usage
|
||||
*/
|
||||
recordToolUsage(toolName: string, agentName: string, duration: number, success: boolean): void {
|
||||
this.increment('tool.executions', 1, {
|
||||
tool: toolName,
|
||||
agent: agentName,
|
||||
success: success ? 'true' : 'false'
|
||||
});
|
||||
|
||||
this.histogram('tool.execution.duration', duration, {
|
||||
tool: toolName,
|
||||
agent: agentName
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record MCP server connection
|
||||
*/
|
||||
recordMCPConnection(serverName: string, success: boolean, metadata?: any): void {
|
||||
this.increment('mcp.connections', 1, {
|
||||
server: serverName,
|
||||
success: success ? 'true' : 'false'
|
||||
});
|
||||
|
||||
if (success) {
|
||||
this.gauge('mcp.servers.active', 1, {
|
||||
server: serverName
|
||||
});
|
||||
}
|
||||
|
||||
this.log('info', `MCP server ${serverName} connection ${success ? 'established' : 'failed'}`, {
|
||||
server: serverName,
|
||||
...metadata
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session for tracking related executions
|
||||
*/
|
||||
createSession(sessionId?: string): string {
|
||||
const id = sessionId || `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
this.setAttributes({ 'hanzo.session.id': id });
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced shutdown with cleanup
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
// Clear intervals
|
||||
if (this.flushInterval) {
|
||||
clearInterval(this.flushInterval);
|
||||
}
|
||||
|
||||
// Flush remaining metrics
|
||||
await this.flushMetrics(this.config as HanzoCloudConfig);
|
||||
|
||||
// Shutdown OpenTelemetry
|
||||
if (this.provider) {
|
||||
await this.provider.shutdown();
|
||||
}
|
||||
|
||||
// Call parent shutdown
|
||||
await super.shutdown();
|
||||
|
||||
this.logger.info('Hanzo Cloud telemetry shut down');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Hanzo Cloud telemetry instance
|
||||
*/
|
||||
export function createHanzoCloudTelemetry(config: HanzoCloudConfig): HanzoCloudTelemetry {
|
||||
return new HanzoCloudTelemetry(config);
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
/**
|
||||
* Telemetry integration for Hanzo AI
|
||||
* Provides distributed tracing, logging, and metrics for agent executions
|
||||
*/
|
||||
|
||||
import * as opentelemetry from '@opentelemetry/api';
|
||||
import { Span, SpanStatusCode, SpanKind } from '@opentelemetry/api';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export interface TelemetryConfig {
|
||||
serviceName?: string;
|
||||
enabled?: boolean;
|
||||
cloudUrl?: string;
|
||||
apiKey?: string;
|
||||
projectId?: string;
|
||||
sessionId?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SpanContext {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
traceFlags?: number;
|
||||
}
|
||||
|
||||
export interface TelemetryEvent {
|
||||
name: string;
|
||||
attributes?: Record<string, any>;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export class Telemetry extends EventEmitter {
|
||||
private tracer: opentelemetry.Tracer;
|
||||
private enabled: boolean;
|
||||
private config: TelemetryConfig;
|
||||
private activeSpans: Map<string, Span> = new Map();
|
||||
|
||||
constructor(config: TelemetryConfig = {}) {
|
||||
super();
|
||||
this.config = {
|
||||
serviceName: 'hanzo-ai',
|
||||
enabled: true,
|
||||
...config
|
||||
};
|
||||
|
||||
this.enabled = this.config.enabled ?? true;
|
||||
this.tracer = opentelemetry.trace.getTracer(
|
||||
this.config.serviceName || 'hanzo-ai',
|
||||
'1.0.0'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new span for tracing
|
||||
*/
|
||||
startSpan(
|
||||
name: string,
|
||||
options?: {
|
||||
kind?: SpanKind;
|
||||
attributes?: Record<string, any>;
|
||||
parent?: Span | SpanContext;
|
||||
}
|
||||
): Span {
|
||||
if (!this.enabled) {
|
||||
return opentelemetry.trace.getTracer('noop').startSpan('noop');
|
||||
}
|
||||
|
||||
const spanOptions: opentelemetry.SpanOptions = {
|
||||
kind: options?.kind || SpanKind.INTERNAL,
|
||||
attributes: {
|
||||
'service.name': this.config.serviceName,
|
||||
'hanzo.ai.version': '1.0.0',
|
||||
...this.config.metadata,
|
||||
...options?.attributes
|
||||
}
|
||||
};
|
||||
|
||||
// Handle parent span
|
||||
let context = opentelemetry.context.active();
|
||||
if (options?.parent) {
|
||||
if ('spanContext' in options.parent) {
|
||||
// It's a Span
|
||||
context = opentelemetry.trace.setSpan(context, options.parent);
|
||||
} else {
|
||||
// It's a SpanContext - need to create a parent span
|
||||
const parentSpan = this.tracer.startSpan('parent', {
|
||||
context: opentelemetry.trace.setSpanContext(
|
||||
opentelemetry.ROOT_CONTEXT,
|
||||
options.parent as SpanContext
|
||||
)
|
||||
});
|
||||
context = opentelemetry.trace.setSpan(context, parentSpan);
|
||||
}
|
||||
}
|
||||
|
||||
const span = this.tracer.startSpan(name, spanOptions, context);
|
||||
this.activeSpans.set(name, span);
|
||||
|
||||
// Add default attributes
|
||||
if (this.config.projectId) {
|
||||
span.setAttribute('hanzo.project.id', this.config.projectId);
|
||||
}
|
||||
if (this.config.sessionId) {
|
||||
span.setAttribute('hanzo.session.id', this.config.sessionId);
|
||||
}
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
/**
|
||||
* End a span
|
||||
*/
|
||||
endSpan(name: string, status?: { code: SpanStatusCode; message?: string }): void {
|
||||
const span = this.activeSpans.get(name);
|
||||
if (span) {
|
||||
if (status) {
|
||||
span.setStatus(status);
|
||||
}
|
||||
span.end();
|
||||
this.activeSpans.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace an async operation
|
||||
*/
|
||||
async trace<T>(
|
||||
name: string,
|
||||
fn: (span: Span) => Promise<T>,
|
||||
options?: {
|
||||
kind?: SpanKind;
|
||||
attributes?: Record<string, any>;
|
||||
}
|
||||
): Promise<T> {
|
||||
const span = this.startSpan(name, options);
|
||||
|
||||
try {
|
||||
const result = await fn(span);
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.recordException(error as Error, span);
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
this.activeSpans.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace a sync operation
|
||||
*/
|
||||
traceSync<T>(
|
||||
name: string,
|
||||
fn: (span: Span) => T,
|
||||
options?: {
|
||||
kind?: SpanKind;
|
||||
attributes?: Record<string, any>;
|
||||
}
|
||||
): T {
|
||||
const span = this.startSpan(name, options);
|
||||
|
||||
try {
|
||||
const result = fn(span);
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.recordException(error as Error, span);
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
this.activeSpans.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an exception in the current or specified span
|
||||
*/
|
||||
recordException(error: Error, span?: Span): void {
|
||||
const activeSpan = span || this.getCurrentSpan();
|
||||
if (!activeSpan) return;
|
||||
|
||||
activeSpan.recordException({
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
// Add error attributes for observability platforms
|
||||
activeSpan.setAttributes({
|
||||
'error.type': error.name,
|
||||
'error.message': error.message,
|
||||
'error.stack': error.stack
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an event in the current or specified span
|
||||
*/
|
||||
recordEvent(event: TelemetryEvent, span?: Span): void {
|
||||
const activeSpan = span || this.getCurrentSpan();
|
||||
if (!activeSpan) return;
|
||||
|
||||
activeSpan.addEvent(event.name, event.attributes, event.timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set attributes on the current or specified span
|
||||
*/
|
||||
setAttributes(attributes: Record<string, any>, span?: Span): void {
|
||||
const activeSpan = span || this.getCurrentSpan();
|
||||
if (!activeSpan) return;
|
||||
|
||||
activeSpan.setAttributes(attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active span
|
||||
*/
|
||||
getCurrentSpan(): Span | undefined {
|
||||
return opentelemetry.trace.getActiveSpan();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current trace context for propagation
|
||||
*/
|
||||
getTraceContext(): Record<string, string> {
|
||||
const span = this.getCurrentSpan();
|
||||
if (!span) return {};
|
||||
|
||||
const carrier: Record<string, string> = {};
|
||||
opentelemetry.propagation.inject(
|
||||
opentelemetry.trace.setSpan(opentelemetry.context.active(), span),
|
||||
carrier
|
||||
);
|
||||
|
||||
return carrier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child telemetry instance with inherited context
|
||||
*/
|
||||
createChild(name: string, attributes?: Record<string, any>): Telemetry {
|
||||
return new Telemetry({
|
||||
...this.config,
|
||||
metadata: {
|
||||
...this.config.metadata,
|
||||
...attributes,
|
||||
parent: name
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record metrics
|
||||
*/
|
||||
recordMetric(
|
||||
name: string,
|
||||
value: number,
|
||||
type: 'counter' | 'gauge' | 'histogram' = 'gauge',
|
||||
tags?: Record<string, string | number>
|
||||
): void {
|
||||
if (!this.enabled) return;
|
||||
|
||||
// Emit metric event for collection
|
||||
this.emit('metric', {
|
||||
name,
|
||||
value,
|
||||
type,
|
||||
tags: {
|
||||
...this.config.metadata,
|
||||
...tags
|
||||
},
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment a counter metric
|
||||
*/
|
||||
increment(name: string, value: number = 1, tags?: Record<string, string | number>): void {
|
||||
this.recordMetric(name, value, 'counter', tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a gauge metric
|
||||
*/
|
||||
gauge(name: string, value: number, tags?: Record<string, string | number>): void {
|
||||
this.recordMetric(name, value, 'gauge', tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a histogram metric
|
||||
*/
|
||||
histogram(name: string, value: number, tags?: Record<string, string | number>): void {
|
||||
this.recordMetric(name, value, 'histogram', tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all pending telemetry data
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
// End all active spans
|
||||
for (const [name, span] of this.activeSpans) {
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
span.end();
|
||||
}
|
||||
this.activeSpans.clear();
|
||||
|
||||
// Emit flush event for collectors
|
||||
this.emit('flush');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown telemetry
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
await this.flush();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Global telemetry instance
|
||||
let globalTelemetry: Telemetry | undefined;
|
||||
|
||||
/**
|
||||
* Get or create the global telemetry instance
|
||||
*/
|
||||
export function getTelemetry(config?: TelemetryConfig): Telemetry {
|
||||
if (!globalTelemetry) {
|
||||
globalTelemetry = new Telemetry(config);
|
||||
}
|
||||
return globalTelemetry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the global telemetry instance
|
||||
*/
|
||||
export function setTelemetry(telemetry: Telemetry): void {
|
||||
globalTelemetry = telemetry;
|
||||
}
|
||||
|
||||
// Export types
|
||||
export { Span, SpanStatusCode, SpanKind } from '@opentelemetry/api';
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Core types for @hanzo/ai
|
||||
*/
|
||||
|
||||
export interface ModelInterface {
|
||||
name?: string;
|
||||
complete(params: CompletionParams): Promise<CompletionResponse>;
|
||||
stream(params: CompletionParams): AsyncIterableIterator<StreamChunk>;
|
||||
}
|
||||
|
||||
export interface CompletionParams {
|
||||
messages: Message[];
|
||||
tools?: Tool[];
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export interface CompletionResponse {
|
||||
content?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
usage?: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StreamChunk {
|
||||
type: 'content' | 'tool_call' | 'done';
|
||||
content?: string;
|
||||
toolCall?: ToolCall;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
toolResults?: ToolResult[];
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: any; // JSON Schema
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: any;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
id: string;
|
||||
result?: any;
|
||||
error?: string;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
/**
|
||||
* Creates a unique completion ID
|
||||
*/
|
||||
export function createCompletionId(): string {
|
||||
return `cmpl-${nanoid()}`;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { z, ZodSchema } from 'zod';
|
||||
|
||||
/**
|
||||
* Validates data against a schema
|
||||
*/
|
||||
export function validateSchema<T>(
|
||||
schema: ZodSchema<T>,
|
||||
data: unknown
|
||||
): { success: true; data: T } | { success: false; error: z.ZodError } {
|
||||
const result = schema.safeParse(data);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
} else {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { createParser, ParsedEvent } from 'eventsource-parser';
|
||||
|
||||
export interface StreamPart {
|
||||
type: 'text' | 'function_call' | 'tool_calls' | 'data' | 'error' | 'done';
|
||||
value: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a stream part from a server-sent event
|
||||
*/
|
||||
export function parseStreamPart(data: string): StreamPart | null {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event source parser
|
||||
*/
|
||||
export function createEventSourceParser(onParse: (event: ParsedEvent) => void) {
|
||||
return createParser(onParse);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
index: 'src/index.ts',
|
||||
'server/index': 'src/server/index.ts',
|
||||
},
|
||||
format: ['cjs', 'esm'],
|
||||
dts: false, // Temporarily disable type generation - needs fixing
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: ['react'],
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'dist/',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
'**/mockData.ts',
|
||||
'**/__tests__/**'
|
||||
]
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@hanzo/auth",
|
||||
"version": "1.0.0",
|
||||
"description": "The one Hanzo IAM auth core — canonical HIP-0111 OAuth2 + PKCE, shared by every Hanzo extension (browser, office, vscode, cli, mcp).",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// The ONE definition of Hanzo IAM endpoints and the per-surface OAuth client
|
||||
// registry. Every Hanzo extension (browser, office, vscode, cli, mcp) resolves
|
||||
// its auth URLs HERE — so a path can never drift between five hand-rolled
|
||||
// copies again (the vscode add-in shipped `hanzo.id/oauth/authorize`, missing
|
||||
// the /v1/iam prefix, for exactly that reason).
|
||||
//
|
||||
// HIP-0111: the canonical OAuth2/OIDC surface is `${issuer}/v1/iam/oauth/*`.
|
||||
// The login UI (authorize) lives on hanzo.id; the token/userinfo API on
|
||||
// iam.hanzo.ai. Both carry the SAME /v1/iam prefix — that is the whole point.
|
||||
|
||||
export const IAM_LOGIN_URL = 'https://hanzo.id';
|
||||
export const IAM_API_URL = 'https://iam.hanzo.ai';
|
||||
export const IAM_API_PATH = '/v1/iam';
|
||||
export const DEFAULT_SCOPES = 'openid profile email offline_access';
|
||||
|
||||
// SurfaceClient is one registered IAM OAuth client — one per product surface, so
|
||||
// tokens, redirect URIs, and audit trails are attributable to the surface that
|
||||
// minted them. redirectUri is where IAM sends the code back; it must be
|
||||
// registered on the IAM client of the same id. Adding a surface is ONE entry
|
||||
// here, never a new copy of the flow.
|
||||
export interface SurfaceClient {
|
||||
id: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
// CLIENTS is the registry. The ids match the applications seeded in Hanzo IAM
|
||||
// (<org>-<app> convention). redirectUris match what those clients allow.
|
||||
export const CLIENTS = {
|
||||
browser: { id: 'hanzo-browser', redirectUri: 'https://hanzo.ai/callback' },
|
||||
office: { id: 'hanzo-office', redirectUri: 'https://office.hanzo.ai/auth-callback.html' },
|
||||
vscode: { id: 'hanzo-vscode', redirectUri: 'https://hanzo.ai/callback' },
|
||||
cli: { id: 'hanzo-cli', redirectUri: 'http://127.0.0.1/callback' },
|
||||
} as const satisfies Record<string, SurfaceClient>;
|
||||
|
||||
export type SurfaceName = keyof typeof CLIENTS;
|
||||
|
||||
// URL builders — the only place these paths are assembled. issuer defaults to
|
||||
// the login host for authorize and the API host for token/userinfo, but both
|
||||
// are overridable (self-hosted brands, tests) via the opts.
|
||||
|
||||
export function authorizeURL(issuer: string = IAM_LOGIN_URL): string {
|
||||
return `${trimEnd(issuer)}${IAM_API_PATH}/oauth/authorize`;
|
||||
}
|
||||
export function tokenURL(issuer: string = IAM_API_URL): string {
|
||||
return `${trimEnd(issuer)}${IAM_API_PATH}/oauth/token`;
|
||||
}
|
||||
export function userinfoURL(issuer: string = IAM_API_URL): string {
|
||||
return `${trimEnd(issuer)}${IAM_API_PATH}/oauth/userinfo`;
|
||||
}
|
||||
|
||||
function trimEnd(s: string): string {
|
||||
return s.replace(/\/+$/, '');
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// The auth FLOW — written exactly once, against TokenStore + Opener. This is the
|
||||
// decomplected heart: browser / office / vscode / cli call these functions with
|
||||
// their own adapter and get identical login, refresh, and session semantics. No
|
||||
// surface re-implements PKCE, endpoint assembly, code exchange, or refresh.
|
||||
|
||||
import { createPkce } from './pkce.js';
|
||||
import {
|
||||
buildAuthorizeURL, exchangeCode, refreshTokens, fetchUserInfo, parseCallback,
|
||||
type UserInfo,
|
||||
} from './oauth.js';
|
||||
import type { TokenStore, TokenSet, Opener } from './types.js';
|
||||
|
||||
export interface AuthClient {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
scope?: string;
|
||||
loginIssuer?: string;
|
||||
apiIssuer?: string;
|
||||
}
|
||||
|
||||
// EXPIRY_SKEW_MS refreshes a token slightly before it truly expires, so a call
|
||||
// never races the deadline.
|
||||
const EXPIRY_SKEW_MS = 60_000;
|
||||
|
||||
// login runs the full interactive authorization-code + PKCE flow and persists
|
||||
// the tokens. Returns the TokenSet. Throws on user-cancel, state mismatch, an
|
||||
// IAM error param, or a failed exchange — the surface shows the message.
|
||||
export async function login(client: AuthClient, store: TokenStore, opener: Opener): Promise<TokenSet> {
|
||||
const pkce = await createPkce();
|
||||
const authorizeUrl = buildAuthorizeURL({
|
||||
clientId: client.clientId,
|
||||
redirectUri: client.redirectUri,
|
||||
challenge: pkce.challenge,
|
||||
state: pkce.state,
|
||||
scope: client.scope,
|
||||
loginIssuer: client.loginIssuer,
|
||||
});
|
||||
|
||||
const redirectUrl = await opener.open(authorizeUrl, client.redirectUri);
|
||||
const cb = parseCallback(redirectUrl);
|
||||
if (cb.error) throw new Error(`sign-in failed: ${cb.error}`);
|
||||
if (!cb.code) throw new Error('sign-in returned no authorization code');
|
||||
if (cb.state !== pkce.state) throw new Error('sign-in state mismatch (possible CSRF) — try again');
|
||||
|
||||
const tokens = await exchangeCode({
|
||||
clientId: client.clientId,
|
||||
code: cb.code,
|
||||
verifier: pkce.verifier,
|
||||
redirectUri: client.redirectUri,
|
||||
apiIssuer: client.apiIssuer,
|
||||
});
|
||||
await store.set(tokens);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// isExpired reports whether a TokenSet is at/near its deadline. A token with no
|
||||
// expiresAt is treated as non-expiring (the surface still validates server-side
|
||||
// on 401). Pure.
|
||||
export function isExpired(tokens: TokenSet | null, now: number = Date.now()): boolean {
|
||||
if (!tokens) return true;
|
||||
if (tokens.expiresAt === undefined) return false;
|
||||
return now >= tokens.expiresAt - EXPIRY_SKEW_MS;
|
||||
}
|
||||
|
||||
// getValidToken returns a usable access token, transparently refreshing an
|
||||
// expired one when a refresh_token is present. Returns '' when there is no
|
||||
// session (never signed in) or the refresh failed — the caller then either runs
|
||||
// login() or proceeds anonymously. This is the ONE function every surface calls
|
||||
// before an authenticated request.
|
||||
export async function getValidToken(client: AuthClient, store: TokenStore): Promise<string> {
|
||||
const current = await store.get();
|
||||
if (!current) return '';
|
||||
if (!isExpired(current)) return current.accessToken;
|
||||
if (!current.refreshToken) return current.accessToken; // no refresh path; let the server 401
|
||||
try {
|
||||
const next = await refreshTokens({
|
||||
clientId: client.clientId,
|
||||
refreshToken: current.refreshToken,
|
||||
apiIssuer: client.apiIssuer,
|
||||
});
|
||||
await store.set(next);
|
||||
return next.accessToken;
|
||||
} catch {
|
||||
return ''; // refresh failed (revoked/expired) — surface re-prompts login
|
||||
}
|
||||
}
|
||||
|
||||
// logout clears the stored session.
|
||||
export async function logout(store: TokenStore): Promise<void> {
|
||||
await store.clear();
|
||||
}
|
||||
|
||||
// currentUser returns the signed-in identity for onboarding UIs, or null when
|
||||
// there is no valid session. Refreshes if needed.
|
||||
export async function currentUser(client: AuthClient, store: TokenStore): Promise<UserInfo | null> {
|
||||
const token = await getValidToken(client, store);
|
||||
if (!token) return null;
|
||||
try {
|
||||
return await fetchUserInfo(token, client.apiIssuer);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// @hanzo/auth — the ONE Hanzo IAM auth core. Every extension (browser, office,
|
||||
// vscode, cli, mcp) imports from here: canonical HIP-0111 endpoints, PKCE, the
|
||||
// OAuth2 authorization-code + PKCE flow, refresh, and userinfo. A surface
|
||||
// supplies only a TokenStore (persistence) and an Opener (the interactive leg);
|
||||
// the flow itself lives once, in flow.ts.
|
||||
|
||||
export * from './config.js';
|
||||
export * from './pkce.js';
|
||||
export * from './oauth.js';
|
||||
export * from './flow.js';
|
||||
export type { TokenSet, TokenStore, Opener } from './types.js';
|
||||
|
||||
// Convenience: resolve a surface's AuthClient from the CLIENTS registry so a
|
||||
// caller writes `authClientFor('office')` instead of assembling ids/redirects.
|
||||
import { CLIENTS, type SurfaceName } from './config.js';
|
||||
import type { AuthClient } from './flow.js';
|
||||
|
||||
export function authClientFor(surface: SurfaceName, overrides: Partial<AuthClient> = {}): AuthClient {
|
||||
const c = CLIENTS[surface];
|
||||
return { clientId: c.id, redirectUri: c.redirectUri, ...overrides };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// The OAuth2/OIDC HTTP calls — pure functions over `fetch`. No storage, no UI,
|
||||
// no runtime specifics. Every authorize URL and token exchange in the Hanzo
|
||||
// extension family is built HERE, against config.ts, so HIP-0111 paths are
|
||||
// assembled exactly once.
|
||||
|
||||
import { authorizeURL, tokenURL, userinfoURL, DEFAULT_SCOPES, IAM_LOGIN_URL, IAM_API_URL } from './config.js';
|
||||
import type { TokenSet } from './types.js';
|
||||
|
||||
export interface AuthorizeParams {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
challenge: string;
|
||||
state: string;
|
||||
scope?: string;
|
||||
loginIssuer?: string;
|
||||
}
|
||||
|
||||
// buildAuthorizeURL assembles the authorization-code + PKCE (S256) URL.
|
||||
export function buildAuthorizeURL(p: AuthorizeParams): string {
|
||||
const q = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: p.clientId,
|
||||
redirect_uri: p.redirectUri,
|
||||
scope: p.scope || DEFAULT_SCOPES,
|
||||
state: p.state,
|
||||
code_challenge: p.challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
return `${authorizeURL(p.loginIssuer || IAM_LOGIN_URL)}?${q.toString()}`;
|
||||
}
|
||||
|
||||
// normalizeTokens converts an OIDC token response into our TokenSet, turning the
|
||||
// relative expires_in into an absolute expiresAt deadline (now-based).
|
||||
export function normalizeTokens(raw: any, now: number = Date.now()): TokenSet {
|
||||
if (!raw || typeof raw.access_token !== 'string' || !raw.access_token) {
|
||||
throw new Error('token response missing access_token');
|
||||
}
|
||||
const set: TokenSet = { accessToken: raw.access_token };
|
||||
if (raw.refresh_token) set.refreshToken = raw.refresh_token;
|
||||
if (raw.id_token) set.idToken = raw.id_token;
|
||||
if (raw.token_type) set.tokenType = raw.token_type;
|
||||
if (raw.scope) set.scope = raw.scope;
|
||||
if (typeof raw.expires_in === 'number') set.expiresAt = now + raw.expires_in * 1000;
|
||||
return set;
|
||||
}
|
||||
|
||||
// exchangeCode trades an authorization code for tokens (RFC 6749 §4.1.3 +
|
||||
// PKCE). Form-encoded per the spec — application/json is NOT accepted by IAM.
|
||||
export async function exchangeCode(args: {
|
||||
clientId: string;
|
||||
code: string;
|
||||
verifier: string;
|
||||
redirectUri: string;
|
||||
apiIssuer?: string;
|
||||
}): Promise<TokenSet> {
|
||||
const resp = await fetch(tokenURL(args.apiIssuer || IAM_API_URL), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: args.clientId,
|
||||
code: args.code,
|
||||
code_verifier: args.verifier,
|
||||
redirect_uri: args.redirectUri,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`token exchange failed: ${resp.status} ${await safeText(resp)}`);
|
||||
}
|
||||
return normalizeTokens(await resp.json());
|
||||
}
|
||||
|
||||
// refreshTokens uses a refresh_token to mint a fresh access token. IAM may or
|
||||
// may not rotate the refresh_token; if it omits one we keep the old.
|
||||
export async function refreshTokens(args: {
|
||||
clientId: string;
|
||||
refreshToken: string;
|
||||
apiIssuer?: string;
|
||||
}): Promise<TokenSet> {
|
||||
const resp = await fetch(tokenURL(args.apiIssuer || IAM_API_URL), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: args.clientId,
|
||||
refresh_token: args.refreshToken,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`refresh failed: ${resp.status} ${await safeText(resp)}`);
|
||||
}
|
||||
const set = normalizeTokens(await resp.json());
|
||||
if (!set.refreshToken) set.refreshToken = args.refreshToken; // reuse when not rotated
|
||||
return set;
|
||||
}
|
||||
|
||||
// UserInfo is the OIDC userinfo shape the surfaces render for onboarding.
|
||||
export interface UserInfo {
|
||||
sub: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
org?: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
export async function fetchUserInfo(accessToken: string, apiIssuer?: string): Promise<UserInfo> {
|
||||
const resp = await fetch(userinfoURL(apiIssuer || IAM_API_URL), {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!resp.ok) throw new Error(`userinfo failed: ${resp.status}`);
|
||||
const j = await resp.json();
|
||||
return { sub: j.sub, name: j.name, email: j.email, org: j.org || j.owner, picture: j.picture };
|
||||
}
|
||||
|
||||
// parseCallback extracts { code, state, error } from the redirect URL IAM lands
|
||||
// on (the Opener resolves this). Pure.
|
||||
export function parseCallback(redirectUrl: string): { code?: string; state?: string; error?: string } {
|
||||
const qi = redirectUrl.indexOf('?');
|
||||
const params = new URLSearchParams(qi >= 0 ? redirectUrl.slice(qi + 1) : '');
|
||||
return {
|
||||
code: params.get('code') || undefined,
|
||||
state: params.get('state') || undefined,
|
||||
error: params.get('error') || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function safeText(resp: Response): Promise<string> {
|
||||
try {
|
||||
return (await resp.text()).slice(0, 200);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// PKCE (RFC 7636) — pure crypto over the platform CSPRNG + WebCrypto, so it runs
|
||||
// identically in a browser extension, an Office task pane, VS Code's extension
|
||||
// host, and Node. The ONE PKCE implementation for every Hanzo surface.
|
||||
|
||||
export function base64url(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
const b64 = typeof btoa === 'function' ? btoa(bin) : Buffer.from(bytes).toString('base64');
|
||||
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function randomString(bytes = 32): string {
|
||||
const buf = new Uint8Array(bytes);
|
||||
(globalThis.crypto as Crypto).getRandomValues(buf);
|
||||
return base64url(buf);
|
||||
}
|
||||
|
||||
// challengeFromVerifier = BASE64URL(SHA256(verifier)) — the S256 method.
|
||||
export async function challengeFromVerifier(verifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await (globalThis.crypto as Crypto).subtle.digest('SHA-256', data);
|
||||
return base64url(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
export interface Pkce {
|
||||
verifier: string;
|
||||
challenge: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export async function createPkce(): Promise<Pkce> {
|
||||
const verifier = randomString(32);
|
||||
const challenge = await challengeFromVerifier(verifier);
|
||||
const state = randomString(16);
|
||||
return { verifier, challenge, state };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// The runtime-agnostic contracts a surface implements. The flow (flow.ts) is
|
||||
// written ONCE against these; browser / office / vscode / cli differ only in the
|
||||
// adapter they pass. This is what makes "one auth, many surfaces" real.
|
||||
|
||||
// TokenSet is the persisted credential — the OIDC token response, normalized.
|
||||
export interface TokenSet {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
idToken?: string;
|
||||
// expiresAt is an absolute epoch-ms deadline (NOT the relative expires_in),
|
||||
// so validity is a pure comparison with the clock, storable and portable.
|
||||
expiresAt?: number;
|
||||
tokenType?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
// TokenStore is the per-surface persistence: chrome.storage (browser),
|
||||
// SecretStorage (vscode), roamingSettings (office), a 0600 file (cli). Async so
|
||||
// every backend fits. clear() removes the credential (logout).
|
||||
export interface TokenStore {
|
||||
get(): Promise<TokenSet | null>;
|
||||
set(tokens: TokenSet): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
// Opener drives the interactive leg: open the authorize URL and resolve with the
|
||||
// full redirect URL IAM lands on (carrying ?code=&state=). Browser: a tab +
|
||||
// tabs.onUpdated. Office: the Dialog API. VS Code: env.openExternal + a Uri
|
||||
// handler. CLI: a loopback HTTP server. The flow never knows which.
|
||||
export interface Opener {
|
||||
open(authorizeUrl: string, redirectUri: string): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { authorizeURL, tokenURL, userinfoURL, CLIENTS, IAM_API_PATH } from '../src/config';
|
||||
import { authClientFor } from '../src/index';
|
||||
|
||||
describe('canonical HIP-0111 endpoints — the drift guard', () => {
|
||||
it('authorize/token/userinfo ALL carry the /v1/iam prefix', () => {
|
||||
// This is the exact bug that shipped in vscode: hanzo.id/oauth/authorize,
|
||||
// missing /v1/iam. Pin every path so it can never regress.
|
||||
expect(authorizeURL()).toBe('https://hanzo.id/v1/iam/oauth/authorize');
|
||||
expect(tokenURL()).toBe('https://iam.hanzo.ai/v1/iam/oauth/token');
|
||||
expect(userinfoURL()).toBe('https://iam.hanzo.ai/v1/iam/oauth/userinfo');
|
||||
expect(IAM_API_PATH).toBe('/v1/iam');
|
||||
});
|
||||
|
||||
it('a custom issuer keeps the /v1/iam prefix', () => {
|
||||
expect(authorizeURL('https://id.acme.test')).toBe('https://id.acme.test/v1/iam/oauth/authorize');
|
||||
expect(tokenURL('https://iam.acme.test/')).toBe('https://iam.acme.test/v1/iam/oauth/token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('surface client registry — one client per surface', () => {
|
||||
it('every surface has a distinct hanzo-<surface> id', () => {
|
||||
expect(CLIENTS.browser.id).toBe('hanzo-browser');
|
||||
expect(CLIENTS.office.id).toBe('hanzo-office');
|
||||
expect(CLIENTS.vscode.id).toBe('hanzo-vscode');
|
||||
expect(CLIENTS.cli.id).toBe('hanzo-cli');
|
||||
const ids = Object.values(CLIENTS).map((c) => c.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('authClientFor resolves id + redirect and honors overrides', () => {
|
||||
const c = authClientFor('office');
|
||||
expect(c.clientId).toBe('hanzo-office');
|
||||
expect(c.redirectUri).toBe('https://office.hanzo.ai/auth-callback.html');
|
||||
expect(authClientFor('office', { redirectUri: 'https://localhost:3000/cb' }).redirectUri).toBe(
|
||||
'https://localhost:3000/cb',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { login, getValidToken, logout, isExpired, currentUser } from '../src/flow';
|
||||
import type { TokenStore, TokenSet, Opener } from '../src/types';
|
||||
import { authClientFor } from '../src/index';
|
||||
|
||||
// memStore is the fake per-surface TokenStore — the same seam browser/office/
|
||||
// vscode/cli implement over their real storage.
|
||||
function memStore(initial: TokenSet | null = null): TokenStore & { value: TokenSet | null } {
|
||||
return {
|
||||
value: initial,
|
||||
async get() { return this.value; },
|
||||
async set(t) { this.value = t; },
|
||||
async clear() { this.value = null; },
|
||||
};
|
||||
}
|
||||
|
||||
// fakeOpener returns a canned redirect URL (what IAM would land on).
|
||||
function fakeOpener(redirectUrl: (authorizeUrl: string) => string): Opener {
|
||||
return { async open(authorizeUrl) { return redirectUrl(authorizeUrl); } };
|
||||
}
|
||||
|
||||
const client = authClientFor('office');
|
||||
|
||||
describe('login', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('runs PKCE → authorize → exchange → store', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 }) } as any)));
|
||||
const store = memStore();
|
||||
// Echo the state from the authorize URL back in the redirect so it matches.
|
||||
const opener = fakeOpener((authUrl) => {
|
||||
const state = new URL(authUrl).searchParams.get('state');
|
||||
return `https://office.hanzo.ai/auth-callback.html?code=CODE&state=${state}`;
|
||||
});
|
||||
const tokens = await login(client, store, opener);
|
||||
expect(tokens.accessToken).toBe('AT');
|
||||
expect(store.value?.accessToken).toBe('AT');
|
||||
});
|
||||
|
||||
it('rejects a state mismatch (CSRF guard)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
const store = memStore();
|
||||
const opener = fakeOpener(() => 'https://office.hanzo.ai/auth-callback.html?code=CODE&state=WRONG');
|
||||
await expect(login(client, store, opener)).rejects.toThrow(/state mismatch/);
|
||||
expect(store.value).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces an IAM error param', async () => {
|
||||
const store = memStore();
|
||||
const opener = fakeOpener(() => 'https://office.hanzo.ai/auth-callback.html?error=access_denied');
|
||||
await expect(login(client, store, opener)).rejects.toThrow(/access_denied/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpired', () => {
|
||||
it('null / past-deadline is expired; no-deadline is not', () => {
|
||||
expect(isExpired(null)).toBe(true);
|
||||
expect(isExpired({ accessToken: 'a' })).toBe(false);
|
||||
expect(isExpired({ accessToken: 'a', expiresAt: 1000 }, 999_999)).toBe(true);
|
||||
expect(isExpired({ accessToken: 'a', expiresAt: 10_000_000 }, 1000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidToken', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('returns the token when fresh (no network)', async () => {
|
||||
const store = memStore({ accessToken: 'FRESH', expiresAt: Date.now() + 3600_000 });
|
||||
expect(await getValidToken(client, store)).toBe('FRESH');
|
||||
});
|
||||
|
||||
it('refreshes transparently when expired', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ access_token: 'NEW', expires_in: 3600 }) } as any)));
|
||||
const store = memStore({ accessToken: 'OLD', refreshToken: 'RT', expiresAt: Date.now() - 1 });
|
||||
expect(await getValidToken(client, store)).toBe('NEW');
|
||||
expect(store.value?.accessToken).toBe('NEW');
|
||||
expect(store.value?.refreshToken).toBe('RT'); // reused
|
||||
});
|
||||
|
||||
it('returns empty when refresh fails (revoked) so the surface re-prompts', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 401, text: async () => 'invalid_grant' } as any)));
|
||||
const store = memStore({ accessToken: 'OLD', refreshToken: 'RT', expiresAt: Date.now() - 1 });
|
||||
expect(await getValidToken(client, store)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty when never signed in', async () => {
|
||||
expect(await getValidToken(client, memStore())).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout + currentUser', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('logout clears the store', async () => {
|
||||
const store = memStore({ accessToken: 'a' });
|
||||
await logout(store);
|
||||
expect(store.value).toBeNull();
|
||||
});
|
||||
|
||||
it('currentUser returns null with no session, identity with one', async () => {
|
||||
expect(await currentUser(client, memStore())).toBeNull();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ sub: 'u1', email: 'z@hanzo.ai' }) } as any)));
|
||||
const u = await currentUser(client, memStore({ accessToken: 'AT', expiresAt: Date.now() + 3600_000 }));
|
||||
expect(u?.email).toBe('z@hanzo.ai');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
buildAuthorizeURL, normalizeTokens, exchangeCode, refreshTokens, parseCallback, fetchUserInfo,
|
||||
} from '../src/oauth';
|
||||
import { challengeFromVerifier } from '../src/pkce';
|
||||
|
||||
describe('buildAuthorizeURL', () => {
|
||||
it('is the HIP-0111 authorize URL with PKCE S256', () => {
|
||||
const u = new URL(buildAuthorizeURL({
|
||||
clientId: 'hanzo-office', redirectUri: 'https://office.hanzo.ai/auth-callback.html',
|
||||
challenge: 'CHAL', state: 'STATE',
|
||||
}));
|
||||
expect(u.origin + u.pathname).toBe('https://hanzo.id/v1/iam/oauth/authorize');
|
||||
expect(u.searchParams.get('response_type')).toBe('code');
|
||||
expect(u.searchParams.get('client_id')).toBe('hanzo-office');
|
||||
expect(u.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(u.searchParams.get('code_challenge')).toBe('CHAL');
|
||||
expect(u.searchParams.get('state')).toBe('STATE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeTokens', () => {
|
||||
it('turns expires_in into an absolute expiresAt', () => {
|
||||
const t = normalizeTokens({ access_token: 'a', refresh_token: 'r', expires_in: 3600 }, 1_000_000);
|
||||
expect(t.accessToken).toBe('a');
|
||||
expect(t.refreshToken).toBe('r');
|
||||
expect(t.expiresAt).toBe(1_000_000 + 3600_000);
|
||||
});
|
||||
it('throws without an access_token', () => {
|
||||
expect(() => normalizeTokens({})).toThrow(/access_token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCallback', () => {
|
||||
it('extracts code/state/error from a redirect URL', () => {
|
||||
expect(parseCallback('https://x/cb?code=abc&state=xyz')).toEqual({ code: 'abc', state: 'xyz', error: undefined });
|
||||
expect(parseCallback('https://x/cb?error=access_denied').error).toBe('access_denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangeCode + refreshTokens (form-encoded, HIP-0111)', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('POSTs form-urlencoded to the /v1/iam token URL and normalizes', async () => {
|
||||
const fetchMock = vi.fn(async (url: string, init: any) => {
|
||||
expect(url).toBe('https://iam.hanzo.ai/v1/iam/oauth/token');
|
||||
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||
const body = new URLSearchParams(init.body.toString());
|
||||
expect(body.get('grant_type')).toBe('authorization_code');
|
||||
expect(body.get('code_verifier')).toBe('ver');
|
||||
return { ok: true, json: async () => ({ access_token: 'AT', refresh_token: 'RT', expires_in: 60 }) } as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const t = await exchangeCode({ clientId: 'hanzo-vscode', code: 'c', verifier: 'ver', redirectUri: 'https://hanzo.ai/callback' });
|
||||
expect(t.accessToken).toBe('AT');
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('refresh reuses the old refresh_token when IAM does not rotate it', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ access_token: 'AT2', expires_in: 60 }) } as any)));
|
||||
const t = await refreshTokens({ clientId: 'hanzo-cli', refreshToken: 'OLD' });
|
||||
expect(t.accessToken).toBe('AT2');
|
||||
expect(t.refreshToken).toBe('OLD');
|
||||
});
|
||||
|
||||
it('surfaces a failed exchange', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 400, text: async () => 'invalid_grant' } as any)));
|
||||
await expect(exchangeCode({ clientId: 'x', code: 'c', verifier: 'v', redirectUri: 'r' })).rejects.toThrow(/400/);
|
||||
});
|
||||
|
||||
it('userinfo reads sub/email/org', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => {
|
||||
expect(url).toBe('https://iam.hanzo.ai/v1/iam/oauth/userinfo');
|
||||
expect(init.headers.Authorization).toBe('Bearer AT');
|
||||
return { ok: true, json: async () => ({ sub: 'u1', email: 'z@hanzo.ai', owner: 'acme' }) } as any;
|
||||
}));
|
||||
const u = await fetchUserInfo('AT');
|
||||
expect(u).toEqual({ sub: 'u1', name: undefined, email: 'z@hanzo.ai', org: 'acme', picture: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PKCE round-trips with the S256 challenge', () => {
|
||||
it('RFC 7636 test vector', async () => {
|
||||
expect(await challengeFromVerifier('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')).toBe(
|
||||
'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -3,16 +3,16 @@ import { test, expect } from './fixtures';
|
||||
/**
|
||||
* End-to-end tests for the login + chat flow.
|
||||
*
|
||||
* These tests exercise the real Casdoor OAuth2 flow using the z@hanzo.ai
|
||||
* These tests exercise the real Hanzo IAM OAuth2 flow using the z@hanzo.ai
|
||||
* test account. They verify:
|
||||
* 1. Login tab opens correctly
|
||||
* 2. Password auth succeeds
|
||||
* 3. Token is stored and user info is fetched
|
||||
* 4. Chat sends a message to a Zen model via llm.hanzo.ai
|
||||
* 4. Chat sends a message to a Zen model via api.hanzo.ai
|
||||
*
|
||||
* Requirements:
|
||||
* - IAM at iam.hanzo.ai / hanzo.id must be reachable
|
||||
* - LLM gateway at llm.hanzo.ai must be reachable
|
||||
* - LLM gateway at api.hanzo.ai must be reachable
|
||||
* - Extension must be built: npm run build
|
||||
*/
|
||||
|
||||
@@ -40,13 +40,13 @@ descFn('Auth + Chat', () => {
|
||||
sidebar.locator('#auth-btn').click(),
|
||||
]);
|
||||
|
||||
// Wait for the Casdoor login page to load
|
||||
// Wait for the Hanzo IAM login page to load
|
||||
await authPage.waitForLoadState('networkidle');
|
||||
const authUrl = authPage.url();
|
||||
expect(authUrl).toContain('hanzo.id');
|
||||
|
||||
// Fill in credentials on the Casdoor login page
|
||||
// Casdoor uses a form with username + password inputs
|
||||
// Fill in credentials on the Hanzo IAM login page
|
||||
// IAM uses a form with username + password inputs
|
||||
const usernameInput = authPage.locator('input[name="username"], input#input');
|
||||
const passwordInput = authPage.locator('input[name="password"], input[type="password"]');
|
||||
|
||||
@@ -64,8 +64,8 @@ descFn('Auth + Chat', () => {
|
||||
// Tab might not close if redirect is to a real page
|
||||
});
|
||||
} else {
|
||||
// If no form visible, Casdoor might have a different UI — skip
|
||||
test.skip(true, 'Casdoor login form not found');
|
||||
// If no form visible, IAM might have a different UI — skip
|
||||
test.skip(true, 'IAM login form not found');
|
||||
}
|
||||
|
||||
// Back on sidebar — verify auth state
|
||||
@@ -114,7 +114,7 @@ descFn('Auth + Chat', () => {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
application: 'app-hanzo',
|
||||
application: 'hanzo-browser',
|
||||
organization: 'hanzo',
|
||||
username: 'z',
|
||||
password: 'IloveHanzo2026!!!',
|
||||
|
||||
@@ -119,12 +119,16 @@ test.describe('Popup Tools', () => {
|
||||
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
await popup.waitForTimeout(500);
|
||||
|
||||
// #tool-picker resolves the active tab and chrome.tabs.sendMessage's it
|
||||
// tool.picker.start. In this harness the popup is itself the active tab
|
||||
// (privileged), so the lookup can never reach example.com — send the
|
||||
// same message the button sends, explicitly targeted.
|
||||
const pickerBtn = popup.locator('#tool-picker');
|
||||
await expect(pickerBtn).toBeVisible();
|
||||
await pickerBtn.click();
|
||||
|
||||
// Wait for message to propagate before popup closes
|
||||
await popup.waitForTimeout(800);
|
||||
await popup.evaluate(async () => {
|
||||
const tabs = await chrome.tabs.query({ url: 'https://example.com/' });
|
||||
await chrome.tabs.sendMessage(tabs[0]!.id!, { action: 'tool.picker.start' });
|
||||
});
|
||||
|
||||
// Switch to target and wait for picker to mount
|
||||
await target.bringToFront();
|
||||
@@ -155,12 +159,14 @@ test.describe('Popup Tools', () => {
|
||||
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
await popup.waitForTimeout(500);
|
||||
|
||||
// Same active-tab harness limitation as the picker test above — send
|
||||
// the message #tool-console sends, explicitly targeted.
|
||||
const consoleBtn = popup.locator('#tool-console');
|
||||
await expect(consoleBtn).toBeVisible();
|
||||
await consoleBtn.click();
|
||||
|
||||
// Wait for message to propagate before popup closes
|
||||
await popup.waitForTimeout(800);
|
||||
await popup.evaluate(async () => {
|
||||
const tabs = await chrome.tabs.query({ url: 'https://example.com/' });
|
||||
await chrome.tabs.sendMessage(tabs[0]!.id!, { action: 'tool.console.start' });
|
||||
});
|
||||
|
||||
await target.bringToFront();
|
||||
await target.waitForTimeout(1000);
|
||||
@@ -194,7 +200,7 @@ test.describe('Popup Tools', () => {
|
||||
|
||||
const openPanel = page.locator('#open-panel');
|
||||
await expect(openPanel).toBeVisible();
|
||||
await expect(openPanel).toContainText('Open Chat Panel');
|
||||
await expect(openPanel).toContainText('Open Chat');
|
||||
await expect(openPanel).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,13 +78,16 @@ test.describe('Popup', () => {
|
||||
await expect(openPanel).toBeAttached();
|
||||
});
|
||||
|
||||
test('on-page overlay toggle button exists', async ({ context, extensionId }) => {
|
||||
test('on-page overlay toggle exists', async ({ context, extensionId }) => {
|
||||
// 1.9.3 killed the on-page FAB (#open-page-overlay). The on-page surface
|
||||
// is now controlled by the #overlay-enabled checkbox (overlay vs new
|
||||
// tab) and opened via the single #open-panel button.
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
|
||||
const openOverlay = page.locator('#open-page-overlay');
|
||||
await expect(openOverlay).toBeAttached();
|
||||
await expect(openOverlay).toContainText('Overlay');
|
||||
const overlayEnabled = page.locator('#overlay-enabled');
|
||||
await expect(overlayEnabled).toBeAttached();
|
||||
await expect(overlayEnabled).toBeChecked();
|
||||
});
|
||||
|
||||
test('toggles on-page overlay on a live webpage', async ({ context, extensionId }) => {
|
||||
@@ -98,9 +101,22 @@ test.describe('Popup', () => {
|
||||
const popupPage = await context.newPage();
|
||||
await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
|
||||
const toggleOverlayBtn = popupPage.locator('#open-page-overlay');
|
||||
await expect(toggleOverlayBtn).toBeVisible();
|
||||
await toggleOverlayBtn.click();
|
||||
// #open-panel resolves the active tab and relays page.overlay.show to
|
||||
// its content script. In this harness the popup is itself the active
|
||||
// tab (a privileged chrome-extension:// page), so the active-tab lookup
|
||||
// can never reach example.com — drive the same relay the button sends,
|
||||
// explicitly targeted, and let background + content script do the rest.
|
||||
const openPanelBtn = popupPage.locator('#open-panel');
|
||||
await expect(openPanelBtn).toBeVisible();
|
||||
await popupPage.evaluate(async () => {
|
||||
const tabs = await chrome.tabs.query({ url: 'https://example.com/' });
|
||||
await chrome.runtime.sendMessage({
|
||||
action: 'page.overlay.show',
|
||||
tabId: tabs[0]?.id,
|
||||
side: 'right',
|
||||
width: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
await targetPage.bringToFront();
|
||||
const overlayRoot = targetPage.locator('#hanzo-page-overlay-root');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.8.8",
|
||||
"version": "1.9.31",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
@@ -16,20 +16,21 @@
|
||||
"axios": "^1.10.0",
|
||||
"chalk": "^4.1.2",
|
||||
"commander": "^11.1.0",
|
||||
"webextension-polyfill": "0.12.0",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/chrome": "^0.0.270",
|
||||
"@types/firefox-webext-browser": "^120.0.0",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"esbuild": "^0.25.6",
|
||||
"esbuild": "^0.25.8",
|
||||
"jsdom": "^26.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^0.34.6",
|
||||
"vitest": "^3.2.6",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,21 @@
|
||||
// Background Service Worker for Browser Extension
|
||||
//
|
||||
// First import: webextension-polyfill. On Chrome/Edge this is a side-effect
|
||||
// import that defines `globalThis.browser` as the unified Promise-returning
|
||||
// WebExtension API. On Firefox/Safari `browser.*` is already native and the
|
||||
// polyfill is a no-op. The shared modules (auth.ts, tab-id.ts, etc.) read
|
||||
// this global so importing it once at the entrypoint is enough.
|
||||
import 'webextension-polyfill';
|
||||
|
||||
import type { ZapState, ControlSession, RagSnippet, RagQueryParams } from './shared/types.js';
|
||||
import {
|
||||
createZapManager,
|
||||
encodeZapMessage,
|
||||
discoverZapServers,
|
||||
handleZapMessage,
|
||||
MSG_REQUEST,
|
||||
DEFAULT_ZAP_PORTS as SHARED_ZAP_PORTS,
|
||||
type ZapManager,
|
||||
} from './shared/zap.js';
|
||||
import { connectNativeZap, type NativeZapState } from './shared/native-zap.js';
|
||||
import {
|
||||
getRagConfig as getSharedRagConfig,
|
||||
normalizeRagSnippets,
|
||||
@@ -29,7 +36,7 @@ import {
|
||||
const IAM_V1 = `${IAM_API_URL}${IAM_API_PATH}`;
|
||||
import { BrowserControl } from './browser-control';
|
||||
import { WebGPUAI } from './webgpu-ai';
|
||||
import { getCDPBridge, CDPBridge } from './cdp-bridge';
|
||||
import { getCDPBridge, CDPBridge } from './browser-dispatch';
|
||||
import * as auth from './auth';
|
||||
import { listModels, chatCompletion, ChatMessage } from './chat-client';
|
||||
import { PageMonitor } from './page-monitor';
|
||||
@@ -115,8 +122,11 @@ const controlSession: ControlSession = {
|
||||
startedAt: null,
|
||||
};
|
||||
|
||||
// Default ports (overridable via chrome.storage.local settings)
|
||||
const DEFAULT_ZAP_PORTS = SHARED_ZAP_PORTS;
|
||||
// Default ports (overridable via chrome.storage.local settings).
|
||||
// NOTE: ZAP discovery is mDNS-only (HIP-0069); the legacy zapPorts setting
|
||||
// is no longer consulted for discovery — kept here only because other
|
||||
// surfaces (status display, settings UI) still expose it during the
|
||||
// removal window.
|
||||
const DEFAULT_MCP_PORT = CFG_MCP_PORT;
|
||||
const DEFAULT_CDP_PORT = CFG_CDP_PORT;
|
||||
const DEFAULT_RAG_TOP_K = CFG_RAG_TOP_K;
|
||||
@@ -133,11 +143,10 @@ const controlMessageLimiter = new ActionRateLimiter();
|
||||
const controlMessageSignatures = new Map<string, string>();
|
||||
|
||||
/** Load port configuration from storage */
|
||||
async function getPortConfig(): Promise<{ zapPorts: number[]; mcpPort: number; cdpPort: number }> {
|
||||
async function getPortConfig(): Promise<{ mcpPort: number; cdpPort: number }> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(['zapPorts', 'mcpPort', 'cdpPort'], (result) => {
|
||||
chrome.storage.local.get(['mcpPort', 'cdpPort'], (result) => {
|
||||
resolve({
|
||||
zapPorts: result.zapPorts || DEFAULT_ZAP_PORTS,
|
||||
mcpPort: result.mcpPort || DEFAULT_MCP_PORT,
|
||||
cdpPort: result.cdpPort || DEFAULT_CDP_PORT,
|
||||
});
|
||||
@@ -281,15 +290,46 @@ chrome.runtime.onInstalled.addListener(async () => {
|
||||
if (gpuAvailable) {
|
||||
debugLog('[Hanzo] WebGPU available');
|
||||
try {
|
||||
await webgpuAI.loadModel({
|
||||
name: 'hanzo-browser-control',
|
||||
url: chrome.runtime.getURL('models/browser-control-4bit.bin'),
|
||||
quantization: '4bit',
|
||||
maxTokens: 512
|
||||
});
|
||||
// Model source resolution order (first hit wins):
|
||||
// 1. chrome.storage.local.hanzo_model_url — explicit override
|
||||
// 2. chrome.storage.local.hanzo_model_hf — { repo, file, revision? }
|
||||
// 3. The hanzo-browser-control HF default — cached after first load
|
||||
// 4. The bundled-with-extension fallback at models/browser-control-4bit.bin
|
||||
const stored = await chrome.storage.local.get([
|
||||
'hanzo_model_url', 'hanzo_model_hf', 'hanzo_model_quant', 'hanzo_model_max_tokens', 'hanzo_model_vocab_url',
|
||||
]);
|
||||
let modelUrl: string;
|
||||
if (stored.hanzo_model_url) {
|
||||
modelUrl = stored.hanzo_model_url;
|
||||
} else if (stored.hanzo_model_hf && stored.hanzo_model_hf.repo && stored.hanzo_model_hf.file) {
|
||||
const { repo, file, revision } = stored.hanzo_model_hf;
|
||||
modelUrl = WebGPUAI.huggingFaceURL(repo, file, revision || 'main');
|
||||
} else {
|
||||
// Default to the HF-published browser-control artifact. If it 404s, the
|
||||
// catch below falls through to the bundled file, then to remote providers.
|
||||
modelUrl = WebGPUAI.huggingFaceURL('zenlm/hanzo-browser-control', 'browser-control-4bit.bin');
|
||||
}
|
||||
try {
|
||||
await webgpuAI.loadModel({
|
||||
name: 'hanzo-browser-control',
|
||||
url: modelUrl,
|
||||
quantization: stored.hanzo_model_quant || '4bit',
|
||||
maxTokens: stored.hanzo_model_max_tokens || 512,
|
||||
vocabUrl: stored.hanzo_model_vocab_url,
|
||||
});
|
||||
debugLog(`[Hanzo] WebGPU model loaded from ${modelUrl}`);
|
||||
} catch (hfErr) {
|
||||
debugLog(`[Hanzo] WebGPU model fetch failed for ${modelUrl}: ${hfErr}; trying bundled fallback…`);
|
||||
await webgpuAI.loadModel({
|
||||
name: 'hanzo-browser-control',
|
||||
url: chrome.runtime.getURL('models/browser-control-4bit.bin'),
|
||||
quantization: '4bit',
|
||||
maxTokens: 512,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Model file not bundled yet — will use Ollama/LM Studio/cloud instead
|
||||
debugLog('[Hanzo] Local model not bundled, using remote providers');
|
||||
// Neither HF nor bundled artifact available — fall back to remote providers.
|
||||
debugLog(`[Hanzo] No local WebGPU model available (${error}); using Ollama/LM Studio/cloud`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1468,7 +1508,7 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
const data = await resp.json();
|
||||
const user = data.data || data;
|
||||
|
||||
// Casdoor stores org memberships in user.groups or via org API
|
||||
// Hanzo IAM stores org memberships in user.groups or via org API
|
||||
const orgs: { id: string; name: string; displayName?: string }[] = [];
|
||||
|
||||
// Primary org from user's owner field
|
||||
@@ -1750,54 +1790,16 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Legacy MCP WebSocket (fallback when no ZAP gateway running)
|
||||
// =============================================================================
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
async function connectToMCP() {
|
||||
const { mcpPort } = await getPortConfig();
|
||||
ws = new WebSocket(`ws://localhost:${mcpPort}/browser-extension`);
|
||||
|
||||
ws.onopen = () => {
|
||||
debugLog('[Hanzo] Connected to legacy MCP server');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
handleMCPMessage(data);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// Silent — ZAP is primary, this is fallback
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setTimeout(connectToMCP, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
function handleMCPMessage(data: any) {
|
||||
switch (data.type) {
|
||||
case 'browserControl':
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (tabs[0]?.id) {
|
||||
browserControl.launchAIWorker(tabs[0].id, data.model);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Startup
|
||||
// =============================================================================
|
||||
|
||||
// 0. Register side panel
|
||||
if (chrome.sidePanel) {
|
||||
chrome.sidePanel.setOptions({ path: 'sidebar.html', enabled: true });
|
||||
}
|
||||
// 0. Side panel intentionally NOT registered. The user requirement is a
|
||||
// right-anchored, in-page chat overlay across every browser; the native
|
||||
// chrome.sidePanel surface gives an OS-managed panel which doesn't match
|
||||
// that design and behaves differently from the Firefox path. Routing
|
||||
// through the content-script overlay (page.overlay.* messages) is the
|
||||
// single source of truth — see popup.ts.
|
||||
|
||||
// End active session when the controlled tab closes.
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
@@ -1806,23 +1808,44 @@ chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Primary: Discover ZAP servers (high-performance binary protocol)
|
||||
getPortConfig().then(({ zapPorts }) => {
|
||||
discoverZapServers(zapMgr, BROWSER_NAME, VERSION, zapPorts, debugLog);
|
||||
// 1. Primary: connect to the local zapd router via the ai.hanzo.zap native
|
||||
// host and register as a browser provider. Inbound ROUTE commands are
|
||||
// dispatched to the browser. Exactly one transport — no WebSocket, no
|
||||
// localhost ports, no mDNS probing; the browser can never get a refused
|
||||
// connection (connectNative attaches the host or fails once, cleanly).
|
||||
const BROWSER_CAPS = ['browser.tabs', 'browser.navigate', 'browser.dom', 'browser.screenshot', 'browser.input'];
|
||||
const nativeZap: NativeZapState = {
|
||||
connected: false,
|
||||
port: null,
|
||||
providerId: `browser:${BROWSER_NAME}/default`,
|
||||
};
|
||||
const browserDispatch = (method: string, params: any) => cdpBridge.dispatchMethod(method, params);
|
||||
connectNativeZap(nativeZap, 'hanzo', BROWSER_CAPS, browserDispatch, debugLog);
|
||||
|
||||
// Keep-alive — Layer 1: Port. Content scripts in every loaded http(s) tab
|
||||
// hold a `zap-keepalive` port. As long as ANY tab has one, Chrome keeps
|
||||
// the service worker alive without alarms.
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
if (port.name !== 'zap-keepalive') return;
|
||||
port.onMessage.addListener(() => { /* receipt is the keep-alive */ });
|
||||
});
|
||||
|
||||
// 2. Fallback: Connect to legacy MCP WebSocket
|
||||
connectToMCP();
|
||||
// Keep-alive — Layer 2: Alarms. Wake the worker periodically; if the native
|
||||
// host link dropped (or the worker was idled out), reconnect.
|
||||
const KEEP_ALIVE_ALARM = 'hanzo-zap-keepalive';
|
||||
try {
|
||||
chrome.alarms.create(KEEP_ALIVE_ALARM, { periodInMinutes: 0.25 });
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name !== KEEP_ALIVE_ALARM) return;
|
||||
if (!nativeZap.connected) {
|
||||
debugLog('[Hanzo/ZAP] keep-alive: reconnecting native host');
|
||||
connectNativeZap(nativeZap, 'hanzo', BROWSER_CAPS, browserDispatch, debugLog);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[Hanzo] alarms unavailable:', e);
|
||||
}
|
||||
|
||||
// 3. CDP bridge for browser control (configurable port)
|
||||
getPortConfig().then(({ cdpPort }) => {
|
||||
try {
|
||||
cdpBridge.startWebSocketServer(cdpPort);
|
||||
debugLog(`[Hanzo] CDP bridge connecting to ws://localhost:${cdpPort}/cdp`);
|
||||
} catch (e) {
|
||||
console.error('[Hanzo] Failed to start CDP bridge:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Export for testing
|
||||
export { browserControl, webgpuAI, zapMgr, zapState };
|
||||
|
||||
@@ -1377,30 +1377,94 @@ export class BrowserControl {
|
||||
// Script Execution Helper
|
||||
// ===========================================================================
|
||||
|
||||
private executeScript<T = any>(tabId: number, code: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (chrome.scripting) {
|
||||
// MV3: use chrome.scripting.executeScript
|
||||
chrome.scripting.executeScript({
|
||||
/**
|
||||
* Run JS in a tab and return its result.
|
||||
*
|
||||
* Why MV3 + MAIN world + the Function trampoline?
|
||||
*
|
||||
* - `chrome.scripting.executeScript` only accepts a function (or files);
|
||||
* it does NOT take a code string the way MV2's `tabs.executeScript`
|
||||
* did. To support our existing call sites that pass dynamic code as
|
||||
* a string, we serialise the user's code through `args` and run it
|
||||
* via `Function(codeStr)()` inside the page.
|
||||
* - MAIN world is required so the page's own globals (window.fetch,
|
||||
* CustomElements, framework instances) are visible. ISOLATED world
|
||||
* can't see them.
|
||||
* - Async unwrap: if the caller's code yields a Promise, executeScript
|
||||
* serialises it as `{}` (the value-loss bug). We detect that shape
|
||||
* and re-run wrapping in `Promise.resolve(...)` so the resolved
|
||||
* value comes back instead of the placeholder.
|
||||
*
|
||||
* Falls back to the MV2 string API on browsers / runtimes that still
|
||||
* have it (older Firefox; no Chrome MV2 ships now).
|
||||
*/
|
||||
private async executeScript<T = any>(tabId: number, code: string): Promise<T> {
|
||||
const exec = async (wrapped: string): Promise<T> => {
|
||||
// MV3 path — preferred on every modern build.
|
||||
if (chrome.scripting?.executeScript) {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: new Function(`return (${code})`) as () => T,
|
||||
}, (results) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(results?.[0]?.result as T);
|
||||
}
|
||||
world: 'MAIN',
|
||||
args: [wrapped],
|
||||
func: (codeStr: string) => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
return Function(codeStr)();
|
||||
} catch (e: any) {
|
||||
return { __hanzo_error: e?.message || String(e) };
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// MV2 fallback: use chrome.tabs.executeScript
|
||||
(chrome.tabs as any).executeScript(tabId, { code }, (results: any[]) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(results?.[0] as T);
|
||||
}
|
||||
return (results?.[0] as any)?.result as T;
|
||||
}
|
||||
// MV2 fallback — only Chrome-MV2 / older Firefox would reach this.
|
||||
const legacy = (chrome.tabs as any).executeScript;
|
||||
if (legacy) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
legacy(tabId, { code: wrapped }, (results: any[]) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(results?.[0] as T);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
throw new Error('No executeScript API available (need MV3 scripting permission)');
|
||||
};
|
||||
|
||||
// Wrap the user's code so it ALWAYS has a top-level `return`. The
|
||||
// Function trampoline discards the value of an IIFE — without an
|
||||
// explicit `return` the result is undefined → JSON-encodes to `{}`.
|
||||
const trimmed = code.trim();
|
||||
const wrappedCode = /^\s*return\b/.test(trimmed)
|
||||
? trimmed
|
||||
: `try { return (${trimmed}); } catch(__e) { return { __hanzo_error: __e.message || String(__e) }; }`;
|
||||
|
||||
let result: any = await exec(wrappedCode);
|
||||
|
||||
// Unwrap our error sentinel so callers see a real Error.
|
||||
if (result && typeof result === 'object' && '__hanzo_error' in result) {
|
||||
throw new Error(result.__hanzo_error);
|
||||
}
|
||||
|
||||
// Detect the empty-Promise serialization. If the caller's code yielded
|
||||
// a Promise (e.g. (async () => 'x')()) executeScript drops it as `{}`.
|
||||
// Re-run wrapped in Promise.resolve so the resolved value comes back.
|
||||
const looksEmpty = result !== null
|
||||
&& typeof result === 'object'
|
||||
&& Object.keys(result).length === 0
|
||||
&& !Array.isArray(result);
|
||||
if (looksEmpty) {
|
||||
const promiseWrapped = `return Promise.resolve((function(){ ${wrappedCode} })()).then(function(__v){
|
||||
try { return JSON.parse(JSON.stringify(__v === undefined ? null : __v)); } catch(e) { return String(__v); }
|
||||
});`;
|
||||
result = await exec(promiseWrapped);
|
||||
if (result && typeof result === 'object' && '__hanzo_error' in result) {
|
||||
throw new Error(result.__hanzo_error);
|
||||
}
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// CDP Bridge for hanzo-mcp Browser Tool Integration
|
||||
// Enables Playwright to control browser via Chrome DevTools Protocol
|
||||
import { ActionRateLimiter, debugLog, loadDebugFlagFromStorage } from './runtime-guard';
|
||||
import { parseTabId, unwrapEvaluateResult } from './shared/tab-id.js';
|
||||
import { pickEvaluable, wrapEvaluable } from './shared/evaluable.js';
|
||||
|
||||
interface CDPSession {
|
||||
tabId: number;
|
||||
@@ -164,27 +166,40 @@ export class CDPBridge {
|
||||
debugLog(`[CDP] Page.captureScreenshot failed, using captureVisibleTab fallback: ${e}`);
|
||||
}
|
||||
|
||||
// Fallback: use chrome.tabs.captureVisibleTab (no debugger needed)
|
||||
// Fallback: chrome.tabs.captureVisibleTab. This API only works on a
|
||||
// *foreground* tab in the target window — Firefox enforces this strictly,
|
||||
// Chrome will sometimes succeed with a backgrounded tab but is
|
||||
// inconsistent on Mac. Brief-activate the target tab if needed and
|
||||
// restore focus afterwards. Always pass an explicit windowId resolved
|
||||
// from the tab so multi-window automation captures the right window.
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
|
||||
format: options?.format === 'jpeg' ? 'jpeg' : 'png',
|
||||
quality: options?.quality,
|
||||
});
|
||||
let activatedFor: number | null = null;
|
||||
let prevActiveId: number | undefined;
|
||||
if (!tab.active) {
|
||||
const prev = await new Promise<chrome.tabs.Tab | undefined>((resolve) => {
|
||||
chrome.tabs.query({ active: true, windowId: tab.windowId }, (tabs) => resolve(tabs[0]));
|
||||
});
|
||||
prevActiveId = prev?.id;
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
activatedFor = tabId;
|
||||
// One animation tick so the tab actually composes.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
let dataUrl: string;
|
||||
try {
|
||||
dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
|
||||
format: options?.format === 'jpeg' ? 'jpeg' : 'png',
|
||||
quality: options?.quality,
|
||||
});
|
||||
} finally {
|
||||
if (activatedFor !== null && prevActiveId) {
|
||||
chrome.tabs.update(prevActiveId, { active: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
// Strip data:image/png;base64, prefix
|
||||
return dataUrl.replace(/^data:image\/\w+;base64,/, '');
|
||||
}
|
||||
|
||||
/** Accept tabId in any of: number | "tab-NNN" | "NNN" | undefined.
|
||||
* Fully anchored so "https://x.com/?tab-123" can't be coerced. */
|
||||
private parseTabId(raw: unknown): number | null {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||
if (typeof raw !== 'string' || raw === '') return null;
|
||||
const m = raw.match(/^(?:tab-)?(\d+)$/);
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
async click(tabId: number, x: number, y: number): Promise<void> {
|
||||
await this.send(tabId, 'Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
@@ -218,11 +233,18 @@ export class CDPBridge {
|
||||
|
||||
async evaluate(tabId: number, expression: string): Promise<any> {
|
||||
await this.send(tabId, 'Runtime.enable');
|
||||
// awaitPromise: true makes CDP wait for any async IIFE the caller passed
|
||||
// to resolve before returning. Without this, expressions like
|
||||
// (async () => fetch('/x').then(r => r.json()))()
|
||||
// serialize to {} (a Promise placeholder) and the caller can't tell
|
||||
// success from failure. unwrapEvaluateResult further normalises every
|
||||
// shape (Chrome / CDP / hanzo-tools) to the bare value.
|
||||
const result = await this.send(tabId, 'Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true
|
||||
expression: wrapEvaluable(expression),
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
});
|
||||
return result.result?.value;
|
||||
return unwrapEvaluateResult(result);
|
||||
}
|
||||
|
||||
async getDocument(tabId: number): Promise<any> {
|
||||
@@ -293,78 +315,28 @@ export class CDPBridge {
|
||||
return true;
|
||||
}
|
||||
|
||||
// WebSocket server for external connections (hanzo-mcp)
|
||||
startWebSocketServer(port: number = 9223): void {
|
||||
// Note: Chrome extensions can't create native WebSocket servers
|
||||
// Instead, we connect to an external bridge server
|
||||
this.connectToBridge(`ws://localhost:${port}/cdp`);
|
||||
}
|
||||
|
||||
private connectToBridge(url: string): void {
|
||||
try {
|
||||
this.wsServer = new WebSocket(url);
|
||||
|
||||
this.wsServer.onopen = () => {
|
||||
debugLog('[CDP] Connected to bridge server');
|
||||
// Register as CDP provider with browser identification
|
||||
const browser = typeof navigator !== 'undefined'
|
||||
? (navigator.userAgent.includes('Firefox') ? 'firefox'
|
||||
: navigator.userAgent.includes('Edg/') ? 'edge'
|
||||
: navigator.userAgent.includes('Chrome') ? 'chrome'
|
||||
: navigator.userAgent.includes('Safari') ? 'safari'
|
||||
: 'unknown')
|
||||
: 'unknown';
|
||||
this.wsServer?.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser,
|
||||
capabilities: ['navigate', 'screenshot', 'click', 'type', 'evaluate', 'takeover']
|
||||
}));
|
||||
};
|
||||
|
||||
this.wsServer.onmessage = async (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
// Legacy bridge client removed — the extension reaches the local zapd router
|
||||
// via the ai.hanzo.zap native host. dispatchMethod() below remains the
|
||||
// inbound browser-command dispatcher.
|
||||
|
||||
// Handle extension-request from bridge server (monitor/audit calls)
|
||||
if (message.type === 'extension-request' && message.action) {
|
||||
chrome.runtime.sendMessage(
|
||||
{ action: message.action, ...(message.params || {}) },
|
||||
(result) => {
|
||||
this.wsServer?.send(JSON.stringify({
|
||||
type: 'extension-response',
|
||||
id: message.id,
|
||||
result: result || { error: chrome.runtime.lastError?.message },
|
||||
}));
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.handleBridgeMessage(message);
|
||||
this.wsServer?.send(JSON.stringify(response));
|
||||
} catch (error: any) {
|
||||
this.wsServer?.send(JSON.stringify({
|
||||
id: 0,
|
||||
error: { code: -32603, message: error.message }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
this.wsServer.onerror = (error) => {
|
||||
console.error('[CDP] Bridge connection error:', error);
|
||||
};
|
||||
|
||||
this.wsServer.onclose = () => {
|
||||
debugLog('[CDP] Bridge disconnected, reconnecting in 5s...');
|
||||
setTimeout(() => this.connectToBridge(url), 5000);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[CDP] Failed to connect to bridge:', error);
|
||||
setTimeout(() => this.connectToBridge(url), 5000);
|
||||
/**
|
||||
* Public entry point for ZAP-server-initiated requests.
|
||||
*
|
||||
* Wraps {@link handleBridgeMessage} so background scripts can hand off
|
||||
* ``method`` + ``params`` from a ZAP MSG_REQUEST without synthesising a
|
||||
* fake numeric ``id``. Throws on bridge error so the caller can convert
|
||||
* to a MSG_RESPONSE error frame.
|
||||
*/
|
||||
async dispatchMethod(method: string, params: any): Promise<any> {
|
||||
const response = await this.handleBridgeMessage({ id: 0, method, params: params || {} });
|
||||
if (response.error) {
|
||||
const err: any = new Error(response.error.message);
|
||||
err.code = response.error.code;
|
||||
throw err;
|
||||
}
|
||||
return response.result;
|
||||
}
|
||||
|
||||
|
||||
private async handleBridgeMessage(message: any): Promise<CDPResponse> {
|
||||
const { id, method, params } = message;
|
||||
|
||||
@@ -375,7 +347,7 @@ export class CDPBridge {
|
||||
// getTargets, plain numeric string. Without explicit targeting fall
|
||||
// back to the active tab in the focused window — which is fragile when
|
||||
// the user has many windows open. Always prefer explicit ids.
|
||||
const tabId = this.parseTabId(params?.tabId ?? params?.targetId)
|
||||
const tabId = parseTabId(params?.tabId ?? params?.targetId)
|
||||
?? await this.getActiveTabId();
|
||||
|
||||
switch (method) {
|
||||
@@ -418,14 +390,21 @@ export class CDPBridge {
|
||||
break;
|
||||
|
||||
case 'Runtime.evaluate':
|
||||
case 'evaluate':
|
||||
// Must enable Runtime domain and set returnByValue for actual
|
||||
// values. `awaitPromise: true` makes CDP wait for async IIFEs
|
||||
// to resolve so callers passing `(async () => {...})()` get
|
||||
// the resolved value instead of an empty {} (the previous bug
|
||||
// — caller saw a serialized Promise placeholder).
|
||||
// pickEvaluable + wrapEvaluable: accept every code-param alias
|
||||
// and let statement bodies (`const b=…; b.click()`) evaluate
|
||||
// instead of tripping SpiderMonkey/V8 expression parsing — the
|
||||
// same rule background-firefox.ts applies, so both browsers
|
||||
// accept identical caller JS.
|
||||
await this.send(tabId, 'Runtime.enable');
|
||||
result = await this.send(tabId, method, {
|
||||
result = await this.send(tabId, 'Runtime.evaluate', {
|
||||
...params,
|
||||
expression: wrapEvaluable(pickEvaluable(params)),
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
});
|
||||
@@ -8,29 +8,13 @@ const { execSync } = require('child_process');
|
||||
async function build() {
|
||||
console.log('Building browser extension...');
|
||||
|
||||
const hanzoUiRoot = path.resolve(__dirname, '../../../../ui');
|
||||
const hanzoUiPrimitives = path.join(hanzoUiRoot, 'pkg/ui/dist/primitives/index-common.js');
|
||||
const localReact = path.join(hanzoUiRoot, 'node_modules/react');
|
||||
const localReactDom = path.join(hanzoUiRoot, 'node_modules/react-dom');
|
||||
const forceLocalUiShim = process.env.HANZO_FORCE_LOCAL_UI_SHIM === '1';
|
||||
const canUseSharedUi =
|
||||
!forceLocalUiShim &&
|
||||
fs.existsSync(hanzoUiPrimitives) &&
|
||||
fs.existsSync(localReact) &&
|
||||
fs.existsSync(localReactDom);
|
||||
|
||||
if (canUseSharedUi) {
|
||||
console.log('Using shared @hanzo/ui primitives from sibling repo:', hanzoUiPrimitives);
|
||||
} else {
|
||||
console.warn(
|
||||
[
|
||||
'Shared @hanzo/ui dependencies were not found. Falling back to local primitives shim.',
|
||||
`Expected: ${hanzoUiPrimitives}`,
|
||||
`Expected: ${localReact}`,
|
||||
`Expected: ${localReactDom}`,
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
// The extension does NOT depend on `@hanzo/ui`. Primitives are served by
|
||||
// a small local shim aliased as `@hanzo/gui` so call-sites read like the
|
||||
// canonical hanzo design system. The full tamagui-based @hanzo/gui will
|
||||
// land in a feature branch — when that ships, just point the alias below
|
||||
// at the published `@hanzo/gui` package and remove gui-primitives.tsx.
|
||||
const hanzoGuiShim = path.join(__dirname, 'gui-primitives.tsx');
|
||||
console.log('Using @hanzo/gui local primitives shim:', hanzoGuiShim);
|
||||
|
||||
// Ensure dist directories exist
|
||||
fs.mkdirSync('dist/browser-extension', { recursive: true });
|
||||
@@ -80,16 +64,11 @@ async function build() {
|
||||
format: 'esm'
|
||||
});
|
||||
|
||||
// Build sidebar + popup UI scripts (TypeScript-first, with JS fallback)
|
||||
const sidebarAliases = canUseSharedUi
|
||||
? {
|
||||
'@hanzo/ui/primitives-common': hanzoUiPrimitives,
|
||||
react: localReact,
|
||||
'react-dom': localReactDom,
|
||||
}
|
||||
: {
|
||||
'@hanzo/ui/primitives-common': path.join(__dirname, 'primitives-common-fallback.tsx'),
|
||||
};
|
||||
// Build sidebar + popup UI scripts (TypeScript-first, with JS fallback).
|
||||
// Primitives are aliased to the local @hanzo/gui shim (see gui-primitives.tsx).
|
||||
const sidebarAliases = {
|
||||
'@hanzo/gui': hanzoGuiShim,
|
||||
};
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [fs.existsSync('src/sidebar.ts') ? 'src/sidebar.ts' : 'src/sidebar.js'],
|
||||
@@ -130,16 +109,6 @@ async function build() {
|
||||
packages: 'external'
|
||||
});
|
||||
|
||||
// Build CDP Bridge Server (WebSocket + HTTP API for hanzo-mcp integration)
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/cdp-bridge-server.ts'],
|
||||
bundle: true,
|
||||
outfile: 'dist/browser-extension/cdp-bridge-server.js',
|
||||
platform: 'node',
|
||||
target: 'node16',
|
||||
packages: 'external'
|
||||
});
|
||||
|
||||
// Make CLI executable
|
||||
if (process.platform !== 'win32') {
|
||||
execSync('chmod +x dist/browser-extension/cli.js');
|
||||
@@ -152,23 +121,18 @@ async function build() {
|
||||
// There is a tsconfig in src.
|
||||
// execSync('cd src && npx tsc --noEmit', { stdio: 'inherit' });
|
||||
|
||||
// Copy manifests for different browsers
|
||||
fs.copyFileSync(
|
||||
'src/manifest.json',
|
||||
'dist/browser-extension/manifest.json'
|
||||
);
|
||||
|
||||
// Chrome version
|
||||
fs.copyFileSync(
|
||||
'src/manifest.json',
|
||||
'dist/browser-extension/chrome/manifest.json'
|
||||
);
|
||||
|
||||
// Firefox version
|
||||
fs.copyFileSync(
|
||||
'src/manifest-firefox.json',
|
||||
'dist/browser-extension/firefox/manifest.json'
|
||||
);
|
||||
// Copy manifests for different browsers. package.json is the SINGLE source of
|
||||
// truth for the version — stamp it into every manifest so the three can never
|
||||
// drift apart (the "must agree" rule, enforced by the build instead of by hand).
|
||||
const pkgVersion = JSON.parse(fs.readFileSync('package.json', 'utf8')).version;
|
||||
const writeManifest = (srcPath, destPath) => {
|
||||
const m = JSON.parse(fs.readFileSync(srcPath, 'utf8'));
|
||||
m.version = pkgVersion;
|
||||
fs.writeFileSync(destPath, JSON.stringify(m, null, 2));
|
||||
};
|
||||
writeManifest('src/manifest.json', 'dist/browser-extension/manifest.json');
|
||||
writeManifest('src/manifest.json', 'dist/browser-extension/chrome/manifest.json');
|
||||
writeManifest('src/manifest-firefox.json', 'dist/browser-extension/firefox/manifest.json');
|
||||
|
||||
// Safari needs special handling — write Info.plist for Xcode project
|
||||
fs.writeFileSync('dist/browser-extension/safari/Info.plist', `<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -180,9 +144,9 @@ async function build() {
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>ai.hanzo.browser-extension</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0.0</string>
|
||||
<string>${pkgVersion}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<string>${pkgVersion}</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@@ -1,881 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CDP Bridge Server - Unified browser control interface
|
||||
*
|
||||
* Provides hanzo.browser(action, params) interface matching hanzo-mcp pattern.
|
||||
* Browser extension connects to this server for remote control.
|
||||
*/
|
||||
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import * as readline from 'readline';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
interface CDPClient {
|
||||
ws: WebSocket;
|
||||
id: string; // Unique ID: "chrome", "firefox", "chrome-2", etc.
|
||||
capabilities: string[];
|
||||
browser: string; // Browser type: "chrome", "firefox", "safari", "edge"
|
||||
tabId?: number;
|
||||
}
|
||||
|
||||
interface BrowserCommand {
|
||||
action: string;
|
||||
// Navigation
|
||||
url?: string;
|
||||
// Selectors
|
||||
selector?: string;
|
||||
ref?: string;
|
||||
element?: string;
|
||||
// Input
|
||||
text?: string;
|
||||
value?: string;
|
||||
key?: string;
|
||||
// Screenshot
|
||||
fullPage?: boolean;
|
||||
format?: 'png' | 'jpeg';
|
||||
filename?: string;
|
||||
// Evaluate
|
||||
code?: string;
|
||||
function?: string;
|
||||
expression?: string;
|
||||
// Tabs
|
||||
tabId?: number;
|
||||
tabIndex?: number;
|
||||
// Wait
|
||||
timeout?: number;
|
||||
state?: string;
|
||||
// Browser targeting (multi-browser support)
|
||||
browser?: string; // Target browser: "chrome", "firefox", "safari", or unique ID
|
||||
// Generic
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
class CDPBridgeServer {
|
||||
private wss: WebSocketServer;
|
||||
private clients: Map<WebSocket, CDPClient> = new Map();
|
||||
private pendingCommands: Map<number, { resolve: Function; reject: Function }> = new Map();
|
||||
private pendingExtensionRequests: Map<string, { resolve: Function; timeout: ReturnType<typeof setTimeout> }> = new Map();
|
||||
private commandId = 0;
|
||||
private httpPort: number;
|
||||
|
||||
constructor(port: number = 9223) {
|
||||
this.httpPort = port;
|
||||
this.wss = new WebSocketServer({ port, path: '/cdp' });
|
||||
|
||||
this.wss.on('connection', (ws) => {
|
||||
console.log('[hanzo.browser] Extension connected');
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
this.handleMessage(ws, message);
|
||||
} catch (error: any) {
|
||||
console.error('[hanzo.browser] Error:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[hanzo.browser] Extension disconnected');
|
||||
this.clients.delete(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('[hanzo.browser] WebSocket error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`[hanzo.browser] Server listening on ws://localhost:${port}/cdp`);
|
||||
}
|
||||
|
||||
/** Assign a unique ID for a browser type (e.g. "chrome", "chrome-2", "firefox") */
|
||||
private assignClientId(browser: string): string {
|
||||
const existing = Array.from(this.clients.values())
|
||||
.filter((c) => c.browser === browser);
|
||||
if (existing.length === 0) return browser;
|
||||
// Find next available suffix
|
||||
for (let i = 2; ; i++) {
|
||||
const candidate = `${browser}-${i}`;
|
||||
if (!existing.some((c) => c.id === candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
/** Find a client by browser name/ID, or return first connected client */
|
||||
private resolveClient(target?: string): CDPClient | undefined {
|
||||
if (!target) {
|
||||
// Default: first connected client
|
||||
return this.clients.values().next().value;
|
||||
}
|
||||
// Exact match on ID first (e.g. "chrome-2")
|
||||
for (const client of this.clients.values()) {
|
||||
if (client.id === target) return client;
|
||||
}
|
||||
// Match by browser type (returns first of that type, e.g. "firefox")
|
||||
for (const client of this.clients.values()) {
|
||||
if (client.browser === target) return client;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private handleMessage(ws: WebSocket, message: any) {
|
||||
if (message.type === 'register') {
|
||||
const browser = message.browser || 'unknown';
|
||||
const id = this.assignClientId(browser);
|
||||
this.clients.set(ws, {
|
||||
ws,
|
||||
id,
|
||||
capabilities: message.capabilities || [],
|
||||
browser,
|
||||
});
|
||||
console.log(`[hanzo.browser] Registered ${id} (${browser}):`, message.capabilities?.join(', '));
|
||||
// Notify the extension of its assigned ID
|
||||
ws.send(JSON.stringify({ type: 'registered', id, browser }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'config') {
|
||||
this.saveConfig(message.key, message.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle responses from extension for sendToExtension() calls
|
||||
if (message.type === 'extension-response' && message.id) {
|
||||
const pending = this.pendingExtensionRequests.get(message.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingExtensionRequests.delete(message.id);
|
||||
pending.resolve(message.result || message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.id !== undefined) {
|
||||
const pending = this.pendingCommands.get(message.id);
|
||||
if (pending) {
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message));
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
this.pendingCommands.delete(message.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'event') {
|
||||
console.log(`[hanzo.browser] Event: ${message.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Save a config value to ~/.hanzo/extension/config.json */
|
||||
private saveConfig(key: string, value: unknown): void {
|
||||
const configDir = path.join(
|
||||
process.env.HOME || process.env.USERPROFILE || '.',
|
||||
'.hanzo',
|
||||
'extension',
|
||||
);
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
config[key] = value;
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
||||
console.log(`[hanzo.browser] Config saved: ${key} = ${JSON.stringify(value)}`);
|
||||
} catch (error: any) {
|
||||
console.error(`[hanzo.browser] Failed to save config: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get connected browser names */
|
||||
getConnectedBrowsers(): string[] {
|
||||
return Array.from(this.clients.values())
|
||||
.map((c) => c.id || c.browser || 'unknown')
|
||||
.filter((b) => b !== 'unknown');
|
||||
}
|
||||
|
||||
/** Get detailed info about all connected browsers */
|
||||
getConnectedBrowserDetails(): Array<{ id: string; browser: string; capabilities: string[] }> {
|
||||
return Array.from(this.clients.values()).map((c) => ({
|
||||
id: c.id,
|
||||
browser: c.browser,
|
||||
capabilities: c.capabilities,
|
||||
}));
|
||||
}
|
||||
|
||||
private async sendRaw(method: string, params?: any, targetBrowser?: string): Promise<any> {
|
||||
const client = this.resolveClient(targetBrowser);
|
||||
if (!client) {
|
||||
const connected = this.getConnectedBrowsers();
|
||||
throw new Error(
|
||||
targetBrowser
|
||||
? `Browser "${targetBrowser}" not connected. Connected: [${connected.join(', ')}]`
|
||||
: 'No browser extension connected'
|
||||
);
|
||||
}
|
||||
|
||||
const id = ++this.commandId;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingCommands.set(id, { resolve, reject });
|
||||
client.ws.send(JSON.stringify({ id, method, params }));
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.pendingCommands.has(id)) {
|
||||
this.pendingCommands.delete(id);
|
||||
reject(new Error(`Command timeout (browser: ${client.id})`));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified hanzo.browser interface - matches mcp__hanzo__browser pattern
|
||||
*/
|
||||
async browser(params: BrowserCommand): Promise<any> {
|
||||
const { action, browser: targetBrowser, ...rest } = params;
|
||||
|
||||
// Special action: list connected browsers
|
||||
if (action === 'list_browsers' || action === 'browsers') {
|
||||
return {
|
||||
browsers: this.getConnectedBrowserDetails(),
|
||||
count: this.clients.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Shorthand: b = targetBrowser for all sendRaw/sendToExtension calls below
|
||||
const b = targetBrowser;
|
||||
|
||||
// Universal tab targeting: a `tabId` or `tabIndex` on the incoming command
|
||||
// applies to every downstream sendRaw call so the user can pin a specific
|
||||
// tab without repeating it. Without this, every action falls back to
|
||||
// `chrome.tabs.query({active:true,currentWindow:true})[0]` — which is
|
||||
// fragile when the user has many windows / a chrome://newtab in focus.
|
||||
const tabTarget = (extra?: Record<string, unknown>) => {
|
||||
const out: Record<string, unknown> = { ...(extra || {}) };
|
||||
if (rest.tabId !== undefined) out.tabId = rest.tabId;
|
||||
if (rest.tabIndex !== undefined) out.tabIndex = rest.tabIndex;
|
||||
if (rest.targetId !== undefined && out.tabId === undefined) out.tabId = rest.targetId;
|
||||
return out;
|
||||
};
|
||||
|
||||
switch (action) {
|
||||
// Navigation
|
||||
case 'navigate':
|
||||
return this.sendRaw('Page.navigate', tabTarget({ url: rest.url }), b);
|
||||
|
||||
case 'navigate_back':
|
||||
case 'go_back':
|
||||
return this.sendRaw('Page.goBack', tabTarget(), b);
|
||||
|
||||
case 'navigate_forward':
|
||||
case 'go_forward':
|
||||
return this.sendRaw('Page.goForward', tabTarget(), b);
|
||||
|
||||
case 'reload':
|
||||
return this.sendRaw('Page.reload', tabTarget(), b);
|
||||
|
||||
case 'url':
|
||||
return this.sendRaw('hanzo.url', tabTarget(), b);
|
||||
|
||||
case 'title':
|
||||
return this.sendRaw('hanzo.title', tabTarget(), b);
|
||||
|
||||
case 'tab_info':
|
||||
return this.sendRaw('hanzo.tabInfo', tabTarget(), b);
|
||||
|
||||
case 'content':
|
||||
return this.sendRaw('Runtime.evaluate', tabTarget({
|
||||
expression: 'document.documentElement.outerHTML',
|
||||
returnByValue: true
|
||||
}), b);
|
||||
|
||||
// Screenshots
|
||||
case 'screenshot': {
|
||||
const screenshot = await this.sendRaw('hanzo.screenshot', tabTarget({
|
||||
format: rest.format || 'png',
|
||||
fullPage: rest.fullPage,
|
||||
}), b);
|
||||
if (rest.filename && screenshot?.data) {
|
||||
const buffer = Buffer.from(screenshot.data, 'base64');
|
||||
fs.writeFileSync(rest.filename, buffer);
|
||||
return { saved: rest.filename, bytes: buffer.length, data: screenshot.data };
|
||||
}
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
case 'snapshot':
|
||||
// Accessibility snapshot
|
||||
return this.sendRaw('Accessibility.getFullAXTree', tabTarget(), b);
|
||||
|
||||
// Input - Click
|
||||
case 'click':
|
||||
return this.sendRaw('hanzo.click', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
}), b);
|
||||
|
||||
case 'dblclick':
|
||||
case 'double_click':
|
||||
return this.sendRaw('hanzo.dblclick', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
}), b);
|
||||
|
||||
case 'hover':
|
||||
return this.sendRaw('hanzo.hover', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
}), b);
|
||||
|
||||
// Input - Type
|
||||
case 'type':
|
||||
return this.sendRaw('hanzo.type', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
text: rest.text,
|
||||
}), b);
|
||||
|
||||
case 'fill':
|
||||
return this.sendRaw('hanzo.fill', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
value: rest.value || rest.text,
|
||||
}), b);
|
||||
|
||||
case 'clear':
|
||||
return this.sendRaw('hanzo.clear', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
}), b);
|
||||
|
||||
case 'press_key':
|
||||
case 'press':
|
||||
return this.sendRaw('Input.dispatchKeyEvent', tabTarget({
|
||||
type: 'keyDown',
|
||||
key: rest.key,
|
||||
}), b);
|
||||
|
||||
// Forms
|
||||
case 'select_option':
|
||||
return this.sendRaw('hanzo.select', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
value: rest.value,
|
||||
}), b);
|
||||
|
||||
case 'check':
|
||||
return this.sendRaw('hanzo.check', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
}), b);
|
||||
|
||||
case 'uncheck':
|
||||
return this.sendRaw('hanzo.uncheck', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
}), b);
|
||||
|
||||
// Navigation
|
||||
case 'go_back':
|
||||
case 'navigate_back':
|
||||
return this.sendRaw('Page.goBack', tabTarget(), b);
|
||||
|
||||
case 'go_forward':
|
||||
case 'navigate_forward':
|
||||
return this.sendRaw('Page.goForward', tabTarget(), b);
|
||||
|
||||
case 'get_url':
|
||||
return this.sendRaw('hanzo.url', tabTarget(), b);
|
||||
|
||||
case 'get_title':
|
||||
return this.sendRaw('hanzo.title', tabTarget(), b);
|
||||
|
||||
case 'get_tab_info':
|
||||
case 'tab_info':
|
||||
return this.sendRaw('hanzo.tabInfo', tabTarget(), b);
|
||||
|
||||
case 'get_history':
|
||||
case 'history':
|
||||
return this.sendRaw('hanzo.getHistory', tabTarget({ maxResults: rest.maxResults }), b);
|
||||
|
||||
case 'wait_for_navigation':
|
||||
return this.sendRaw('hanzo.waitForNavigation', tabTarget({ timeout: rest.timeout }), b);
|
||||
|
||||
case 'create_tab':
|
||||
return this.sendRaw('Target.createTarget', { url: rest.url || 'about:blank' }, b);
|
||||
|
||||
case 'close_tab':
|
||||
return this.sendRaw('Target.closeTarget', { targetId: rest.tabId }, b);
|
||||
|
||||
// Fetch through browser context (uses page cookies/auth)
|
||||
case 'fetch': {
|
||||
const fetchResult = await this.sendRaw('hanzo.fetch', {
|
||||
url: rest.url,
|
||||
options: {
|
||||
method: rest.method || rest.options?.method,
|
||||
headers: rest.headers || rest.options?.headers,
|
||||
body: rest.body || rest.options?.body,
|
||||
mode: rest.mode || rest.options?.mode,
|
||||
credentials: rest.credentials || rest.options?.credentials,
|
||||
},
|
||||
}, b);
|
||||
return fetchResult;
|
||||
}
|
||||
|
||||
// Selectors / Waiting
|
||||
case 'wait_for_selector':
|
||||
return this.sendRaw('hanzo.waitForSelector', { selector: rest.selector, timeout: rest.timeout }, b);
|
||||
|
||||
case 'query_selector_all':
|
||||
return this.sendRaw('hanzo.querySelectorAll', { selector: rest.selector }, b);
|
||||
|
||||
case 'get_element_info':
|
||||
return this.sendRaw('hanzo.getElementInfo', { selector: rest.selector }, b);
|
||||
|
||||
case 'get_page_info':
|
||||
return this.sendRaw('hanzo.getPageInfo', {}, b);
|
||||
|
||||
// DOM Read/Write/Observe
|
||||
case 'get_html':
|
||||
return this.sendRaw('hanzo.getHTML', { selector: rest.selector, outer: rest.outer }, b);
|
||||
|
||||
case 'set_html':
|
||||
return this.sendRaw('hanzo.setHTML', { selector: rest.selector, html: rest.html, outer: rest.outer }, b);
|
||||
|
||||
case 'get_text':
|
||||
return this.sendRaw('hanzo.getText', { selector: rest.selector }, b);
|
||||
|
||||
case 'set_text':
|
||||
return this.sendRaw('hanzo.setText', { selector: rest.selector, text: rest.text }, b);
|
||||
|
||||
case 'get_attribute':
|
||||
return this.sendRaw('hanzo.getAttribute', { selector: rest.selector, attr: rest.attr }, b);
|
||||
|
||||
case 'set_attribute':
|
||||
return this.sendRaw('hanzo.setAttribute', { selector: rest.selector, attr: rest.attr, value: rest.value }, b);
|
||||
|
||||
case 'remove_attribute':
|
||||
return this.sendRaw('hanzo.removeAttribute', { selector: rest.selector, attr: rest.attr }, b);
|
||||
|
||||
case 'set_style':
|
||||
return this.sendRaw('hanzo.setStyle', { selector: rest.selector, styles: rest.styles }, b);
|
||||
|
||||
case 'add_class':
|
||||
return this.sendRaw('hanzo.addClass', { selector: rest.selector, classNames: rest.classNames }, b);
|
||||
|
||||
case 'remove_class':
|
||||
return this.sendRaw('hanzo.removeClass', { selector: rest.selector, classNames: rest.classNames }, b);
|
||||
|
||||
case 'insert_element':
|
||||
return this.sendRaw('hanzo.insertElement', { selector: rest.selector, html: rest.html, position: rest.position }, b);
|
||||
|
||||
case 'remove_element':
|
||||
return this.sendRaw('hanzo.removeElement', { selector: rest.selector }, b);
|
||||
|
||||
case 'observe_mutations':
|
||||
return this.sendRaw('hanzo.observeMutations', { selector: rest.selector, options: rest.options, duration: rest.duration }, b);
|
||||
|
||||
case 'computed_styles':
|
||||
return this.sendRaw('hanzo.getComputedStyles', { selector: rest.selector, properties: rest.properties }, b);
|
||||
|
||||
case 'bounding_rects':
|
||||
return this.sendRaw('hanzo.getBoundingRects', { selector: rest.selector }, b);
|
||||
|
||||
case 'inject_script':
|
||||
return this.sendRaw('hanzo.injectScript', { url: rest.url }, b);
|
||||
|
||||
case 'inject_css':
|
||||
return this.sendRaw('hanzo.injectCSS', { css: rest.css }, b);
|
||||
|
||||
case 'local_storage':
|
||||
if (rest.value !== undefined) {
|
||||
return this.sendRaw('hanzo.setLocalStorage', tabTarget({ key: rest.key, value: rest.value }), b);
|
||||
}
|
||||
return this.sendRaw('hanzo.getLocalStorage', tabTarget({ key: rest.key }), b);
|
||||
|
||||
case 'cookies':
|
||||
return this.sendRaw('hanzo.getCookies', tabTarget(), b);
|
||||
|
||||
// Evaluate
|
||||
case 'evaluate': {
|
||||
const evalResult = await this.sendRaw('Runtime.evaluate', tabTarget({
|
||||
expression: rest.code || rest.function || rest.expression,
|
||||
returnByValue: true,
|
||||
}), b);
|
||||
// Unwrap CDP result envelope. Handle every shape extensions can hand
|
||||
// back: {result:{value}} (Chrome), {result:{value,type}} (CDP), bare
|
||||
// value, undefined-as-empty-object ({}).
|
||||
const inner = evalResult?.result;
|
||||
if (inner && typeof inner === 'object' && 'value' in inner) {
|
||||
return { result: (inner as any).value, raw: evalResult };
|
||||
}
|
||||
return { result: inner ?? evalResult };
|
||||
}
|
||||
|
||||
// Wait
|
||||
case 'wait':
|
||||
await new Promise(resolve => setTimeout(resolve, (rest.timeout || 1) * 1000));
|
||||
return { waited: rest.timeout || 1 };
|
||||
|
||||
case 'wait_for_load':
|
||||
return this.sendRaw('Page.waitForLoadState', tabTarget({
|
||||
state: rest.state || 'load',
|
||||
}), b);
|
||||
|
||||
// Tabs
|
||||
case 'tabs':
|
||||
case 'list_tabs':
|
||||
return this.sendRaw('Target.getTargets', undefined, b);
|
||||
|
||||
case 'new_tab':
|
||||
return this.sendRaw('Target.createTarget', { url: rest.url || 'about:blank' }, b);
|
||||
|
||||
case 'close_tab':
|
||||
return this.sendRaw('Target.closeTarget', { targetId: rest.tabId }, b);
|
||||
|
||||
case 'select_tab':
|
||||
return this.sendRaw('Target.activateTarget', { targetId: rest.tabId }, b);
|
||||
|
||||
// Console/Network — routed to PageMonitor via extension background
|
||||
case 'console_messages':
|
||||
case 'console':
|
||||
case 'console_logs':
|
||||
return this.sendToExtension('monitor.consoleLogs', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'console_errors':
|
||||
return this.sendToExtension('monitor.consoleErrors', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'network_requests':
|
||||
case 'network_logs':
|
||||
return this.sendToExtension('monitor.networkLogs', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'network_errors':
|
||||
return this.sendToExtension('monitor.networkErrors', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'network_success':
|
||||
return this.sendToExtension('monitor.networkSuccess', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'wipe_logs':
|
||||
return this.sendToExtension('monitor.wipeLogs', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'start_monitoring':
|
||||
return this.sendToExtension('monitor.start', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'stop_monitoring':
|
||||
return this.sendToExtension('monitor.stop', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'monitor_status':
|
||||
return this.sendToExtension('monitor.status', {}, b);
|
||||
|
||||
// Audits — routed to AuditRunner via extension background
|
||||
case 'accessibility_audit':
|
||||
return this.sendToExtension('audit.accessibility', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'performance_audit':
|
||||
return this.sendToExtension('audit.performance', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'seo_audit':
|
||||
return this.sendToExtension('audit.seo', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'best_practices_audit':
|
||||
return this.sendToExtension('audit.bestPractices', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'full_audit':
|
||||
return this.sendToExtension('audit.full', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'nextjs_audit':
|
||||
return this.sendToExtension('audit.nextjs', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'debugger_mode':
|
||||
case 'debug':
|
||||
return this.sendToExtension('debugger.mode', { tabId: rest.tabId }, b);
|
||||
|
||||
// Ollama / Local LLM
|
||||
case 'ollama_models':
|
||||
return this.sendToExtension('ollama.models', {}, b);
|
||||
|
||||
case 'ollama_chat':
|
||||
return this.sendToExtension('ollama.chat', { model: rest.model, messages: rest.messages, options: rest.options }, b);
|
||||
|
||||
case 'local_chat':
|
||||
return this.sendToExtension('local.chat', { provider: rest.provider, endpoint: rest.endpoint, model: rest.model, messages: rest.messages, options: rest.options, apiKey: rest.apiKey }, b);
|
||||
|
||||
case 'local_models':
|
||||
return this.sendToExtension('local.models', { provider: rest.provider, endpoint: rest.endpoint, apiKey: rest.apiKey }, b);
|
||||
|
||||
case 'local_discover':
|
||||
return this.sendToExtension('local.discover', {}, b);
|
||||
|
||||
// Model Hub — browse, search, download
|
||||
case 'hub_search':
|
||||
return this.sendToExtension('hub.search', { query: rest.query, filter: rest.filter, sort: rest.sort, limit: rest.limit, author: rest.author }, b);
|
||||
|
||||
case 'hub_search_gguf':
|
||||
return this.sendToExtension('hub.searchGGUF', { query: rest.query, limit: rest.limit }, b);
|
||||
|
||||
case 'hub_model':
|
||||
return this.sendToExtension('hub.model', { modelId: rest.modelId }, b);
|
||||
|
||||
case 'hub_recommended':
|
||||
return this.sendToExtension('hub.recommended', {}, b);
|
||||
|
||||
case 'hub_search_ollama':
|
||||
return this.sendToExtension('hub.searchOllama', { query: rest.query }, b);
|
||||
|
||||
case 'hub_download_ollama':
|
||||
return this.sendToExtension('hub.downloadOllama', { modelName: rest.modelName, ollamaUrl: rest.ollamaUrl }, b);
|
||||
|
||||
case 'hub_download_hf':
|
||||
return this.sendToExtension('hub.downloadHF', { downloadUrl: rest.downloadUrl }, b);
|
||||
|
||||
case 'hub_all_models':
|
||||
return this.sendToExtension('hub.allModels', {}, b);
|
||||
|
||||
case 'hub_search_mlx':
|
||||
return this.sendToExtension('hub.searchMLX', { query: rest.query, limit: rest.limit }, b);
|
||||
|
||||
case 'hub_model_card':
|
||||
return this.sendToExtension('hub.modelCard', { modelId: rest.modelId }, b);
|
||||
|
||||
case 'hub_model_stats':
|
||||
return this.sendToExtension('hub.modelStats', { modelId: rest.modelId }, b);
|
||||
|
||||
// Status
|
||||
case 'status':
|
||||
return {
|
||||
connected: this.clients.size > 0,
|
||||
clients: this.clients.size,
|
||||
browsers: this.getConnectedBrowserDetails(),
|
||||
port: this.httpPort
|
||||
};
|
||||
|
||||
default:
|
||||
// Pass through as raw CDP command
|
||||
return this.sendRaw(action, rest, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Send a message to the extension background and wait for response
|
||||
private sendToExtension(action: string, params: any, targetBrowser?: string): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
const client = this.resolveClient(targetBrowser);
|
||||
if (!client) {
|
||||
const connected = this.getConnectedBrowsers();
|
||||
resolve({
|
||||
error: targetBrowser
|
||||
? `Browser "${targetBrowser}" not connected. Connected: [${connected.join(', ')}]`
|
||||
: 'No browser extension connected'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `ext-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingExtensionRequests.delete(id);
|
||||
resolve({ error: `Extension request timed out (browser: ${client.id})` });
|
||||
}, 30000);
|
||||
|
||||
this.pendingExtensionRequests.set(id, { resolve, timeout });
|
||||
|
||||
client.ws.send(JSON.stringify({
|
||||
type: 'extension-request',
|
||||
id,
|
||||
action,
|
||||
params,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.clients.size > 0;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.wss.close();
|
||||
}
|
||||
}
|
||||
|
||||
// JSON-RPC server for MCP integration
|
||||
async function startJSONRPCServer(bridgeServer: CDPBridgeServer) {
|
||||
const http = require('http');
|
||||
|
||||
const httpServer = http.createServer(async (req: any, res: any) => {
|
||||
if (req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', (chunk: string) => body += chunk);
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const request = JSON.parse(body);
|
||||
const result = await bridgeServer.browser(request.params || request);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
// Return result directly (no extra wrapping — Python expects flat dict)
|
||||
res.end(JSON.stringify(result && typeof result === 'object' ? result : { result }));
|
||||
} catch (error: any) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: error.message }));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// GET - return status
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
service: 'hanzo.browser',
|
||||
connected: bridgeServer.isConnected(),
|
||||
browsers: bridgeServer.getConnectedBrowserDetails(),
|
||||
actions: [
|
||||
'list_browsers', 'browsers',
|
||||
'navigate', 'navigate_back', 'reload', 'url', 'title', 'content',
|
||||
'screenshot', 'snapshot',
|
||||
'click', 'dblclick', 'hover', 'type', 'fill', 'clear', 'press_key',
|
||||
'select_option', 'check', 'uncheck',
|
||||
'evaluate',
|
||||
'go_back', 'go_forward', 'get_url', 'get_title', 'get_tab_info',
|
||||
'wait_for_navigation', 'get_history', 'create_tab', 'close_tab',
|
||||
'fetch',
|
||||
'get_html', 'set_html', 'get_text', 'set_text',
|
||||
'get_attribute', 'set_attribute', 'remove_attribute',
|
||||
'set_style', 'add_class', 'remove_class',
|
||||
'insert_element', 'remove_element',
|
||||
'observe_mutations', 'computed_styles', 'bounding_rects',
|
||||
'inject_script', 'inject_css',
|
||||
'local_storage', 'cookies',
|
||||
'wait', 'wait_for_load',
|
||||
'tabs', 'new_tab', 'close_tab', 'select_tab',
|
||||
'console_logs', 'console_errors', 'network_logs', 'network_errors', 'network_success',
|
||||
'start_monitoring', 'stop_monitoring', 'monitor_status', 'wipe_logs',
|
||||
'accessibility_audit', 'performance_audit', 'seo_audit', 'best_practices_audit', 'full_audit',
|
||||
'nextjs_audit', 'debugger_mode',
|
||||
'ollama_models', 'ollama_chat', 'local_chat', 'local_models', 'local_discover',
|
||||
'hub_search', 'hub_search_gguf', 'hub_search_mlx', 'hub_model', 'hub_model_card',
|
||||
'hub_model_stats', 'hub_recommended',
|
||||
'hub_search_ollama', 'hub_download_ollama', 'hub_download_hf', 'hub_all_models',
|
||||
'takeover.start', 'takeover.end', 'takeover.cursor',
|
||||
'status'
|
||||
]
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
httpServer.listen(9224, () => {
|
||||
console.log('[hanzo.browser] HTTP API at http://localhost:9224');
|
||||
});
|
||||
}
|
||||
|
||||
// Interactive CLI
|
||||
async function main() {
|
||||
const port = parseInt(process.env.CDP_PORT || '9223');
|
||||
const server = new CDPBridgeServer(port);
|
||||
|
||||
// Start HTTP API
|
||||
startJSONRPCServer(server);
|
||||
|
||||
console.log('\n[hanzo.browser] Commands:');
|
||||
console.log(' navigate <url> - Go to URL');
|
||||
console.log(' screenshot [file] - Capture screen');
|
||||
console.log(' click <selector> - Click element');
|
||||
console.log(' type <sel> <text> - Type into element');
|
||||
console.log(' eval <js> - Run JavaScript');
|
||||
console.log(' tabs - List all tabs');
|
||||
console.log(' status - Connection status');
|
||||
console.log(' quit - Exit\n');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
const prompt = () => {
|
||||
rl.question('hanzo.browser> ', async (line) => {
|
||||
const parts = line.trim().split(' ');
|
||||
const action = parts[0];
|
||||
const args = parts.slice(1);
|
||||
|
||||
if (!action) {
|
||||
prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
switch (action) {
|
||||
case 'quit':
|
||||
case 'exit':
|
||||
server.close();
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
|
||||
case 'navigate':
|
||||
result = await server.browser({ action: 'navigate', url: args[0] });
|
||||
console.log('Navigated to:', args[0]);
|
||||
break;
|
||||
|
||||
case 'screenshot':
|
||||
result = await server.browser({
|
||||
action: 'screenshot',
|
||||
filename: args[0] || `screenshot-${Date.now()}.png`
|
||||
});
|
||||
console.log('Screenshot:', result);
|
||||
break;
|
||||
|
||||
case 'click':
|
||||
result = await server.browser({ action: 'click', selector: args[0] });
|
||||
console.log('Clicked:', args[0]);
|
||||
break;
|
||||
|
||||
case 'type':
|
||||
result = await server.browser({
|
||||
action: 'type',
|
||||
selector: args[0],
|
||||
text: args.slice(1).join(' ')
|
||||
});
|
||||
console.log('Typed into:', args[0]);
|
||||
break;
|
||||
|
||||
case 'eval':
|
||||
result = await server.browser({
|
||||
action: 'evaluate',
|
||||
expression: args.join(' ')
|
||||
});
|
||||
console.log('Result:', result?.result?.value);
|
||||
break;
|
||||
|
||||
case 'tabs':
|
||||
result = await server.browser({ action: 'tabs' });
|
||||
console.log('Tabs:', result?.targetInfos?.map((t: any) => t.title).join(', '));
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
result = await server.browser({ action: 'status' });
|
||||
console.log('Status:', result);
|
||||
break;
|
||||
|
||||
case 'url':
|
||||
result = await server.browser({ action: 'url' });
|
||||
console.log('URL:', result?.result?.value);
|
||||
break;
|
||||
|
||||
case 'title':
|
||||
result = await server.browser({ action: 'title' });
|
||||
console.log('Title:', result?.result?.value);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Try as raw action
|
||||
result = await server.browser({ action, ...Object.fromEntries(
|
||||
args.map((a, i) => [i === 0 ? 'selector' : `arg${i}`, a])
|
||||
)});
|
||||
console.log('Result:', result);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
|
||||
prompt();
|
||||
});
|
||||
};
|
||||
|
||||
prompt();
|
||||
}
|
||||
|
||||
export { CDPBridgeServer, BrowserCommand };
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Textarea,
|
||||
} from '@hanzo/ui/primitives-common';
|
||||
} from '@hanzo/gui';
|
||||
|
||||
function LoginPrompt() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// Hanzo Browser Extension - Content Script
|
||||
// Connects clicked elements to source code via source-maps
|
||||
//
|
||||
// Polyfill MUST be the first import so any subsequent code that touches
|
||||
// `browser.*` (the cross-browser API surface) sees the wrapped Promise
|
||||
// API on Chrome too. On native runtimes (Firefox / Safari) the import is
|
||||
// a no-op.
|
||||
import 'webextension-polyfill';
|
||||
|
||||
import {
|
||||
DEFAULT_INSPECT_SHORTCUT,
|
||||
@@ -109,12 +115,22 @@ class HanzoContentScript {
|
||||
sendResponse({ success: true });
|
||||
return true;
|
||||
case 'page.overlay.toggle':
|
||||
if (request.side === 'left' || request.side === 'right') pageOverlay.setSide(request.side);
|
||||
if (request.mode === 'push' || request.mode === 'overlay') pageOverlay.setMode(request.mode);
|
||||
if (request.width === 'compact' || request.width === 'default' || request.width === 'wide') {
|
||||
pageOverlay.setWidth(request.width);
|
||||
}
|
||||
sendResponse({
|
||||
success: true,
|
||||
open: pageOverlay.toggle(),
|
||||
});
|
||||
return true;
|
||||
case 'page.overlay.show':
|
||||
if (request.side === 'left' || request.side === 'right') pageOverlay.setSide(request.side);
|
||||
if (request.mode === 'push' || request.mode === 'overlay') pageOverlay.setMode(request.mode);
|
||||
if (request.width === 'compact' || request.width === 'default' || request.width === 'wide') {
|
||||
pageOverlay.setWidth(request.width);
|
||||
}
|
||||
pageOverlay.show();
|
||||
sendResponse({ success: true, open: true });
|
||||
return true;
|
||||
@@ -123,7 +139,44 @@ class HanzoContentScript {
|
||||
sendResponse({ success: true, open: false });
|
||||
return true;
|
||||
case 'page.overlay.status':
|
||||
sendResponse({ success: true, open: pageOverlay.isOpen() });
|
||||
sendResponse({
|
||||
success: true,
|
||||
open: pageOverlay.isOpen(),
|
||||
side: pageOverlay.getSide(),
|
||||
mode: pageOverlay.getMode(),
|
||||
});
|
||||
return true;
|
||||
case 'page.overlay.setSide':
|
||||
if (request.side === 'left' || request.side === 'right') {
|
||||
pageOverlay.setSide(request.side);
|
||||
sendResponse({ success: true, side: request.side });
|
||||
} else {
|
||||
sendResponse({ success: false, error: 'side must be "left" or "right"' });
|
||||
}
|
||||
return true;
|
||||
case 'page.overlay.setMode':
|
||||
if (request.mode === 'push' || request.mode === 'overlay') {
|
||||
pageOverlay.setMode(request.mode);
|
||||
sendResponse({ success: true, mode: request.mode });
|
||||
} else {
|
||||
sendResponse({ success: false, error: 'mode must be "push" or "overlay"' });
|
||||
}
|
||||
return true;
|
||||
case 'page.overlay.setWidth':
|
||||
if (request.width === 'compact' || request.width === 'default' || request.width === 'wide') {
|
||||
pageOverlay.setWidth(request.width);
|
||||
sendResponse({ success: true, width: request.width });
|
||||
} else {
|
||||
sendResponse({ success: false, error: 'width must be compact|default|wide' });
|
||||
}
|
||||
return true;
|
||||
case 'page.overlay.setEnabled':
|
||||
if (typeof request.enabled === 'boolean') {
|
||||
pageOverlay.setEnabled(request.enabled);
|
||||
sendResponse({ success: true, enabled: request.enabled });
|
||||
} else {
|
||||
sendResponse({ success: false, error: 'enabled must be boolean' });
|
||||
}
|
||||
return true;
|
||||
|
||||
// --- Popup Tool Actions ---
|
||||
@@ -748,10 +801,22 @@ interface OverlayElementContext {
|
||||
html: string;
|
||||
}
|
||||
|
||||
type OverlayMode = 'overlay' | 'push';
|
||||
type OverlayWidth = 'compact' | 'default' | 'wide';
|
||||
|
||||
const OVERLAY_WIDTH_PX: Record<OverlayWidth, number> = {
|
||||
compact: 320,
|
||||
default: 420,
|
||||
wide: 560,
|
||||
};
|
||||
|
||||
class HanzoPageOverlay {
|
||||
private mounted = false;
|
||||
private enabled = false;
|
||||
private open = false;
|
||||
private side: 'left' | 'right' = 'right';
|
||||
private mode: OverlayMode = 'overlay';
|
||||
private width: OverlayWidth = 'default';
|
||||
private sending = false;
|
||||
private watchMode = false;
|
||||
private editMode = false;
|
||||
@@ -778,6 +843,53 @@ class HanzoPageOverlay {
|
||||
this.injectStyles();
|
||||
this.mount();
|
||||
this.bindGlobalEvents();
|
||||
this.restorePrefs();
|
||||
}
|
||||
|
||||
private restorePrefs(): void {
|
||||
try {
|
||||
const ss = (typeof browser !== 'undefined' ? browser : (chrome as any))?.storage?.sync;
|
||||
if (!ss?.get) return;
|
||||
const promised = ss.get(['overlaySide', 'overlayMode', 'overlayWidth']);
|
||||
const apply = (v: any) => {
|
||||
if (v?.overlaySide === 'left' || v?.overlaySide === 'right') {
|
||||
this.side = v.overlaySide;
|
||||
this.root?.setAttribute('data-side', this.side);
|
||||
}
|
||||
if (v?.overlayMode === 'push' || v?.overlayMode === 'overlay') {
|
||||
this.mode = v.overlayMode;
|
||||
this.root?.setAttribute('data-mode', this.mode);
|
||||
}
|
||||
if (v?.overlayWidth === 'compact' || v?.overlayWidth === 'default' || v?.overlayWidth === 'wide') {
|
||||
this.width = v.overlayWidth;
|
||||
this.applyWidth();
|
||||
}
|
||||
this.applyPushIfOpen();
|
||||
};
|
||||
if (promised && typeof promised.then === 'function') {
|
||||
promised.then(apply).catch(() => {});
|
||||
} else {
|
||||
ss.get(['overlaySide', 'overlayMode', 'overlayWidth'], apply);
|
||||
}
|
||||
} catch { /* storage may be unavailable */ }
|
||||
}
|
||||
|
||||
private applyWidth(): void {
|
||||
const px = OVERLAY_WIDTH_PX[this.width] ?? OVERLAY_WIDTH_PX.default;
|
||||
if (this.root) {
|
||||
this.root.style.setProperty('--hanzo-overlay-width', `${px}px`);
|
||||
this.root.style.width = `min(${px}px, 100vw)`;
|
||||
}
|
||||
document.documentElement.style.setProperty('--hanzo-overlay-width', `${px}px`);
|
||||
}
|
||||
|
||||
private applyPushIfOpen(): void {
|
||||
const html = document.documentElement;
|
||||
// Always strip first so we never stack margins.
|
||||
html.classList.remove('hanzo-overlay-push', 'hanzo-overlay-push-left', 'hanzo-overlay-push-right');
|
||||
if (this.open && this.enabled && this.mode === 'push') {
|
||||
html.classList.add('hanzo-overlay-push', `hanzo-overlay-push-${this.side}`);
|
||||
}
|
||||
}
|
||||
|
||||
toggle(): boolean {
|
||||
@@ -789,12 +901,59 @@ class HanzoPageOverlay {
|
||||
return false;
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.open;
|
||||
}
|
||||
|
||||
getSide(): 'left' | 'right' {
|
||||
return this.side;
|
||||
}
|
||||
|
||||
getMode(): OverlayMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
setSide(side: 'left' | 'right'): void {
|
||||
this.side = side;
|
||||
this.root?.setAttribute('data-side', side);
|
||||
this.applyPushIfOpen();
|
||||
this.persist({ overlaySide: side });
|
||||
}
|
||||
|
||||
setMode(mode: OverlayMode): void {
|
||||
this.mode = mode;
|
||||
this.root?.setAttribute('data-mode', mode);
|
||||
this.applyPushIfOpen();
|
||||
this.persist({ overlayMode: mode });
|
||||
}
|
||||
|
||||
setWidth(width: OverlayWidth): void {
|
||||
this.width = width;
|
||||
this.applyWidth();
|
||||
this.persist({ overlayWidth: width });
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled;
|
||||
this.root?.setAttribute('data-enabled', enabled ? 'true' : 'false');
|
||||
if (!enabled) this.hide();
|
||||
this.persist({ overlayEnabled: enabled });
|
||||
}
|
||||
|
||||
private persist(values: Record<string, unknown>): void {
|
||||
try {
|
||||
const ss = (typeof browser !== 'undefined' ? browser : (chrome as any))?.storage?.sync;
|
||||
ss?.set?.(values);
|
||||
} catch { /* storage may be unavailable */ }
|
||||
}
|
||||
|
||||
show(): void {
|
||||
this.ensureMounted();
|
||||
this.enabled = true;
|
||||
this.open = true;
|
||||
this.root?.setAttribute('data-enabled', 'true');
|
||||
this.root?.setAttribute('data-open', 'true');
|
||||
this.applyPushIfOpen();
|
||||
this.loadModels();
|
||||
this.focusInput();
|
||||
}
|
||||
@@ -802,16 +961,13 @@ class HanzoPageOverlay {
|
||||
hide(): void {
|
||||
this.open = false;
|
||||
this.root?.setAttribute('data-open', 'false');
|
||||
this.applyPushIfOpen();
|
||||
this.updateStatus('');
|
||||
this.clearHighlight();
|
||||
this.setWatchMode(false);
|
||||
this.setEditMode(false);
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.open;
|
||||
}
|
||||
|
||||
private ensureMounted(): void {
|
||||
if (this.mounted) return;
|
||||
this.mount();
|
||||
@@ -823,74 +979,68 @@ class HanzoPageOverlay {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'hanzo-page-overlay-styles';
|
||||
style.textContent = `
|
||||
/* Edge-pinned sidebar panel — no FAB, opened from extension popup.
|
||||
Hanzo brand: monochrome (white primary, neutral surfaces). No blue. */
|
||||
#hanzo-page-overlay-root {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: min(var(--hanzo-overlay-width, 420px), 100vw);
|
||||
z-index: 2147483645;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#hanzo-page-overlay-root[data-side="left"] {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#hanzo-page-overlay-root * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#hanzo-page-overlay-root[data-enabled="false"] .hanzo-page-launcher {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
pointer-events: none;
|
||||
/* Push mode: when the user pins the panel as a dock, html shifts so
|
||||
page content reflows beside it instead of being covered. Applied to
|
||||
<html> from setMode('push') + open. */
|
||||
html.hanzo-overlay-push.hanzo-overlay-push-right {
|
||||
margin-right: var(--hanzo-overlay-width, 420px) !important;
|
||||
transition: margin-right 0.18s ease;
|
||||
}
|
||||
|
||||
.hanzo-page-launcher {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: linear-gradient(135deg, rgba(22, 24, 31, 0.96), rgba(7, 8, 12, 0.96));
|
||||
color: #ffffff;
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.hanzo-page-launcher:hover {
|
||||
transform: translateY(-2px);
|
||||
html.hanzo-overlay-push.hanzo-overlay-push-left {
|
||||
margin-left: var(--hanzo-overlay-width, 420px) !important;
|
||||
transition: margin-left 0.18s ease;
|
||||
}
|
||||
|
||||
.hanzo-page-panel {
|
||||
width: min(420px, calc(100vw - 24px));
|
||||
max-height: min(620px, calc(100vh - 28px));
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: linear-gradient(180deg, rgba(11, 13, 18, 0.97), rgba(7, 8, 12, 0.97));
|
||||
color: #eef2ff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: linear-gradient(180deg, rgba(10, 10, 11, 0.97), rgba(7, 7, 8, 0.97));
|
||||
color: #f5f5f5;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.45);
|
||||
transform-origin: bottom right;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
box-shadow: -22px 0 48px rgba(0, 0, 0, 0.45);
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 10px;
|
||||
min-height: 0;
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
|
||||
#hanzo-page-overlay-root[data-open="true"] .hanzo-page-panel {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
#hanzo-page-overlay-root[data-side="left"] .hanzo-page-panel {
|
||||
border-left: none;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.16);
|
||||
box-shadow: 22px 0 48px rgba(0, 0, 0, 0.45);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
#hanzo-page-overlay-root[data-open="true"] .hanzo-page-launcher {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
pointer-events: none;
|
||||
#hanzo-page-overlay-root[data-open="true"] .hanzo-page-panel {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hanzo-page-header {
|
||||
@@ -898,14 +1048,16 @@ class HanzoPageOverlay {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.10);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hanzo-page-brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
font-size: 13px;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.hanzo-page-header-actions {
|
||||
@@ -918,16 +1070,22 @@ class HanzoPageOverlay {
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 9999px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: #d6deff;
|
||||
background: transparent;
|
||||
color: rgba(245, 245, 245, 0.85);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.hanzo-chip-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hanzo-chip-btn[data-active="true"] {
|
||||
border-color: rgba(56, 189, 248, 0.62);
|
||||
background: rgba(14, 116, 144, 0.3);
|
||||
color: #e0f2fe;
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hanzo-icon-btn {
|
||||
@@ -936,9 +1094,28 @@ class HanzoPageOverlay {
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: transparent;
|
||||
color: #e5e7eb;
|
||||
color: #f5f5f5;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.hanzo-icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.hanzo-icon-btn[data-active="true"] {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.hanzo-icon-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.hanzo-page-meta {
|
||||
@@ -947,11 +1124,12 @@ class HanzoPageOverlay {
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hanzo-page-context {
|
||||
font-size: 11px;
|
||||
color: #a5b4fc;
|
||||
color: rgba(245, 245, 245, 0.65);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -959,11 +1137,12 @@ class HanzoPageOverlay {
|
||||
|
||||
.hanzo-page-status {
|
||||
font-size: 11px;
|
||||
color: #93c5fd;
|
||||
color: rgba(245, 245, 245, 0.55);
|
||||
}
|
||||
|
||||
.hanzo-model-row {
|
||||
padding: 0 14px 8px 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hanzo-model-row select {
|
||||
@@ -971,15 +1150,16 @@ class HanzoPageOverlay {
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: #f1f5f9;
|
||||
color: #f5f5f5;
|
||||
padding: 7px 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* The chat scroller: takes ALL remaining vertical space so the
|
||||
composer below stays sticky against the bottom of the panel. */
|
||||
.hanzo-page-messages {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
max-height: 370px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px 14px 12px 14px;
|
||||
display: flex;
|
||||
@@ -998,23 +1178,27 @@ class HanzoPageOverlay {
|
||||
.hanzo-page-msg.user {
|
||||
align-self: flex-end;
|
||||
max-width: 88%;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border: 1px solid rgba(59, 130, 246, 0.4);
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.hanzo-page-msg.assistant {
|
||||
align-self: flex-start;
|
||||
max-width: 92%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
|
||||
/* Composer pinned to bottom of the panel. flex-shrink: 0 prevents
|
||||
it from collapsing when the messages scroller grows. */
|
||||
.hanzo-page-composer {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.10);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hanzo-page-composer textarea {
|
||||
@@ -1025,21 +1209,31 @@ class HanzoPageOverlay {
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: #f8fafc;
|
||||
color: #f5f5f5;
|
||||
padding: 9px 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Primary action: WHITE per @hanzo/brand monochrome guidelines. */
|
||||
.hanzo-page-composer button {
|
||||
min-width: 68px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(37, 99, 235, 0.65);
|
||||
background: linear-gradient(180deg, rgba(37, 99, 235, 0.95), rgba(30, 64, 175, 0.95));
|
||||
color: #eff6ff;
|
||||
border: 1px solid #ffffff;
|
||||
background: #ffffff;
|
||||
color: #0a0a0b;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, transform 0.08s;
|
||||
}
|
||||
|
||||
.hanzo-page-composer button:hover:not(:disabled) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.hanzo-page-composer button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.hanzo-page-composer button:disabled {
|
||||
@@ -1051,13 +1245,13 @@ class HanzoPageOverlay {
|
||||
position: fixed;
|
||||
z-index: 2147483644;
|
||||
pointer-events: none;
|
||||
border: 2px solid rgba(56, 189, 248, 0.95);
|
||||
border: 2px solid rgba(255, 255, 255, 0.95);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 2px rgba(14, 116, 144, 0.2);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
[data-hanzo-inline-editing="true"] {
|
||||
outline: 2px solid rgba(251, 191, 36, 0.92) !important;
|
||||
outline: 2px solid rgba(255, 255, 255, 0.92) !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
`;
|
||||
@@ -1071,14 +1265,22 @@ class HanzoPageOverlay {
|
||||
this.root.id = 'hanzo-page-overlay-root';
|
||||
this.root.setAttribute('data-open', 'false');
|
||||
this.root.setAttribute('data-enabled', 'false');
|
||||
this.root.setAttribute('data-side', this.side);
|
||||
this.root.setAttribute('data-mode', this.mode);
|
||||
this.applyWidth();
|
||||
this.root.innerHTML = `
|
||||
<div class="hanzo-page-panel" role="dialog" aria-label="Hanzo Page Assistant">
|
||||
<div class="hanzo-page-header">
|
||||
<div class="hanzo-page-brand">Hanzo Overlay</div>
|
||||
<div class="hanzo-page-brand">Hanzo</div>
|
||||
<div class="hanzo-page-header-actions">
|
||||
<button class="hanzo-chip-btn" data-role="watch" data-active="false" title="Watch elements on hover">Watch</button>
|
||||
<button class="hanzo-chip-btn" data-role="edit" data-active="false" title="Click any element to edit text">Edit</button>
|
||||
<button class="hanzo-icon-btn" data-role="close" title="Minimize">×</button>
|
||||
<button class="hanzo-icon-btn" data-role="pin-mode" title="Toggle: pin (push content) vs overlay (float over page)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M9 4v16M3 4h12a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="hanzo-icon-btn" data-role="close" title="Hide overlay">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hanzo-page-meta">
|
||||
@@ -1102,11 +1304,10 @@ class HanzoPageOverlay {
|
||||
<button data-role="send" type="button">Ask</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="hanzo-page-launcher" data-role="launcher" type="button" title="Open Hanzo Overlay">AI</button>
|
||||
`;
|
||||
|
||||
this.panel = this.root.querySelector('.hanzo-page-panel');
|
||||
this.launcher = this.root.querySelector('[data-role="launcher"]');
|
||||
this.launcher = null;
|
||||
this.messagesEl = this.root.querySelector('[data-role="messages"]');
|
||||
this.inputEl = this.root.querySelector('[data-role="input"]');
|
||||
this.sendBtn = this.root.querySelector('[data-role="send"]');
|
||||
@@ -1116,8 +1317,14 @@ class HanzoPageOverlay {
|
||||
this.editBtn = this.root.querySelector('[data-role="edit"]');
|
||||
this.statusEl = this.root.querySelector('.hanzo-page-status');
|
||||
|
||||
this.launcher?.addEventListener('click', () => this.show());
|
||||
this.root.querySelector('[data-role="close"]')?.addEventListener('click', () => this.hide());
|
||||
const pinModeBtn = this.root.querySelector('[data-role="pin-mode"]');
|
||||
pinModeBtn?.setAttribute('data-active', this.mode === 'push' ? 'true' : 'false');
|
||||
pinModeBtn?.addEventListener('click', () => {
|
||||
const next: OverlayMode = this.mode === 'push' ? 'overlay' : 'push';
|
||||
this.setMode(next);
|
||||
pinModeBtn.setAttribute('data-active', next === 'push' ? 'true' : 'false');
|
||||
});
|
||||
this.watchBtn?.addEventListener('click', () => this.setWatchMode(!this.watchMode));
|
||||
this.editBtn?.addEventListener('click', () => this.setEditMode(!this.editMode));
|
||||
this.sendBtn?.addEventListener('click', () => this.askPageQuestion());
|
||||
@@ -1733,3 +1940,27 @@ const inlineConsole = (() => {
|
||||
|
||||
// Initialize
|
||||
new HanzoContentScript();
|
||||
|
||||
// =============================================================================
|
||||
// Background keep-alive port.
|
||||
//
|
||||
// Firefox MV3 treats `background.scripts` as event pages; Chrome MV3 service
|
||||
// workers idle out after ~30s. An open `chrome.runtime` port from any
|
||||
// content script keeps the background page alive for the lifetime of the
|
||||
// port — no polling, no alarms required. Every loaded http(s) tab holds
|
||||
// one of these, so the background ZAP socket survives indefinitely as
|
||||
// long as the user has any non-privileged tab open.
|
||||
//
|
||||
// Heartbeats every 10s prevent Firefox's 4-minute idle-port disconnect.
|
||||
// =============================================================================
|
||||
try {
|
||||
const port = (browser as any)?.runtime?.connect?.({ name: 'zap-keepalive' })
|
||||
|| (chrome as any)?.runtime?.connect?.({ name: 'zap-keepalive' });
|
||||
if (port) {
|
||||
const tick = setInterval(() => {
|
||||
try { port.postMessage({ type: 'ping', t: Date.now() }); }
|
||||
catch { clearInterval(tick); }
|
||||
}, 10_000);
|
||||
port.onDisconnect?.addListener?.(() => clearInterval(tick));
|
||||
}
|
||||
} catch { /* not all pages let content scripts open ports — best effort */ }
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Local @hanzo/gui primitives shim for the extension.
|
||||
*
|
||||
* This is the canonical surface the extension's React components import via
|
||||
* `@hanzo/gui`. It deliberately does NOT pull in the upstream tamagui-based
|
||||
* `@hanzo/gui` package because:
|
||||
*
|
||||
* 1. content-script + background bundles target the smallest possible
|
||||
* payload — RNW + tamagui's runtime is far too heavy for a script
|
||||
* injected into every page;
|
||||
* 2. Hanzo brand is strict monochrome (see ~/work/hanzo/brand/brand.json,
|
||||
* primaryColor #FFFFFF), so no color tokens travel with primitives
|
||||
* anyway — pure structural elements styled by sibling CSS;
|
||||
* 3. The full tamagui adoption ships in a future feature branch with the
|
||||
* compiler + RNW peer dep wired correctly.
|
||||
*
|
||||
* The build aliases `@hanzo/gui` → this file. When the tamagui adoption
|
||||
* lands, swap the alias target to the published `@hanzo/gui` package.
|
||||
*
|
||||
* Each primitive renders a plain DOM element with a stable, monochrome
|
||||
* class hook. All visuals come from sidebar.css / popup.css using the
|
||||
* brand token scale (white luminance ladder, no chromatic accents).
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
type Classy = { className?: string; children?: React.ReactNode };
|
||||
|
||||
function cx(base: string, extra?: string): string {
|
||||
return extra ? `${base} ${extra}` : base;
|
||||
}
|
||||
|
||||
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<button {...rest} className={cx('hanzo-gui-btn', className)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<textarea {...rest} className={cx('hanzo-gui-textarea', className)}>
|
||||
{children}
|
||||
</textarea>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
const { className, ...rest } = props;
|
||||
return <input {...rest} className={cx('hanzo-gui-input', className)} />;
|
||||
}
|
||||
|
||||
export function Card({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-gui-card', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-gui-card-header', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, children }: Classy) {
|
||||
return <h3 className={cx('hanzo-gui-card-title', className)}>{children}</h3>;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, children }: Classy) {
|
||||
return <p className={cx('hanzo-gui-card-description', className)}>{children}</p>;
|
||||
}
|
||||
|
||||
export function CardContent({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-gui-card-content', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
/* Layout primitives — mirror the names used by the upstream Tamagui-based
|
||||
@hanzo/gui (XStack/YStack) so call-sites can be ported without churn
|
||||
when the full adoption lands. Pure flexbox under the hood. */
|
||||
type Stack = Classy & React.HTMLAttributes<HTMLDivElement> & {
|
||||
gap?: number | string;
|
||||
};
|
||||
|
||||
export function XStack({ className, gap, style, children, ...rest }: Stack) {
|
||||
const merged: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap,
|
||||
...style,
|
||||
};
|
||||
return (
|
||||
<div {...rest} style={merged} className={cx('hanzo-gui-xstack', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function YStack({ className, gap, style, children, ...rest }: Stack) {
|
||||
const merged: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap,
|
||||
...style,
|
||||
};
|
||||
return (
|
||||
<div {...rest} style={merged} className={cx('hanzo-gui-ystack', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Text({ className, children, ...rest }: Classy & React.HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span {...rest} className={cx('hanzo-gui-text', className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.8.8",
|
||||
"version": "1.9.22",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
@@ -10,7 +10,9 @@
|
||||
"tabs",
|
||||
"webNavigation",
|
||||
"scripting",
|
||||
"cookies"
|
||||
"cookies",
|
||||
"nativeMessaging",
|
||||
"alarms"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://localhost/*",
|
||||
@@ -45,6 +47,15 @@
|
||||
"128": "icon128.png"
|
||||
}
|
||||
},
|
||||
"sidebar_action": {
|
||||
"default_title": "Hanzo AI",
|
||||
"default_panel": "sidebar.html",
|
||||
"default_icon": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png"
|
||||
},
|
||||
"open_at_install": false
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": [
|
||||
@@ -62,13 +73,5 @@
|
||||
"strict_min_version": "109.0"
|
||||
},
|
||||
"gecko_android": {}
|
||||
},
|
||||
"data_collection_permissions": {
|
||||
"purposes": [
|
||||
"functionality"
|
||||
],
|
||||
"data_categories": [
|
||||
"technical_data"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.8.8",
|
||||
"version": "1.9.25",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxfXGh0lPUT5m04GKfjUwrLsV6pLaK3VcZuFRPogqAir2tzyLYnQPRtHynue9yvDyguIVnlkwvcwfDOYZrvH76zbw4s6onPBG8HqTKO6LQ9K3kdO1qBBkMMjdOgULQ1MrWThEbpU7NSTiwLYpEta/jAvrKRCAeKIlQE8p6htZmPy9aRUZuae66JgLcAlzD2vviX9sVB1asFABJVswL1RgZ55/8IzZaUrFjzOo9OHK4hmEOtudzkML+5silsAYdC+1BZugph2x94ai17YmZTCL1XyUa5Ke4q80cj+i9rOTgzhZs+mruyhL/AvNVOXilsgqCdNqSz77naWzC3pVGbxOewIDAQAB",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"identity",
|
||||
"sidePanel",
|
||||
"storage",
|
||||
"tabs",
|
||||
"webNavigation",
|
||||
"scripting",
|
||||
"cookies",
|
||||
"debugger"
|
||||
"debugger",
|
||||
"alarms",
|
||||
"nativeMessaging"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://localhost/*",
|
||||
@@ -45,9 +47,6 @@
|
||||
"128": "icon128.png"
|
||||
}
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidebar.html"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Hanzo AI Browser Extension Popup — aligned with @hanzo/ui dark theme */
|
||||
/* Hanzo AI Browser Extension Popup — @hanzo/gui dark theme, monochrome */
|
||||
|
||||
:root {
|
||||
--primary: #fafafa;
|
||||
@@ -13,9 +13,10 @@
|
||||
--border-strong: rgba(255,255,255,0.16);
|
||||
--glass: rgba(255,255,255,0.04);
|
||||
--glass-strong: rgba(255,255,255,0.07);
|
||||
--success: #22c55e;
|
||||
--warning: #eab308;
|
||||
--error: #ef4444;
|
||||
/* Status: monochrome by hue per @hanzo/brand (white luminance scale). */
|
||||
--success: #fafafa;
|
||||
--warning: #d4d4d4;
|
||||
--error: rgba(255,255,255,0.55);
|
||||
--link: #a3a3a3;
|
||||
--link-hover: #fafafa;
|
||||
--radius: 10px;
|
||||
|
||||
@@ -57,20 +57,43 @@
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<!-- Actions: single Open Chat button + on-page surface settings. -->
|
||||
<button id="open-panel" class="btn btn-secondary" style="margin-bottom: 8px;">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
Open Chat Panel
|
||||
</button>
|
||||
<button id="open-page-overlay" class="btn btn-ghost" style="margin-bottom: 12px;">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 4h16v12H8l-4 4V4z"/>
|
||||
</svg>
|
||||
Toggle On-Page Overlay
|
||||
Open Chat
|
||||
</button>
|
||||
|
||||
<!-- On-page chat config -->
|
||||
<div class="onpage-config" style="display:flex;flex-direction:column;gap:6px;margin-bottom:12px;font-size:11px;">
|
||||
<div class="feature-toggle" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="overlay-enabled" checked>
|
||||
<span>On-page chat panel</span>
|
||||
</label>
|
||||
<span style="color:var(--text-muted,#888);font-size:10px;">overlay vs new tab</span>
|
||||
</div>
|
||||
|
||||
<div id="overlay-side-row" class="side-picker" style="display:flex;gap:6px;align-items:center;">
|
||||
<span style="color:var(--text-muted,#888);">Pin:</span>
|
||||
<button id="pin-left" class="btn btn-ghost" style="flex:1;padding:4px 8px;font-size:11px;">Left</button>
|
||||
<button id="pin-right" class="btn btn-ghost" style="flex:1;padding:4px 8px;font-size:11px;" data-active="true">Right</button>
|
||||
</div>
|
||||
|
||||
<div id="overlay-width-row" style="display:flex;gap:6px;align-items:center;">
|
||||
<span style="color:var(--text-muted,#888);">Width:</span>
|
||||
<button class="btn btn-ghost width-btn" data-width="compact" style="flex:1;padding:4px 8px;font-size:11px;">Compact</button>
|
||||
<button class="btn btn-ghost width-btn" data-width="default" style="flex:1;padding:4px 8px;font-size:11px;" data-active="true">Default</button>
|
||||
<button class="btn btn-ghost width-btn" data-width="wide" style="flex:1;padding:4px 8px;font-size:11px;">Wide</button>
|
||||
</div>
|
||||
|
||||
<span style="color:var(--text-muted,#888);font-size:10px;line-height:1.4;">
|
||||
FF native sidebar: View → Sidebar → Hanzo AI.<br>
|
||||
Disable to fall back to a standalone tab — useful on sites with strict CSP.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Tools -->
|
||||
@@ -313,7 +336,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<!-- Footer: version + nav links -->
|
||||
<div class="footer-version" style="text-align:center;font-size:10px;color:var(--text-muted,#888);margin-top:14px;letter-spacing:0.04em;">
|
||||
Hanzo AI <span id="footer-version">…</span>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="https://docs.hanzo.ai" target="_blank">Docs</a>
|
||||
<a href="https://console.hanzo.ai" target="_blank">Console</a>
|
||||
|
||||
+112
-24
@@ -1,5 +1,10 @@
|
||||
// Hanzo AI — Popup Script
|
||||
// Uses background message passing for auth (not direct storage access).
|
||||
//
|
||||
// Polyfill the WebExtension API surface so this script can read/write
|
||||
// chrome.storage with Promise-returning calls on every browser. Chrome
|
||||
// gets the polyfill wrapper, Firefox/Safari get a no-op.
|
||||
import 'webextension-polyfill';
|
||||
|
||||
import {
|
||||
DEFAULT_INSPECT_SHORTCUT,
|
||||
@@ -23,13 +28,42 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const mainSection = document.getElementById('main-section');
|
||||
const settingsSection = document.getElementById('settings-section');
|
||||
|
||||
// Footer version — pulled from manifest at runtime so it always tracks build.
|
||||
const versionEl = document.getElementById('footer-version');
|
||||
if (versionEl) {
|
||||
try {
|
||||
versionEl.textContent = `v${chrome.runtime.getManifest().version}`;
|
||||
} catch { /* manifest unavailable in some contexts */ }
|
||||
}
|
||||
|
||||
// Auth
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
|
||||
// Open panel
|
||||
const openPanel = document.getElementById('open-panel');
|
||||
const openPageOverlay = document.getElementById('open-page-overlay');
|
||||
const openPageOverlay = document.getElementById('open-page-overlay'); // legacy id, may not exist
|
||||
const pinLeftBtn = document.getElementById('pin-left');
|
||||
const pinRightBtn = document.getElementById('pin-right');
|
||||
const overlayEnabledCheckbox = document.getElementById('overlay-enabled') as HTMLInputElement | null;
|
||||
const widthBtns = Array.from(document.querySelectorAll('.width-btn')) as HTMLButtonElement[];
|
||||
const overlaySideRow = document.getElementById('overlay-side-row');
|
||||
const overlayWidthRow = document.getElementById('overlay-width-row');
|
||||
|
||||
type OverlayWidth = 'compact' | 'default' | 'wide';
|
||||
|
||||
function syncSettingsUi(enabled: boolean, side: 'left' | 'right', width: OverlayWidth) {
|
||||
if (overlayEnabledCheckbox) overlayEnabledCheckbox.checked = enabled;
|
||||
pinLeftBtn?.setAttribute('data-active', side === 'left' ? 'true' : 'false');
|
||||
pinRightBtn?.setAttribute('data-active', side === 'right' ? 'true' : 'false');
|
||||
widthBtns.forEach((b) => b.setAttribute('data-active', b.getAttribute('data-width') === width ? 'true' : 'false'));
|
||||
// Grey out side/width when on-page overlay is disabled.
|
||||
[overlaySideRow, overlayWidthRow].forEach((row) => {
|
||||
if (!row) return;
|
||||
(row as HTMLElement).style.opacity = enabled ? '1' : '0.5';
|
||||
(row as HTMLElement).style.pointerEvents = enabled ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Status dots
|
||||
const zapStatus = document.getElementById('zap-status');
|
||||
@@ -100,34 +134,88 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Open chat panel ---
|
||||
// Always uses the in-page right-anchored overlay (content-script). The
|
||||
// browser-native sidebar (chrome.sidePanel / browser.sidebarAction) is
|
||||
// not used because:
|
||||
// - Firefox always renders its sidebar on the user's preferred side
|
||||
// (default left) and surrounds it with the native sidebar-icon strip,
|
||||
// which the user explicitly does NOT want.
|
||||
// - Chrome's sidePanel is right-anchored, but the position is OS-managed
|
||||
// and doesn't match the in-page overlay's behaviour.
|
||||
// Routing every "Open Chat Panel" click through page.overlay gives a
|
||||
// consistent right-anchored chat across every browser.
|
||||
// --- Open chat ---
|
||||
// Default surface: in-page edge-pinned overlay (content-script). Honours
|
||||
// the user's `overlayEnabled`, `overlaySide`, and `overlayWidth` prefs.
|
||||
// When `overlayEnabled` is false (or the active tab is privileged), opens
|
||||
// the chat surface in a standalone tab. Firefox users can also reach it
|
||||
// via View → Sidebar → Hanzo AI (registered via sidebar_action).
|
||||
function openSidebarTab() {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL('sidebar.html') });
|
||||
window.close();
|
||||
}
|
||||
|
||||
openPanel?.addEventListener('click', () => {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const activeTab = tabs[0];
|
||||
const target = isInjectableTab(activeTab) ? activeTab : null;
|
||||
if (!target?.id) {
|
||||
// Fallback: open a standalone window with the chat surface so the
|
||||
// popup-button still does something on chrome:// / about: pages.
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL('sidebar.html') });
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
chrome.runtime.sendMessage({ action: 'page.overlay.show', tabId: target.id }, () => {
|
||||
window.close();
|
||||
chrome.storage?.sync?.get?.(['overlayEnabled', 'overlaySide', 'overlayWidth'], (vals) => {
|
||||
const enabled = vals?.overlayEnabled !== false;
|
||||
const side = vals?.overlaySide === 'left' ? 'left' : 'right';
|
||||
const width: OverlayWidth = vals?.overlayWidth === 'compact'
|
||||
? 'compact' : vals?.overlayWidth === 'wide' ? 'wide' : 'default';
|
||||
if (!enabled) { openSidebarTab(); return; }
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const activeTab = tabs[0];
|
||||
const target = isInjectableTab(activeTab) ? activeTab : null;
|
||||
if (!target?.id) { openSidebarTab(); return; }
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'page.overlay.show',
|
||||
tabId: target.id,
|
||||
side,
|
||||
width,
|
||||
}, () => window.close());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function setActiveSide(side: 'left' | 'right') {
|
||||
chrome.storage?.sync?.set?.({ overlaySide: side });
|
||||
pinLeftBtn?.setAttribute('data-active', side === 'left' ? 'true' : 'false');
|
||||
pinRightBtn?.setAttribute('data-active', side === 'right' ? 'true' : 'false');
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const id = tabs[0]?.id;
|
||||
if (id) chrome.runtime.sendMessage({ action: 'page.overlay.setSide', tabId: id, side });
|
||||
});
|
||||
}
|
||||
function setActiveWidth(width: OverlayWidth) {
|
||||
chrome.storage?.sync?.set?.({ overlayWidth: width });
|
||||
widthBtns.forEach((b) => b.setAttribute('data-active', b.getAttribute('data-width') === width ? 'true' : 'false'));
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const id = tabs[0]?.id;
|
||||
if (id) chrome.runtime.sendMessage({ action: 'page.overlay.setWidth', tabId: id, width });
|
||||
});
|
||||
}
|
||||
function setOverlayEnabled(enabled: boolean) {
|
||||
chrome.storage?.sync?.set?.({ overlayEnabled: enabled });
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const id = tabs[0]?.id;
|
||||
if (id) chrome.runtime.sendMessage({ action: 'page.overlay.setEnabled', tabId: id, enabled });
|
||||
});
|
||||
chrome.storage?.sync?.get?.(['overlaySide', 'overlayWidth'], (vals) => {
|
||||
const side = vals?.overlaySide === 'left' ? 'left' : 'right';
|
||||
const width: OverlayWidth = vals?.overlayWidth === 'compact'
|
||||
? 'compact' : vals?.overlayWidth === 'wide' ? 'wide' : 'default';
|
||||
syncSettingsUi(enabled, side, width);
|
||||
});
|
||||
}
|
||||
|
||||
pinLeftBtn?.addEventListener('click', () => setActiveSide('left'));
|
||||
pinRightBtn?.addEventListener('click', () => setActiveSide('right'));
|
||||
widthBtns.forEach((b) => b.addEventListener('click', () => {
|
||||
const w = b.getAttribute('data-width') as OverlayWidth | null;
|
||||
if (w === 'compact' || w === 'default' || w === 'wide') setActiveWidth(w);
|
||||
}));
|
||||
overlayEnabledCheckbox?.addEventListener('change', (e) => {
|
||||
setOverlayEnabled((e.target as HTMLInputElement).checked);
|
||||
});
|
||||
|
||||
// Initial UI sync from storage.
|
||||
chrome.storage?.sync?.get?.(['overlayEnabled', 'overlaySide', 'overlayWidth'], (vals) => {
|
||||
const enabled = vals?.overlayEnabled !== false;
|
||||
const side = vals?.overlaySide === 'left' ? 'left' : 'right';
|
||||
const width: OverlayWidth = vals?.overlayWidth === 'compact'
|
||||
? 'compact' : vals?.overlayWidth === 'wide' ? 'wide' : 'default';
|
||||
syncSettingsUi(enabled, side, width);
|
||||
});
|
||||
|
||||
function isInjectableTab(tab: chrome.tabs.Tab | undefined): boolean {
|
||||
if (!tab || !tab.id) return false;
|
||||
const url = tab.url || '';
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
type Classy = { className?: string; children?: React.ReactNode };
|
||||
|
||||
function cx(base: string, extra?: string): string {
|
||||
return extra ? `${base} ${extra}` : base;
|
||||
}
|
||||
|
||||
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<button {...rest} className={cx('hanzo-ui-btn', className)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<textarea {...rest} className={cx('hanzo-ui-textarea', className)}>
|
||||
{children}
|
||||
</textarea>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-ui-card', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-ui-card-header', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, children }: Classy) {
|
||||
return <h3 className={cx('hanzo-ui-card-title', className)}>{children}</h3>;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, children }: Classy) {
|
||||
return <p className={cx('hanzo-ui-card-description', className)}>{children}</p>;
|
||||
}
|
||||
|
||||
export function CardContent({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-ui-card-content', className)}>{children}</div>;
|
||||
}
|
||||
@@ -187,7 +187,7 @@ export async function login(adapter: BrowserAdapter): Promise<UserInfo> {
|
||||
const state = generateRandomString(32);
|
||||
const redirectUri = config.redirectUri;
|
||||
|
||||
const authorizeUrl = new URL(`${config.iamLoginUrl}/oauth/authorize`);
|
||||
const authorizeUrl = new URL(`${config.iamLoginUrl}${IAM_API_PATH}/oauth/authorize`);
|
||||
authorizeUrl.searchParams.set('client_id', config.clientId);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
@@ -210,11 +210,9 @@ export async function login(adapter: BrowserAdapter): Promise<UserInfo> {
|
||||
token_type: 'Bearer',
|
||||
});
|
||||
} else if (code) {
|
||||
// Hanzo gateway exposes Casdoor's `/api/login/oauth/access_token` at
|
||||
// `/v1/iam/login/oauth/access_token`. The bare `/oauth/token` from
|
||||
// the OIDC well-known doc 404s in prod. Token exchange wants
|
||||
// application/x-www-form-urlencoded per RFC 6749, not JSON.
|
||||
const tokenResponse = await fetch(`${config.iamApiUrl}${IAM_API_PATH}/login/oauth/access_token`, {
|
||||
// HIP-0111 canonical token endpoint `/v1/iam/oauth/token`. Token
|
||||
// exchange wants application/x-www-form-urlencoded per RFC 6749.
|
||||
const tokenResponse = await fetch(`${config.iamApiUrl}${IAM_API_PATH}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
@@ -271,7 +269,7 @@ export async function getValidAccessToken(storage: BrowserStorage): Promise<stri
|
||||
|
||||
async function refreshAccessToken(storage: BrowserStorage, refreshToken: string): Promise<string> {
|
||||
const config = await getRuntimeConfig(storage);
|
||||
const response = await fetch(`${config.iamApiUrl}${IAM_API_PATH}/login/oauth/access_token`, {
|
||||
const response = await fetch(`${config.iamApiUrl}${IAM_API_PATH}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
@@ -286,30 +284,14 @@ async function refreshAccessToken(storage: BrowserStorage, refreshToken: string)
|
||||
return tokens.access_token;
|
||||
}
|
||||
|
||||
/** Fetch user info from IAM. Hanzo gateway exposes Casdoor's get-account
|
||||
* and userinfo at `${iamApiUrl}/v1/iam/...` — never `/api/`. */
|
||||
/** Fetch user info from IAM via the canonical HIP-0111 OIDC userinfo
|
||||
* endpoint `${iamApiUrl}/v1/iam/oauth/userinfo`. Returns standard OIDC
|
||||
* claims (sub, email, name, picture); never the legacy IAM `/api/` shape. */
|
||||
async function fetchUserInfo(storage: BrowserStorage, accessToken: string): Promise<UserInfo> {
|
||||
const config = await getRuntimeConfig(storage);
|
||||
const base = `${config.iamApiUrl}${IAM_API_PATH}`;
|
||||
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||
|
||||
try {
|
||||
const acctResp = await fetch(`${base}/get-account`, { headers });
|
||||
if (acctResp.ok) {
|
||||
const acctJson = await acctResp.json();
|
||||
const acct = acctJson.data || acctJson;
|
||||
if (acct.name || acct.email) {
|
||||
return {
|
||||
sub: acct.id || acct.sub,
|
||||
name: acct.displayName || acct.name,
|
||||
email: acct.email,
|
||||
picture: acct.avatar || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
|
||||
const response = await fetch(`${base}/userinfo`, { headers });
|
||||
const response = await fetch(`${config.iamApiUrl}${IAM_API_PATH}/oauth/userinfo`, { headers });
|
||||
if (!response.ok) throw new Error('Failed to fetch user info');
|
||||
return response.json();
|
||||
}
|
||||
@@ -339,47 +321,66 @@ export async function getAuthStatus(storage: BrowserStorage): Promise<{
|
||||
return { authenticated: true, user };
|
||||
}
|
||||
|
||||
// ── Chrome/Firefox Adapter Factories ────────────────────────────────────
|
||||
|
||||
/** Create a BrowserAdapter from Chrome extension APIs */
|
||||
export function chromeAdapter(): BrowserAdapter {
|
||||
return {
|
||||
storage: {
|
||||
get: (keys) => new Promise((resolve) => chrome.storage.local.get(keys, resolve)),
|
||||
set: (items) => chrome.storage.local.set(items),
|
||||
remove: (keys) => chrome.storage.local.remove(keys),
|
||||
},
|
||||
tabs: {
|
||||
create: (opts) => new Promise((resolve, reject) => {
|
||||
chrome.tabs.create(opts, (tab) => {
|
||||
if (chrome.runtime.lastError || !tab?.id) {
|
||||
reject(new Error(chrome.runtime.lastError?.message || 'Failed to open tab'));
|
||||
} else {
|
||||
resolve(tab);
|
||||
}
|
||||
});
|
||||
}),
|
||||
remove: (tabId) => chrome.tabs.remove(tabId),
|
||||
onUpdated: chrome.tabs.onUpdated,
|
||||
onRemoved: chrome.tabs.onRemoved,
|
||||
},
|
||||
};
|
||||
// ── WebExtension Adapter Factory ────────────────────────────────────────
|
||||
//
|
||||
// Unified across Chrome (MV3), Firefox (MV3), Safari (WebExtension), and
|
||||
// Edge (Chromium MV3). Uses webextension-polyfill at the entrypoint level
|
||||
// so `globalThis.browser` is the canonical Promise-returning API on every
|
||||
// runtime. On Firefox/Safari `browser.*` is native; on Chrome the polyfill
|
||||
// wraps `chrome.*` callbacks. The result is ONE codepath that works
|
||||
// identically everywhere — no more separate `chromeAdapter` /
|
||||
// `firefoxAdapter` factories with subtly different behavior.
|
||||
//
|
||||
// The adapter intentionally still targets a small surface (storage +
|
||||
// tabs) because that's all auth.ts needs. If we ever migrate the wider
|
||||
// codebase to `browser.*` we'd extend this — but for now keeping the
|
||||
// surface minimal avoids unnecessary churn in callback-style listeners
|
||||
// elsewhere (e.g. the runtime.onMessage handlers, which are still chrome
|
||||
// callback style by design).
|
||||
function getBrowser(): typeof browser {
|
||||
// Prefer the polyfilled `browser` if present (Chrome with the polyfill
|
||||
// imported at entrypoint, or any native browser.*-supporting runtime).
|
||||
// Fall back to wrapping `chrome.*` directly if neither is present
|
||||
// (which only happens in tests / non-extension contexts).
|
||||
return (globalThis as any).browser ?? (globalThis as any).chrome;
|
||||
}
|
||||
|
||||
/** Create a BrowserAdapter from Firefox WebExtension APIs */
|
||||
export function firefoxAdapter(): BrowserAdapter {
|
||||
const b = (globalThis as any).browser;
|
||||
/**
|
||||
* Create a BrowserAdapter that works on Chrome/Firefox/Edge/Safari.
|
||||
* Requires `globalThis.browser` to be defined (which the polyfill
|
||||
* import at the entrypoint guarantees on Chrome).
|
||||
*/
|
||||
export function webExtensionAdapter(): BrowserAdapter {
|
||||
const b: any = getBrowser();
|
||||
return {
|
||||
storage: {
|
||||
get: (keys) => b.storage.local.get(keys),
|
||||
set: (items) => b.storage.local.set(items),
|
||||
remove: (keys) => b.storage.local.remove(keys),
|
||||
// Promise-returning on every runtime once the polyfill is loaded.
|
||||
get: (keys) => Promise.resolve(b.storage.local.get(keys)),
|
||||
set: (items) => Promise.resolve(b.storage.local.set(items)),
|
||||
remove: (keys) => Promise.resolve(b.storage.local.remove(keys)),
|
||||
},
|
||||
tabs: {
|
||||
create: (opts) => b.tabs.create(opts),
|
||||
remove: (tabId) => b.tabs.remove(tabId),
|
||||
create: (opts) => Promise.resolve(b.tabs.create(opts)),
|
||||
remove: (tabId) => Promise.resolve(b.tabs.remove(tabId)),
|
||||
onUpdated: b.tabs.onUpdated,
|
||||
onRemoved: b.tabs.onRemoved,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Chrome adapter — kept for backwards compatibility with callers
|
||||
* that imported it before 1.9.0. Now just an alias for
|
||||
* `webExtensionAdapter`. Prefer `webExtensionAdapter` in new code.
|
||||
*/
|
||||
export function chromeAdapter(): BrowserAdapter {
|
||||
return webExtensionAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Firefox adapter — same deal as `chromeAdapter`. Both are
|
||||
* aliases now that the polyfill gives us one identical surface.
|
||||
*/
|
||||
export function firefoxAdapter(): BrowserAdapter {
|
||||
return webExtensionAdapter();
|
||||
}
|
||||
|
||||
@@ -13,19 +13,35 @@
|
||||
/** Login UI domain (Hanzo ID) */
|
||||
export const IAM_LOGIN_URL = 'https://hanzo.id';
|
||||
|
||||
/** IAM API domain (Casdoor backend, fronted by Hanzo's API gateway) */
|
||||
/** IAM API domain (Hanzo IAM backend, fronted by Hanzo's API gateway) */
|
||||
export const IAM_API_URL = 'https://iam.hanzo.ai';
|
||||
|
||||
/**
|
||||
* IAM API path prefix. Hanzo convention is `/v1/<service>/<endpoint>` — never
|
||||
* `/api/`. The Casdoor upstream uses `/api/*` natively but the prod gateway
|
||||
* `/api/`. The Hanzo IAM upstream uses `/api/*` natively but the prod gateway
|
||||
* rewrites `/v1/iam/*` → upstream. Always reference endpoints through this
|
||||
* constant so prefix changes are a single edit.
|
||||
*/
|
||||
export const IAM_API_PATH = '/v1/iam';
|
||||
|
||||
/** OAuth2 client ID — override via storage key 'hanzo_config_client_id' */
|
||||
export const DEFAULT_CLIENT_ID = 'app-hanzo';
|
||||
/**
|
||||
* OAuth2 client ID — override via storage key 'hanzo_config_client_id'.
|
||||
*
|
||||
* MUST be a client_id registered in Hanzo IAM whose redirectUris include
|
||||
* DEFAULT_REDIRECT_URI. The canonical browser-extension client is
|
||||
* `hanzo-browser` (universe/infra/k8s/iam/init_data.json → application
|
||||
* "hanzo-browser", redirectUris: https://hanzo.ai/callback,
|
||||
* https://hanzo.app/callback, http://localhost:3000/callback).
|
||||
*
|
||||
* NOTE: the prior value `app-hanzo` was never a registered IAM client, so the
|
||||
* PKCE token exchange always failed with `invalid_client` — login was broken
|
||||
* for every build since v1.8.0. Verify with:
|
||||
* curl -X POST https://iam.hanzo.ai/v1/iam/oauth/token \
|
||||
* -d 'grant_type=authorization_code&client_id=hanzo-browser&code=x&code_verifier=y&redirect_uri=https://hanzo.ai/callback'
|
||||
* A registered client returns `invalid_grant` (code rejected); an unregistered
|
||||
* one returns `invalid_client`.
|
||||
*/
|
||||
export const DEFAULT_CLIENT_ID = 'hanzo-browser';
|
||||
|
||||
/** OAuth2 scopes */
|
||||
export const DEFAULT_SCOPES = 'openid profile email';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The ONE cross-browser rule for caller-supplied JavaScript.
|
||||
*
|
||||
* Every `evaluate` surface (Chrome `Runtime.evaluate` over chrome.debugger,
|
||||
* Firefox `scripting.executeScript` trampoline) consumes the output of
|
||||
* `wrapEvaluable`: a single *expression* whose value — possibly a Promise —
|
||||
* is the result. Both consumers already await promises (`awaitPromise: true`
|
||||
* on CDP; `executeScript` resolves returned promises), so statement bodies
|
||||
* can be wrapped in an async IIFE without changing caller semantics.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Accept the code parameter under every alias the callers use:
|
||||
* `expression` (CDP), `code` (MCP/SDK), plus the legacy bridge aliases
|
||||
* `script` / `function` / `js`.
|
||||
*/
|
||||
export function pickEvaluable(params: Record<string, unknown> | null | undefined): string {
|
||||
const p = params ?? {};
|
||||
const raw = p.expression ?? p.code ?? p.script ?? p.function ?? p.js ?? '';
|
||||
return typeof raw === 'string' ? raw : String(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make `evaluate` forgiving: a bare expression (`document.title`) is
|
||||
* returned as-is, but a statement body (`const b=…; b.click()`) is wrapped
|
||||
* in an async IIFE so it doesn't blow up with "expected expression, got
|
||||
* 'const'". Multi-statement bodies auto-`return` their trailing expression
|
||||
* (`const x=…; x` yields x, not undefined); an explicit `return` and
|
||||
* control-flow / declaration tails are left alone. Ready-made callables
|
||||
* (functions, arrows, IIFEs) pass through untouched — CDP calls them.
|
||||
*/
|
||||
export function wrapEvaluable(code: string): string {
|
||||
const t = (code ?? '').trim();
|
||||
if (!t) return 'undefined';
|
||||
// already a callable form — leave it (CDP calls it)
|
||||
if (/^(\(?\s*(async\s+)?function\b|\(?\s*\([^)]*\)\s*=>|\(\s*async\b)/.test(t)) return t;
|
||||
const looksStatement =
|
||||
/[;\n]/.test(t) || /^(const|let|var|if|for|while|switch|return|throw|await|try)\b/.test(t);
|
||||
if (!looksStatement) return t; // bare expression → return its value directly
|
||||
let body = t.replace(/;\s*$/, '');
|
||||
const cut = Math.max(body.lastIndexOf(';'), body.lastIndexOf('\n'));
|
||||
const head = cut >= 0 ? body.slice(0, cut + 1) : '';
|
||||
const last = (cut >= 0 ? body.slice(cut + 1) : body).trim();
|
||||
if (last && !/^(return|const|let|var|if|for|while|switch|throw|}|\/\/)/.test(last)) {
|
||||
body = head + ' return ' + last;
|
||||
}
|
||||
return `(async()=>{ ${body} })()`;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* Uses IAM bearer tokens for auth — no separate KMS credentials needed.
|
||||
*
|
||||
* API path. Hanzo convention is `/v1/<service>/<endpoint>` — but the KMS
|
||||
* upstream is an Infisical fork that publishes its v3 API under the root
|
||||
* upstream publishes its v3 API under the root
|
||||
* `${KMS_PATH_PREFIX}/secrets/raw`. The kms.hanzo.ai gateway currently passes that
|
||||
* through unchanged (verified: `kms.hanzo.ai/api/v3/secrets/raw` → 401 JSON,
|
||||
* `/v1/kms/...` → SPA HTML). We centralise the path in `KMS_PATH_PREFIX`
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Native-ZAP transport — the extension's one connection to the local zapd
|
||||
// router, via the ai.hanzo.zap native-messaging host. No WebSocket, no
|
||||
// localhost ports, no mDNS probing. The browser can never get "connection
|
||||
// refused": connectNative either attaches the host or fails once, cleanly.
|
||||
//
|
||||
// Frames are the ZAP router envelope (binary, in zapd). Over native messaging
|
||||
// they ride as JSON `{t, flags, from, to, payload(base64)}` — the Chrome-forced
|
||||
// inch — and the host re-frames 1:1 to binary on the UDS. The opaque command/
|
||||
// result payload uses a compact binary codec below (no JSON in the contract).
|
||||
|
||||
const HELLO = 1, WELCOME = 2, ERROR = 7, ROUTE = 16, RESPONSE = 17;
|
||||
const ROLE_PROVIDER = 1;
|
||||
|
||||
function b64encode(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||
return btoa(s);
|
||||
}
|
||||
function b64decode(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const u = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||
return u;
|
||||
}
|
||||
function putU16(a: number[], n: number) { a.push(n & 0xff, (n >> 8) & 0xff); }
|
||||
function putStr(a: number[], s: string) {
|
||||
const b = new TextEncoder().encode(s);
|
||||
putU16(a, b.length);
|
||||
for (let i = 0; i < b.length; i++) a.push(b[i]);
|
||||
}
|
||||
|
||||
// HELLO body: role(u8) + brand(str) + caps(u16 + str…). Mirrors frame.rs.
|
||||
function encodeHello(role: number, brand: string, caps: string[]): Uint8Array {
|
||||
const a: number[] = [role];
|
||||
putStr(a, brand);
|
||||
putU16(a, caps.length);
|
||||
for (const c of caps) putStr(a, c);
|
||||
return new Uint8Array(a);
|
||||
}
|
||||
|
||||
// Command body: method(str) + params(u16 count of key:str → value:bytes).
|
||||
function decodeCmd(p: Uint8Array): { method: string; params: Record<string, string> } {
|
||||
const dv = new DataView(p.buffer, p.byteOffset, p.byteLength);
|
||||
let o = 0;
|
||||
const ml = dv.getUint16(o, true); o += 2;
|
||||
const method = new TextDecoder().decode(p.subarray(o, o + ml)); o += ml;
|
||||
const n = dv.getUint16(o, true); o += 2;
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < n; i++) {
|
||||
const kl = dv.getUint16(o, true); o += 2;
|
||||
const k = new TextDecoder().decode(p.subarray(o, o + kl)); o += kl;
|
||||
const vl = dv.getUint32(o, true); o += 4;
|
||||
params[k] = new TextDecoder().decode(p.subarray(o, o + vl)); o += vl;
|
||||
}
|
||||
return { method, params };
|
||||
}
|
||||
|
||||
export interface NativeZapState {
|
||||
connected: boolean;
|
||||
port: chrome.runtime.Port | null;
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
export type Dispatch = (method: string, params: any) => Promise<any> | any;
|
||||
|
||||
const RECONNECT_MS = 3000;
|
||||
|
||||
/** Connect to zapd via the native host, register as a browser provider, and
|
||||
* dispatch inbound ROUTE commands to the browser. Self-heals on disconnect. */
|
||||
export function connectNativeZap(
|
||||
state: NativeZapState,
|
||||
brand: string,
|
||||
caps: string[],
|
||||
dispatch: Dispatch,
|
||||
log: (m: string) => void = console.log,
|
||||
): void {
|
||||
// Cross-browser: Firefox exposes `browser.runtime`, Chrome `chrome.runtime`;
|
||||
// both implement connectNative. One transport, both browsers.
|
||||
const rt: any = (typeof browser !== 'undefined' ? (browser as any) : chrome).runtime;
|
||||
// Singleton: never run two native ports. Tear down any prior port first — its
|
||||
// onDisconnect below is guarded so a replaced port never schedules a reconnect.
|
||||
// Without this, repeated connectNativeZap() calls (startup + events + the 3s
|
||||
// reconnect) accumulate ports, each spawning a native host → a host-spawn storm.
|
||||
const prior = state.port;
|
||||
state.port = null;
|
||||
if (prior) { try { prior.disconnect(); } catch {} }
|
||||
let port: chrome.runtime.Port;
|
||||
try {
|
||||
port = rt.connectNative('ai.hanzo.zap');
|
||||
} catch (e: any) {
|
||||
state.connected = false;
|
||||
log('[Hanzo/ZAP] native host ai.hanzo.zap is not installed');
|
||||
return;
|
||||
}
|
||||
state.port = port;
|
||||
const id = state.providerId;
|
||||
|
||||
const post = (t: number, to: string, payload: Uint8Array) =>
|
||||
port.postMessage({ t, flags: 0, from: id, to, payload: b64encode(payload) });
|
||||
|
||||
// Register as a provider.
|
||||
post(HELLO, '', encodeHello(ROLE_PROVIDER, brand, caps));
|
||||
|
||||
port.onMessage.addListener(async (msg: any) => {
|
||||
const payload = msg.payload ? b64decode(msg.payload) : new Uint8Array(0);
|
||||
switch (msg.t) {
|
||||
case WELCOME:
|
||||
state.connected = true;
|
||||
log(`[Hanzo/ZAP] native host connected; registered ${id}`);
|
||||
break;
|
||||
case ERROR:
|
||||
log('[Hanzo/ZAP] ' + new TextDecoder().decode(payload));
|
||||
break;
|
||||
case ROUTE: {
|
||||
const { method, params } = decodeCmd(payload);
|
||||
let out: Uint8Array;
|
||||
try {
|
||||
const r = await dispatch(method, params);
|
||||
// browser.zap stopgap: structured results travel as utf-8 text until
|
||||
// the typed browser schema lands. Router/host never see this — opaque.
|
||||
const s = typeof r === 'string' ? r : JSON.stringify(r ?? null);
|
||||
out = new TextEncoder().encode(s);
|
||||
} catch (e: any) {
|
||||
out = new TextEncoder().encode('ERR:' + (e?.message || String(e)));
|
||||
}
|
||||
port.postMessage({ t: RESPONSE, flags: 0, from: id, to: msg.from, payload: b64encode(out) });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break; // PROVIDERS / PEER_* — a provider ignores these
|
||||
}
|
||||
});
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
if (state.port !== port) return; // replaced by a newer port — do not reconnect
|
||||
state.connected = false;
|
||||
state.port = null;
|
||||
const err = rt.lastError?.message;
|
||||
log('[Hanzo/ZAP] native host disconnected' + (err ? ': ' + err : ''));
|
||||
setTimeout(() => connectNativeZap(state, brand, caps, dispatch, log), RECONNECT_MS);
|
||||
});
|
||||
}
|
||||
@@ -15,10 +15,16 @@ export const MSG_RESPONSE = 0x11;
|
||||
export const MSG_PING = 0xFE;
|
||||
export const MSG_PONG = 0xFF;
|
||||
|
||||
export const DEFAULT_ZAP_PORTS = [9999, 9998, 9997, 9996, 9995];
|
||||
/**
|
||||
* Canonical Hanzo service-type per HIP-0069. Discovery is mDNS-only —
|
||||
* the legacy [9999..9995] port-probe was removed in 1.9.13 to surface
|
||||
* non-compliant deployments instead of papering over them.
|
||||
*/
|
||||
export const HANZO_SERVICE_TYPE = '_hanzo._tcp.local.';
|
||||
export const ZAP_RECONNECT_DELAY = 3000;
|
||||
export const ZAP_DISCOVERY_TIMEOUT = 2000;
|
||||
|
||||
|
||||
// ── Encode / Decode ─────────────────────────────────────────────────────
|
||||
|
||||
export function encodeZapMessage(type: number, payload: object): ArrayBuffer {
|
||||
@@ -50,11 +56,24 @@ export function decodeZapMessage(buf: ArrayBuffer): { type: number; payload: any
|
||||
|
||||
// ── Connection Management ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handler invoked when a ZAP server pushes a request TO the extension.
|
||||
* Returns a result (or throws) and the framework converts that into a
|
||||
* MSG_RESPONSE frame back to the server.
|
||||
*
|
||||
* Keeping this at the manager level means the extension's existing
|
||||
* request-dispatchers (handleExtensionRequest, executeMethod) can be
|
||||
* mounted once and serve every connected MCP without duplication.
|
||||
*/
|
||||
export type ZapRequestHandler = (method: string, params: any) => Promise<any> | any;
|
||||
|
||||
export interface ZapManager {
|
||||
state: ZapState;
|
||||
connections: Map<string, WebSocket>;
|
||||
pendingRequests: Map<string, { resolve: Function; reject: Function }>;
|
||||
requestIdCounter: number;
|
||||
/** Optional inbound request handler (set via setZapRequestHandler). */
|
||||
requestHandler?: ZapRequestHandler;
|
||||
}
|
||||
|
||||
export function createZapManager(): ZapManager {
|
||||
@@ -70,38 +89,9 @@ export function createZapManager(): ZapManager {
|
||||
};
|
||||
}
|
||||
|
||||
/** Probe a ZAP server on given port */
|
||||
export function probeZapServer(
|
||||
port: number,
|
||||
mgr: ZapManager,
|
||||
browserName: string,
|
||||
version: string,
|
||||
): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const url = `ws://localhost:${port}`;
|
||||
const ws = new WebSocket(url);
|
||||
const timer = setTimeout(() => { ws.close(); resolve(null); }, ZAP_DISCOVERY_TIMEOUT);
|
||||
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => {
|
||||
clearTimeout(timer);
|
||||
ws.send(encodeZapMessage(MSG_HANDSHAKE, {
|
||||
clientId: mgr.state.extensionId,
|
||||
clientType: 'browser_extension',
|
||||
browser: browserName,
|
||||
version,
|
||||
capabilities: ['tabs', 'navigate', 'screenshot', 'evaluate', 'cookies', 'storage'],
|
||||
}));
|
||||
|
||||
const hsTimer = setTimeout(() => { ws.close(); resolve(null); }, ZAP_DISCOVERY_TIMEOUT);
|
||||
ws.onmessage = () => {
|
||||
clearTimeout(hsTimer);
|
||||
ws.close();
|
||||
resolve(url);
|
||||
};
|
||||
};
|
||||
ws.onerror = () => { clearTimeout(timer); resolve(null); };
|
||||
});
|
||||
/** Install (or replace) the inbound request handler. */
|
||||
export function setZapRequestHandler(mgr: ZapManager, handler: ZapRequestHandler | undefined): void {
|
||||
mgr.requestHandler = handler;
|
||||
}
|
||||
|
||||
/** Connect to a ZAP server and set up message handling */
|
||||
@@ -149,6 +139,11 @@ export async function connectZap(
|
||||
tools: (info.tools || []).map((t: any) => t.name),
|
||||
});
|
||||
log(`[Hanzo/ZAP] Connected to ${info.name || url} (${(info.tools || []).length} tools)`);
|
||||
// No application-level heartbeat: ZAP is a long-lived WS and the
|
||||
// OS already delivers FIN/RST on real disconnects, which fires
|
||||
// ws.onclose → our reconnect chain (added 1.9.7). The 1.9.9
|
||||
// PING/PONG was both unnecessary and buggy (it stacked
|
||||
// ws.onmessage wrappers on every tick).
|
||||
resolve(mcpId);
|
||||
break;
|
||||
}
|
||||
@@ -162,12 +157,43 @@ export async function connectZap(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MSG_REQUEST: {
|
||||
// Server-initiated RPC: a Python hanzo-mcp is asking the
|
||||
// extension to perform a browser action. Dispatch via the
|
||||
// installed handler and reply with MSG_RESPONSE.
|
||||
const { id: reqId, method: reqMethod, params: reqParams } = msg.payload || {};
|
||||
const respond = (payload: object) => {
|
||||
try {
|
||||
ws.send(encodeZapMessage(MSG_RESPONSE, payload));
|
||||
} catch (e) {
|
||||
// ws.send throws if the socket is closing; nothing we can do.
|
||||
}
|
||||
};
|
||||
const handler = mgr.requestHandler;
|
||||
if (!handler) {
|
||||
respond({ id: reqId, error: { code: -32601, message: `No request handler installed (method: ${reqMethod})` } });
|
||||
break;
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(() => handler(reqMethod, reqParams || {}))
|
||||
.then((result) => respond({ id: reqId, result: result === undefined ? null : result }))
|
||||
.catch((e: any) => respond({ id: reqId, error: { code: -1, message: e?.message || String(e) } }));
|
||||
break;
|
||||
}
|
||||
case MSG_PING:
|
||||
ws.send(encodeZapMessage(MSG_PONG, {}));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let scheduledRetry = false;
|
||||
const scheduleRetry = (reason: string) => {
|
||||
if (scheduledRetry) return;
|
||||
scheduledRetry = true;
|
||||
log(`[Hanzo/ZAP] ${reason} — reconnecting to ${url} in ${ZAP_RECONNECT_DELAY}ms...`);
|
||||
setTimeout(() => connectZap(url, mgr, browserName, version, log), ZAP_RECONNECT_DELAY);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
for (const [id, conn] of mgr.connections) {
|
||||
if (conn === ws) {
|
||||
@@ -177,12 +203,17 @@ export async function connectZap(
|
||||
}
|
||||
}
|
||||
mgr.state.connected = mgr.connections.size > 0;
|
||||
log(`[Hanzo/ZAP] Disconnected from ${url}, reconnecting in ${ZAP_RECONNECT_DELAY}ms...`);
|
||||
setTimeout(() => connectZap(url, mgr, browserName, version, log), ZAP_RECONNECT_DELAY);
|
||||
scheduleRetry('Disconnected');
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(connTimer);
|
||||
// onerror fires when the bind window misses, the server is restarting,
|
||||
// or the connection is reset mid-handshake. Without scheduling a retry
|
||||
// here the entire reconnect chain dies after a single failed attempt
|
||||
// (1.9.6 and earlier) — fixed in 1.9.7 by chaining a retry the same
|
||||
// way onclose does.
|
||||
scheduleRetry('Connect error');
|
||||
resolve(null);
|
||||
};
|
||||
});
|
||||
@@ -256,26 +287,126 @@ export function zapGetPrompt(mgr: ZapManager, mcpId: string, name: string, args?
|
||||
return zapRequest(mgr, mcpId, 'prompts/get', { name, arguments: args });
|
||||
}
|
||||
|
||||
/** Discover and connect to ZAP servers */
|
||||
/**
|
||||
* Ask the native messaging helper (ai.hanzo.zap_mdns) for a live mDNS browse
|
||||
* of `_hanzo._tcp.local.` services per HIP-0069. Returns ws:// URLs the
|
||||
* extension can connect to. Returns [] when the helper isn't installed or
|
||||
* the browse times out empty.
|
||||
*/
|
||||
async function discoverViaMdns(timeoutMs: number = 1500): Promise<string[]> {
|
||||
const api: any = (typeof browser !== 'undefined' ? browser : (chrome as any));
|
||||
const send = api?.runtime?.sendNativeMessage;
|
||||
if (typeof send !== 'function') {
|
||||
throw new Error(
|
||||
'[Hanzo/ZAP] runtime.sendNativeMessage unavailable — ai.hanzo.zap_mdns helper required for discovery',
|
||||
);
|
||||
}
|
||||
const resp: any = await new Promise((resolve, reject) => {
|
||||
// Two dialects:
|
||||
// - Firefox / promise-returning Chrome (MV3): sendNativeMessage(name, msg) → Promise
|
||||
// - Chrome MV2 callback API: sendNativeMessage(name, msg, callback)
|
||||
// Detect by whether the first call returns a thenable; only fall back to
|
||||
// the callback shape when it doesn't, so we never send the same browse
|
||||
// request twice.
|
||||
try {
|
||||
const result = send.call(api.runtime, 'ai.hanzo.zap_mdns', { op: 'browse', timeout_ms: timeoutMs }, (r: any) => {
|
||||
// Callback dialect: result of the call() above is undefined and the
|
||||
// helper response arrives here. Promise dialect ignores callbacks.
|
||||
if (r !== undefined) resolve(r);
|
||||
});
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(resolve).catch(reject);
|
||||
}
|
||||
// If result was undefined and the callback never fires, the discovery
|
||||
// loop's interval handles the retry — no need for a per-call timer.
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
if (!resp || !resp.ok || !Array.isArray(resp.services)) return [];
|
||||
return resp.services
|
||||
.map((s: any) => s.url)
|
||||
.filter((u: any): u is string => typeof u === 'string');
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and connect to ZAP servers. Tries mDNS (HIP-0069,
|
||||
* `_hanzo._tcp.local.`) first via the ``ai.hanzo.zap_mdns`` native-messaging
|
||||
* helper; when the helper is absent or finds nothing, falls back to
|
||||
* probing the localhost ``ZAP_PORTS`` range so a developer running
|
||||
* hanzo-mcp on the same machine is always reachable without extra
|
||||
* installation. Use ``startZapDiscoveryLoop`` for the always-on watchdog.
|
||||
*/
|
||||
export async function discoverZapServers(
|
||||
mgr: ZapManager,
|
||||
browserName: string,
|
||||
version: string,
|
||||
ports: number[] = DEFAULT_ZAP_PORTS,
|
||||
_ports: number[] | undefined = undefined, // unused — retained for ABI continuity
|
||||
log: (msg: string) => void = console.log,
|
||||
): Promise<void> {
|
||||
log('[Hanzo/ZAP] Discovering MCP servers...');
|
||||
const results = await Promise.all(ports.map(p => probeZapServer(p, mgr, browserName, version)));
|
||||
const available = results.filter(Boolean) as string[];
|
||||
|
||||
if (available.length === 0) {
|
||||
log('[Hanzo/ZAP] No servers found, retrying in 10s...');
|
||||
setTimeout(() => discoverZapServers(mgr, browserName, version, ports, log), 10000);
|
||||
return;
|
||||
log('[Hanzo/ZAP] Discovering MCP via mDNS (_hanzo._tcp.local.)...');
|
||||
let mdnsUrls: string[] = [];
|
||||
let mdnsError = false;
|
||||
try {
|
||||
mdnsUrls = await discoverViaMdns(1500);
|
||||
} catch (e: any) {
|
||||
mdnsError = true;
|
||||
log(`[Hanzo/ZAP] mDNS browse failed: ${e?.message || e} — falling back to localhost probe.`);
|
||||
}
|
||||
|
||||
log(`[Hanzo/ZAP] Found ${available.length} server(s): ${available.join(', ')}`);
|
||||
await Promise.all(available.map(url => connectZap(url, mgr, browserName, version, log)));
|
||||
// Skip URLs we're already connected to so a watchdog re-tick is a no-op
|
||||
// when everything is already wired.
|
||||
const known = new Set<string>();
|
||||
for (const m of mgr.state.mcps) known.add(m.url);
|
||||
|
||||
const candidates = mdnsUrls.filter(u => !known.has(u));
|
||||
|
||||
// mDNS-only per HIP-0069. A browser extension must NEVER open arbitrary
|
||||
// ws://localhost:* sockets — that is the ERR_CONNECTION_REFUSED spam.
|
||||
// Either the native helper advertises a server, or we are cleanly
|
||||
// unavailable. No localhost port probing, no fallback ladder.
|
||||
if (candidates.length === 0) {
|
||||
if (mdnsError) {
|
||||
log('[Hanzo/ZAP] unavailable: native mDNS helper ai.hanzo.zap_mdns missing/unreachable');
|
||||
}
|
||||
return;
|
||||
}
|
||||
log(`[Hanzo/ZAP] mDNS found ${candidates.length} new server(s): ${candidates.join(', ')}`);
|
||||
|
||||
await Promise.all(candidates.map(url => connectZap(url, mgr, browserName, version, log)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-on discovery watchdog. Ticks every ``intervalMs`` and re-runs
|
||||
* discovery whenever the manager has zero live connections (or a fresh server
|
||||
* has appeared on mDNS that we're not yet connected to). This is the canonical
|
||||
* entry point — replaces the fragile setTimeout-on-failure chain that died
|
||||
* the moment any code-path missed a re-arm. Returns the interval handle so
|
||||
* tests can stop it; in production the page lives for the extension lifetime.
|
||||
*/
|
||||
export function startZapDiscoveryLoop(
|
||||
mgr: ZapManager,
|
||||
browserName: string,
|
||||
version: string,
|
||||
intervalMs: number = 5000,
|
||||
log: (msg: string) => void = console.log,
|
||||
): { stop: () => void } {
|
||||
let inFlight = false;
|
||||
const tick = async () => {
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
await discoverZapServers(mgr, browserName, version, undefined, log);
|
||||
} catch (e: any) {
|
||||
log(`[Hanzo/ZAP] discovery tick failed: ${e?.message || e}`);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
// Run an immediate first probe; then settle into the watchdog cadence.
|
||||
void tick();
|
||||
const handle = setInterval(tick, intervalMs);
|
||||
return { stop: () => clearInterval(handle) };
|
||||
}
|
||||
|
||||
// ── Message Handler Helpers ─────────────────────────────────────────────
|
||||
@@ -306,31 +437,11 @@ export function handleZapMessage(
|
||||
return true;
|
||||
|
||||
case 'zap.discover':
|
||||
discoverZapServers(mgr, browserName, version).then(() => {
|
||||
sendResponse({
|
||||
success: true,
|
||||
mcps: mgr.state.mcps.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
url: m.url,
|
||||
tools: m.tools,
|
||||
})),
|
||||
});
|
||||
}).catch((e: any) => {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
});
|
||||
return true;
|
||||
|
||||
case 'zap.connect':
|
||||
if (!request.url) {
|
||||
sendResponse({ success: false, error: 'Missing url' });
|
||||
return true;
|
||||
}
|
||||
connectZap(request.url, mgr, browserName, version).then((mcpId) => {
|
||||
sendResponse({ success: !!mcpId, mcpId });
|
||||
}).catch((e: any) => {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
});
|
||||
// Discovery/dialing is gone. The extension connects to the local zapd
|
||||
// router via the ai.hanzo.zap native host (see native-zap.ts) — there is
|
||||
// no URL/port to discover or dial.
|
||||
sendResponse({ success: false, error: 'native-zap: connection is via the ai.hanzo.zap native host (no discovery)' });
|
||||
return true;
|
||||
|
||||
case 'zap.callTool': {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* Hanzo AI Browser Extension Sidebar — aligned with @hanzo/ui dark theme */
|
||||
/* Hanzo AI Browser Extension Sidebar — Hanzo brand: strictly monochrome.
|
||||
See ~/work/hanzo/brand/brand.json — primaryColor #FFFFFF, no chromatic
|
||||
accents. Status states are conveyed by intensity, not hue. */
|
||||
|
||||
:root {
|
||||
--bg-primary: #0a0a0a;
|
||||
@@ -15,9 +17,11 @@
|
||||
--accent-dim: rgba(255,255,255,0.06);
|
||||
--accent-glow: rgba(255,255,255,0.03);
|
||||
|
||||
--success: #22c55e;
|
||||
--warning: #eab308;
|
||||
--error: #ef4444;
|
||||
/* Status: monochrome by hue, distinguished by opacity / weight.
|
||||
Brand is strict monochrome, so success/warn/error use luminance. */
|
||||
--success: #fafafa;
|
||||
--warning: #d4d4d4;
|
||||
--error: rgba(255,255,255,0.55);
|
||||
|
||||
--border: rgba(255,255,255,0.10);
|
||||
--border-strong: rgba(255,255,255,0.16);
|
||||
@@ -259,7 +263,7 @@ html {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Sign-in button — matches @hanzo/ui primary button on dark */
|
||||
/* Sign-in button — matches @hanzo/gui primary button on dark (monochrome) */
|
||||
.auth-signin-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
@@ -1046,16 +1050,18 @@ html {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Element-syntax tinting — kept monochrome via opacity, not hue. */
|
||||
.inspector-result .element-tag {
|
||||
color: #7cc4f8;
|
||||
color: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inspector-result .element-id {
|
||||
color: #c792ea;
|
||||
color: rgba(255,255,255,0.78);
|
||||
}
|
||||
|
||||
.inspector-result .element-class {
|
||||
color: #c3e88d;
|
||||
color: rgba(255,255,255,0.62);
|
||||
}
|
||||
|
||||
.inspector-result .element-size {
|
||||
@@ -1681,11 +1687,13 @@ select option {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bar-blue { background: #3B82F6; }
|
||||
.bar-violet { background: #8B5CF6; }
|
||||
.bar-emerald { background: #10B981; }
|
||||
.bar-amber { background: #F59E0B; }
|
||||
.bar-red { background: #EF4444; }
|
||||
/* Usage bars: monochrome scale (brightest → dimmest) per @hanzo/brand.
|
||||
Class names kept for compatibility with existing markup. */
|
||||
.bar-blue { background: rgba(255,255,255,0.95); }
|
||||
.bar-violet { background: rgba(255,255,255,0.75); }
|
||||
.bar-emerald { background: rgba(255,255,255,0.55); }
|
||||
.bar-amber { background: rgba(255,255,255,0.40); }
|
||||
.bar-red { background: rgba(255,255,255,0.25); }
|
||||
|
||||
.usage-bar-group {
|
||||
margin-top: 8px;
|
||||
@@ -1761,9 +1769,9 @@ select option {
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-cloud { background: rgba(59,130,246,0.15); color: #60A5FA; }
|
||||
.badge-local { background: rgba(16,185,129,0.15); color: #34D399; }
|
||||
.badge-hub { background: rgba(139,92,246,0.15); color: #A78BFA; }
|
||||
.badge-cloud { background: rgba(255,255,255,0.10); color: rgba(255,255,255,0.92); }
|
||||
.badge-local { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.78); }
|
||||
.badge-hub { background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.65); }
|
||||
|
||||
.model-hub-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Polyfill the WebExtension API on Chrome so storage calls return Promises
|
||||
// like Firefox / Safari natively do. No-op on those runtimes.
|
||||
import 'webextension-polyfill';
|
||||
|
||||
import {
|
||||
CHAT_BUDGETS,
|
||||
debugLog,
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
// WebGPU AI Runner for Browser Extension
|
||||
// Enables local AI inference directly in the browser via WebGPU compute shaders.
|
||||
// Supports model loading, BPE tokenization, autoregressive generation, and embeddings.
|
||||
//
|
||||
// File format support:
|
||||
// - Raw blob (legacy hanzo-browser-control): the bytes are uploaded as one
|
||||
// GPUBuffer and consumed by the inline matmul/softmax shaders below.
|
||||
// - GGUF (llama.cpp's quantized container, v1-v3): parsed into a
|
||||
// name-keyed map of (offset, length, dtype, shape) descriptors, with
|
||||
// each tensor lazily uploaded to its own GPUBuffer through ExpertCache.
|
||||
// This unlocks Zen-Nano/Zen-Eco/Zen-Coder GGUF artifacts published on
|
||||
// HuggingFace without an intermediate conversion step.
|
||||
//
|
||||
// Sparse-load shell (ExpertCache below) materialises tensors on demand and
|
||||
// evicts cold ones under an LRU budget. For dense models the cache simply
|
||||
// holds every layer; for MoE (Zen Coder 480B, etc.) it only ever holds the
|
||||
// experts the router actually picks for live tokens, which is the whole
|
||||
// point of being able to run "too big" models in a browser.
|
||||
//
|
||||
// Still TODO before a GGUF model is end-to-end runnable here:
|
||||
// - Transformer kernel suite: RMSNorm, RoPE, multi-head attention with
|
||||
// KV cache, SwiGLU FFN, MoE router + dispatch (multiple thousand lines
|
||||
// of WGSL). The parser + cache below ARE the prerequisite layer.
|
||||
// - Speculative decoding: small dense draft (Zen Nano 0.6B) proposing N
|
||||
// tokens, large MoE verify (Zen Coder 31B/3B-active) checking the batch
|
||||
// in one forward pass. Layered on top of the kernels.
|
||||
|
||||
interface ModelConfig {
|
||||
name: string;
|
||||
@@ -15,6 +38,162 @@ interface LoadedModel {
|
||||
config: ModelConfig;
|
||||
vocab: string[];
|
||||
vocabIndex: Map<string, number>;
|
||||
// Populated when the artifact is GGUF (otherwise undefined and the legacy
|
||||
// single-blob matmul/softmax pipeline runs).
|
||||
gguf?: GGUFContext;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GGUF format
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Reference: https://github.com/ggerganov/ggml/blob/master/docs/gguf.md
|
||||
// Layout (LE throughout):
|
||||
// [magic:4 "GGUF"] [version:u32] [tensor_count:u64] [kv_count:u64]
|
||||
// <kv-metadata × kv_count>
|
||||
// <tensor-info × tensor_count>
|
||||
// <padding to alignment (default 32B)>
|
||||
// <tensor data blob>
|
||||
|
||||
const GGUF_MAGIC = 0x46554747; // "GGUF" little-endian as u32
|
||||
|
||||
enum GGUFType {
|
||||
UINT8 = 0,
|
||||
INT8 = 1,
|
||||
UINT16 = 2,
|
||||
INT16 = 3,
|
||||
UINT32 = 4,
|
||||
INT32 = 5,
|
||||
FLOAT32 = 6,
|
||||
BOOL = 7,
|
||||
STRING = 8,
|
||||
ARRAY = 9,
|
||||
UINT64 = 10,
|
||||
INT64 = 11,
|
||||
FLOAT64 = 12,
|
||||
}
|
||||
|
||||
// GGML quantization types — only the ones modern HF GGUFs use are enumerated.
|
||||
enum GGMLDType {
|
||||
F32 = 0,
|
||||
F16 = 1,
|
||||
Q4_0 = 2,
|
||||
Q4_1 = 3,
|
||||
Q5_0 = 6,
|
||||
Q5_1 = 7,
|
||||
Q8_0 = 8,
|
||||
Q8_1 = 9,
|
||||
Q2_K = 10,
|
||||
Q3_K = 11,
|
||||
Q4_K = 12,
|
||||
Q5_K = 13,
|
||||
Q6_K = 14,
|
||||
Q8_K = 15,
|
||||
I8 = 16,
|
||||
I16 = 17,
|
||||
I32 = 18,
|
||||
}
|
||||
|
||||
interface GGUFTensorInfo {
|
||||
name: string;
|
||||
shape: number[];
|
||||
dtype: GGMLDType;
|
||||
/** Byte offset within the tensor-data blob (NOT within the file). */
|
||||
offset: number;
|
||||
/** Bytes occupied — computed once at parse, used for slicing + cache budget. */
|
||||
length: number;
|
||||
}
|
||||
|
||||
interface GGUFContext {
|
||||
/** Raw KV metadata. ``general.architecture``, hyperparameters, vocab live here. */
|
||||
kv: Record<string, any>;
|
||||
/** Name → tensor descriptor. ``offset`` is relative to ``dataStart``. */
|
||||
tensors: Map<string, GGUFTensorInfo>;
|
||||
/** Byte offset in the source ArrayBuffer where the tensor data blob begins. */
|
||||
dataStart: number;
|
||||
/** Total tensor-data length. */
|
||||
dataLength: number;
|
||||
/** Backing buffer (kept for lazy slicing into GPUBuffers via ExpertCache). */
|
||||
source: ArrayBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* LRU sparse-load cache. Keys are typically ``"<layer>.<expert>.<tensor>"``
|
||||
* (or simpler ``"<tensor-name>"`` for dense models). On a miss the loader
|
||||
* fn is invoked to upload the bytes into a fresh GPUBuffer; on overflow the
|
||||
* coldest entry is destroyed.
|
||||
*
|
||||
* Why this shape: WebGPU has a ~2GB per-buffer cap and shared VRAM budgets
|
||||
* vary by platform, so the cache budget is in bytes (not entries). For MoE
|
||||
* the eviction policy is what lets you run Zen Coder 480B Q4 (~240GB
|
||||
* weights) on a 100GB Mac — the live working set per token is tiny.
|
||||
*/
|
||||
class ExpertCache {
|
||||
private map = new Map<string, { buffer: GPUBuffer; bytes: number }>();
|
||||
private order: string[] = []; // LRU: front = most recent
|
||||
private currentBytes = 0;
|
||||
|
||||
constructor(
|
||||
private device: GPUDevice,
|
||||
/** Max bytes resident on the device. Default 4 GiB; bump for larger VRAM. */
|
||||
private budgetBytes: number = 4 * 1024 * 1024 * 1024,
|
||||
) {}
|
||||
|
||||
has(key: string): boolean { return this.map.has(key); }
|
||||
|
||||
/** Get the GPUBuffer for ``key``, loading it on miss. */
|
||||
async get(key: string, loader: () => Promise<{ data: ArrayBuffer; label?: string }>): Promise<GPUBuffer> {
|
||||
const hit = this.map.get(key);
|
||||
if (hit) {
|
||||
this._touch(key);
|
||||
return hit.buffer;
|
||||
}
|
||||
const { data, label } = await loader();
|
||||
while (this.currentBytes + data.byteLength > this.budgetBytes && this.order.length > 0) {
|
||||
this._evictColdest();
|
||||
}
|
||||
const buffer = this.device.createBuffer({
|
||||
size: data.byteLength,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
||||
label: label || `expert-${key}`,
|
||||
});
|
||||
this.device.queue.writeBuffer(buffer, 0, data);
|
||||
this.map.set(key, { buffer, bytes: data.byteLength });
|
||||
this.order.unshift(key);
|
||||
this.currentBytes += data.byteLength;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Force-evict everything. */
|
||||
clear(): void {
|
||||
for (const { buffer } of this.map.values()) buffer.destroy();
|
||||
this.map.clear();
|
||||
this.order = [];
|
||||
this.currentBytes = 0;
|
||||
}
|
||||
|
||||
stats(): { entries: number; bytes: number; budget: number } {
|
||||
return { entries: this.map.size, bytes: this.currentBytes, budget: this.budgetBytes };
|
||||
}
|
||||
|
||||
private _touch(key: string): void {
|
||||
const i = this.order.indexOf(key);
|
||||
if (i > 0) {
|
||||
this.order.splice(i, 1);
|
||||
this.order.unshift(key);
|
||||
}
|
||||
}
|
||||
|
||||
private _evictColdest(): void {
|
||||
const coldKey = this.order.pop();
|
||||
if (!coldKey) return;
|
||||
const entry = this.map.get(coldKey);
|
||||
if (!entry) return;
|
||||
entry.buffer.destroy();
|
||||
this.map.delete(coldKey);
|
||||
this.currentBytes -= entry.bytes;
|
||||
console.log(`[Hanzo AI] Cache evicted ${coldKey} (${entry.bytes} bytes)`);
|
||||
}
|
||||
}
|
||||
|
||||
export class WebGPUAI {
|
||||
@@ -174,12 +353,11 @@ export class WebGPUAI {
|
||||
|
||||
console.log(`[Hanzo AI] Loading model: ${config.name}`);
|
||||
|
||||
// Load model weights
|
||||
const response = await fetch(config.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch model ${config.name}: ${response.status}`);
|
||||
}
|
||||
const modelData = await response.arrayBuffer();
|
||||
// Load model weights — cache-first, streaming with progress logs.
|
||||
// Service-worker Cache API persists across worker restarts so the bytes
|
||||
// are downloaded at most once per URL. Range-streaming the body avoids
|
||||
// pinning the entire blob in JS memory before GPU upload.
|
||||
const modelData = await this._fetchWithCache(config.url, config.name);
|
||||
|
||||
// Create GPU buffer for model weights
|
||||
const modelBuffer = this.device.createBuffer({
|
||||
@@ -579,6 +757,98 @@ export class WebGPUAI {
|
||||
return embedding;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cached, progress-reporting fetch — used by loadModel().
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch a model blob with persistent caching. Backed by the service-worker
|
||||
* Cache API so the bytes survive worker restarts and only download once
|
||||
* per URL. Streams the body to log progress every ~5% so big artifacts
|
||||
* (huggingface.co/<repo>/resolve/main/<file>) don't appear to hang.
|
||||
*
|
||||
* Cache invalidation: the URL is the key; bump the URL (e.g. point to a
|
||||
* different revision sha) to force a re-fetch. Use ``WebGPUAI.evictCache``
|
||||
* to wipe everything when models go bad.
|
||||
*/
|
||||
private async _fetchWithCache(url: string, label: string): Promise<ArrayBuffer> {
|
||||
const cacheName = 'hanzo-webgpu-models-v1';
|
||||
const cache = typeof caches !== 'undefined' ? await caches.open(cacheName) : null;
|
||||
|
||||
if (cache) {
|
||||
const hit = await cache.match(url);
|
||||
if (hit) {
|
||||
console.log(`[Hanzo AI] Cache hit: ${label} (${url})`);
|
||||
return await hit.arrayBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Hanzo AI] Fetching: ${label} (${url})`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${label}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const total = Number(response.headers.get('content-length') || 0);
|
||||
let received = 0;
|
||||
let nextLog = total > 0 ? Math.floor(total * 0.05) : Infinity;
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
if (response.body) {
|
||||
const reader = response.body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.byteLength;
|
||||
if (total > 0 && received >= nextLog) {
|
||||
const pct = Math.floor((received / total) * 100);
|
||||
console.log(`[Hanzo AI] ${label}: ${pct}% (${received}/${total} bytes)`);
|
||||
nextLog = received + Math.floor(total * 0.05);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
chunks.push(new Uint8Array(await response.arrayBuffer()));
|
||||
}
|
||||
|
||||
// Stitch chunks into a single ArrayBuffer.
|
||||
const blob = new Uint8Array(received || chunks.reduce((n, c) => n + c.byteLength, 0));
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
blob.set(c, offset);
|
||||
offset += c.byteLength;
|
||||
}
|
||||
console.log(`[Hanzo AI] Fetched: ${label} (${blob.byteLength} bytes)`);
|
||||
|
||||
// Write back into the cache. Response body is single-use; re-construct
|
||||
// from the assembled blob so cache.put gets a fresh, readable stream.
|
||||
if (cache) {
|
||||
try {
|
||||
await cache.put(url, new Response(blob, {
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
}));
|
||||
console.log(`[Hanzo AI] Cached: ${label}`);
|
||||
} catch (e) {
|
||||
// Quota errors are non-fatal — we still have the bytes in memory.
|
||||
console.warn(`[Hanzo AI] Cache write failed for ${label}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return blob.buffer;
|
||||
}
|
||||
|
||||
/** Build an HF resolve URL for a given repo / file / revision. */
|
||||
static huggingFaceURL(repo: string, file: string, revision: string = 'main'): string {
|
||||
return `https://huggingface.co/${repo}/resolve/${revision}/${file}`;
|
||||
}
|
||||
|
||||
/** Wipe every cached model blob. Useful when a model artifact is rotated. */
|
||||
static async evictCache(): Promise<void> {
|
||||
if (typeof caches === 'undefined') return;
|
||||
await caches.delete('hanzo-webgpu-models-v1');
|
||||
console.log('[Hanzo AI] WebGPU model cache evicted');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status / Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,27 +7,24 @@ import * as fs from 'fs';
|
||||
describe('Claude Code Browser Extension Integration', () => {
|
||||
let server: BrowserExtensionServer;
|
||||
let ws: WebSocket;
|
||||
const TEST_PORT = 3002;
|
||||
|
||||
|
||||
beforeEach(async () => {
|
||||
// Start server
|
||||
server = new BrowserExtensionServer(TEST_PORT, process.cwd());
|
||||
|
||||
// Wait for server to be ready
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Connect WebSocket
|
||||
ws = new WebSocket(`ws://localhost:${TEST_PORT}/browser-extension`);
|
||||
// Port 0 = OS-assigned. A fixed port raced the previous test's teardown
|
||||
// (EADDRINUSE → unhandled 'error' → hook timeout); ready() waits for the
|
||||
// actual bind instead of sleeping and hoping.
|
||||
server = new BrowserExtensionServer(0, process.cwd());
|
||||
await server.ready();
|
||||
|
||||
ws = new WebSocket(`ws://localhost:${server.port}/browser-extension`);
|
||||
await new Promise(resolve => ws.on('open', resolve));
|
||||
});
|
||||
|
||||
|
||||
afterEach(async () => {
|
||||
ws.close();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
// Fallback if close callback never fires
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
// terminate() drops the client immediately so wss.close() isn't left
|
||||
// waiting on a close handshake — the callback then reliably fires and
|
||||
// no fallback timer is needed.
|
||||
ws.terminate();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe('React source-map integration', () => {
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Cross-browser parity contracts.
|
||||
*
|
||||
* These tests assert that the things which were broken in 1.8.x stay
|
||||
* fixed in 1.9.0 and beyond. They probe BOTH the Chrome (background.ts +
|
||||
* browser-dispatch.ts) and Firefox (background-firefox.ts) source files
|
||||
* for the structural patterns that distinguish a working build from a
|
||||
* regressed one.
|
||||
*
|
||||
* Why string-search instead of behavior tests? The actual page execution
|
||||
* path requires a real browser. These contracts run as `vitest run` in
|
||||
* CI without a browser, so they're a fast lint-class signal that no
|
||||
* future PR silently re-introduces the bug.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
function read(p: string): string {
|
||||
return fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
|
||||
}
|
||||
|
||||
const chromeBg = read('src/background.ts');
|
||||
const firefoxBg = read('src/background-firefox.ts');
|
||||
const cdpBridge = read('src/browser-dispatch.ts');
|
||||
const browserControl = read('src/browser-control.ts');
|
||||
const popupTs = read('src/popup.ts');
|
||||
const contentScript = read('src/content-script.ts');
|
||||
const sharedAuth = read('src/shared/auth.ts');
|
||||
const sharedConfig = read('src/shared/config.ts');
|
||||
const manifestChrome = read('src/manifest.json');
|
||||
const manifestFirefox = read('src/manifest-firefox.json');
|
||||
|
||||
describe('IAM URL conventions', () => {
|
||||
it('IAM_API_PATH is /v1/iam (NOT /api/, NOT bare /oauth)', () => {
|
||||
expect(sharedConfig).toContain("IAM_API_PATH = '/v1/iam'");
|
||||
});
|
||||
|
||||
it('shared auth uses the HIP-0111 canonical ${IAM_API_PATH}/oauth/token endpoint', () => {
|
||||
// Pre-HIP-0111 shapes are both dead: the Casdoor
|
||||
// /login/oauth/access_token path and a bare (un-prefixed) /oauth/token
|
||||
// that 404'd before 1.8.7. Strip comments before scanning — the
|
||||
// doc-comments intentionally reference the old paths to explain the bug.
|
||||
expect(sharedAuth).toContain('${IAM_API_PATH}/oauth/token');
|
||||
const stripped = sharedAuth
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
||||
.replace(/(^|\n)\s*\/\/.*?(?=\n|$)/g, '$1'); // line comments
|
||||
expect(stripped).not.toContain('/login/oauth/access_token');
|
||||
expect(stripped).not.toMatch(/['"`]\/oauth\/token['"`]/);
|
||||
});
|
||||
|
||||
it('token exchange uses application/x-www-form-urlencoded (RFC 6749)', () => {
|
||||
// application/json was the bug pre-1.8.7. Verify the body shape.
|
||||
const tokenBlock = sharedAuth.slice(
|
||||
sharedAuth.indexOf('${IAM_API_PATH}/oauth/token'),
|
||||
sharedAuth.indexOf('${IAM_API_PATH}/oauth/token') + 1500,
|
||||
);
|
||||
expect(tokenBlock).toContain('application/x-www-form-urlencoded');
|
||||
expect(tokenBlock).toContain('URLSearchParams');
|
||||
});
|
||||
|
||||
it('Chrome background uses ${IAM_V1} for IAM URLs (no /api/ prefix)', () => {
|
||||
// Find the IAM_V1 declaration and assert it.
|
||||
expect(chromeBg).toContain('const IAM_V1 = `${IAM_API_URL}${IAM_API_PATH}`');
|
||||
// No raw /api/login or /api/get-account anywhere in chrome bg.
|
||||
expect(chromeBg).not.toMatch(/iam\.hanzo\.ai\/api\//);
|
||||
});
|
||||
|
||||
it('Firefox background uses ${IAM_V1} for IAM URLs', () => {
|
||||
expect(firefoxBg).toContain('const IAM_V1 = `${IAM_API}${IAM_API_PATH}`');
|
||||
expect(firefoxBg).not.toMatch(/iam\.hanzo\.ai\/api\//);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTabId centralization', () => {
|
||||
it('browser-dispatch.ts imports parseTabId from shared/tab-id', () => {
|
||||
expect(cdpBridge).toMatch(/import\s+\{[^}]*parseTabId[^}]*\}\s+from\s+['"]\.\/shared\/tab-id\.js['"]/);
|
||||
});
|
||||
|
||||
it('browser-dispatch.ts no longer defines its own parseTabId', () => {
|
||||
// The class private parseTabId existed as a duplicate before 1.9.0.
|
||||
expect(cdpBridge).not.toMatch(/private\s+parseTabId\s*\(/);
|
||||
});
|
||||
|
||||
it('background-firefox.ts imports parseTabId from shared/tab-id', () => {
|
||||
expect(firefoxBg).toMatch(/import\s+\{[^}]*parseTabId[^}]*\}\s+from\s+['"]\.\/shared\/tab-id\.js['"]/);
|
||||
});
|
||||
|
||||
it('background-firefox.ts no longer defines its own parseTabId', () => {
|
||||
expect(firefoxBg).not.toMatch(/private\s+parseTabId\s*\(/);
|
||||
});
|
||||
|
||||
it('background-firefox.ts uses parseTabId in Target.closeTarget (not parseInt fallback)', () => {
|
||||
// The pre-1.9.0 code did `parseInt((params.targetId).replace('tab-',''),10)`.
|
||||
// That was lossy on non-numeric strings and missed the anchored regex
|
||||
// protection of parseTabId.
|
||||
expect(firefoxBg).not.toMatch(/parseInt\(\(params\.targetId\b/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('awaitPromise propagation', () => {
|
||||
it('browser-dispatch.ts handleBridgeMessage Runtime.evaluate sets awaitPromise: true', () => {
|
||||
// Find the handleBridgeMessage Runtime.evaluate path.
|
||||
const evalSection = cdpBridge.slice(
|
||||
cdpBridge.indexOf("case 'Runtime.evaluate':"),
|
||||
cdpBridge.indexOf("case 'DOM.getDocument':"),
|
||||
);
|
||||
expect(evalSection).toContain('awaitPromise: true');
|
||||
});
|
||||
|
||||
it('browser-dispatch.ts evaluate() helper uses awaitPromise: true', () => {
|
||||
// The local `evaluate(tabId, expression)` method was the one missed
|
||||
// before 1.9.0 — the bridge handler had it but this method didn't.
|
||||
const helperSection = cdpBridge.slice(
|
||||
cdpBridge.indexOf('async evaluate(tabId: number'),
|
||||
cdpBridge.indexOf('async evaluate(tabId: number') + 900,
|
||||
);
|
||||
expect(helperSection).toContain('awaitPromise: true');
|
||||
expect(helperSection).toContain('unwrapEvaluateResult');
|
||||
});
|
||||
|
||||
it('both browsers route caller JS through wrapEvaluable (one canonical rule)', () => {
|
||||
// The forgiving-evaluate rule (statement bodies → async IIFE, trailing
|
||||
// value auto-returned) lives ONCE in shared/evaluable.ts. Each engine's
|
||||
// evaluate surface must consume it — a fork here is how the browsers
|
||||
// drift apart on what caller JS they accept.
|
||||
for (const src of [cdpBridge, firefoxBg]) {
|
||||
expect(src).toMatch(/import\s+\{[^}]*wrapEvaluable[^}]*\}\s+from\s+['"]\.\/shared\/evaluable\.js['"]/);
|
||||
expect(src).toContain('wrapEvaluable(pickEvaluable(params))');
|
||||
}
|
||||
});
|
||||
|
||||
it('Firefox evaluate always resolves promises in-page before serialization', () => {
|
||||
// executeScript's structured clone cannot transfer a Promise — the
|
||||
// in-page body must settle it and hand back the value. Rejections ride
|
||||
// the __hanzo_error channel runInPage throws on.
|
||||
const evalSection = firefoxBg.slice(
|
||||
firefoxBg.indexOf("case 'Runtime.evaluate':"),
|
||||
firefoxBg.indexOf("case 'Page.captureScreenshot':"),
|
||||
);
|
||||
expect(evalSection).toContain('Promise.resolve(');
|
||||
expect(evalSection).toContain('__hanzo_error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeScript MV3 contract', () => {
|
||||
it('background-firefox.ts uses scripting.executeScript with world: MAIN', () => {
|
||||
// The Firefox path has used the MV3 scripting API since 1.8.6.
|
||||
expect(firefoxBg).toMatch(/scripting\.executeScript/);
|
||||
expect(firefoxBg).toMatch(/world:\s*['"]MAIN['"]/);
|
||||
});
|
||||
|
||||
it('browser-control.ts uses scripting.executeScript with world: MAIN', () => {
|
||||
// The big regression in 1.8.x: browser-control still used `func: new Function(...)`
|
||||
// without the MAIN world hint, so async results were silently lost. 1.9.0
|
||||
// mirrors the Firefox pattern.
|
||||
expect(browserControl).toMatch(/scripting\.executeScript/);
|
||||
expect(browserControl).toMatch(/world:\s*['"]MAIN['"]/);
|
||||
expect(browserControl).toContain('Function(codeStr)()');
|
||||
});
|
||||
|
||||
it('browser-control.ts handles the empty-{} promise drop', () => {
|
||||
// Detect the looksEmpty / Promise.resolve fallback pattern.
|
||||
expect(browserControl).toMatch(/Promise\.resolve\(/);
|
||||
expect(browserControl).toMatch(/__hanzo_error/);
|
||||
});
|
||||
|
||||
it('shipping code does NOT call the removed MV2 tabs.executeScript directly', () => {
|
||||
// Allowed: reference inside the MV2 fallback branch (gated by `if`).
|
||||
// Forbidden: as the primary call path. Probe that any such reference
|
||||
// is wrapped behind a "legacy" / fallback comment.
|
||||
const usages = [
|
||||
...chromeBg.matchAll(/\btabs\.executeScript\b/g),
|
||||
...firefoxBg.matchAll(/\btabs\.executeScript\b/g),
|
||||
...browserControl.matchAll(/\btabs\.executeScript\b/g),
|
||||
];
|
||||
// Each remaining match must be in a fallback context (not the primary
|
||||
// path). Heuristic: surrounding 200 chars include the words 'fallback'
|
||||
// or 'legacy' or 'MV2'.
|
||||
for (const m of usages) {
|
||||
const idx = m.index!;
|
||||
const file = m.input!;
|
||||
const ctx = file.slice(Math.max(0, idx - 200), idx + 200);
|
||||
expect(ctx).toMatch(/fallback|legacy|MV2/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sidebar / chat-panel surface', () => {
|
||||
it('Chrome manifest does NOT declare side_panel', () => {
|
||||
const m = JSON.parse(manifestChrome);
|
||||
expect(m.side_panel).toBeUndefined();
|
||||
expect(m.permissions).not.toContain('sidePanel');
|
||||
});
|
||||
|
||||
it('Firefox manifest sidebar_action panel points at sidebar.html (Firefox-only fallback)', () => {
|
||||
// Firefox has no chrome.sidePanel API, so a manifest sidebar_action
|
||||
// is the only native sidebar entrypoint. Chrome MV3 must NOT have
|
||||
// either side_panel or sidePanel permission — the Chrome chat
|
||||
// surface is the page-overlay content-script.
|
||||
const m = JSON.parse(manifestFirefox);
|
||||
if (m.sidebar_action) {
|
||||
expect(m.sidebar_action.default_panel).toBe('sidebar.html');
|
||||
}
|
||||
});
|
||||
|
||||
it('background.ts does NOT call chrome.sidePanel.setOptions', () => {
|
||||
expect(chromeBg).not.toMatch(/chrome\.sidePanel\.setOptions/);
|
||||
});
|
||||
|
||||
it('content-script chat overlay anchors to the right edge (not left)', () => {
|
||||
// The right-anchor is the user's hard requirement. The chat panel
|
||||
// is the #hanzo-page-overlay-root element. It anchors with
|
||||
// `right: 0` (flush) by default and flips to `left: 0` only when
|
||||
// [data-side="left"] is set explicitly.
|
||||
const overlayBlock = contentScript.slice(
|
||||
contentScript.indexOf('#hanzo-page-overlay-root {'),
|
||||
contentScript.indexOf('#hanzo-page-overlay-root {') + 400,
|
||||
);
|
||||
expect(overlayBlock).toMatch(/right:\s*0/);
|
||||
// The default (no data-side) block must NOT have a `left:` declaration.
|
||||
expect(overlayBlock).not.toMatch(/^\s*left:\s/m);
|
||||
});
|
||||
|
||||
it('popup.ts routes "Open Chat Panel" via page.overlay.show (NOT chrome.sidePanel.open)', () => {
|
||||
expect(popupTs).toContain("action: 'page.overlay.show'");
|
||||
// Double-check no leftover sidepanel call paths.
|
||||
expect(popupTs).not.toMatch(/chrome\.sidePanel\.(open|toggle)/);
|
||||
expect(popupTs).not.toMatch(/sidebarAction\.(open|toggle)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inspect-modifier shortcut', () => {
|
||||
it('default is Ctrl alone (NOT Alt — Alt collides with macOS)', () => {
|
||||
const shortcut = read('src/shared/shortcut.ts');
|
||||
const block = shortcut.slice(
|
||||
shortcut.indexOf('DEFAULT_INSPECT_SHORTCUT'),
|
||||
shortcut.indexOf('DEFAULT_INSPECT_SHORTCUT') + 400,
|
||||
);
|
||||
expect(block).toMatch(/ctrl:\s*true/);
|
||||
expect(block).toMatch(/alt:\s*false/);
|
||||
});
|
||||
|
||||
it('content-script reloads shortcut on storage change (no page reload needed)', () => {
|
||||
expect(contentScript).toMatch(/storage\.onChanged\.addListener/);
|
||||
expect(contentScript).toMatch(/STORAGE_KEY_INSPECT/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth client ID convention', () => {
|
||||
it('client ID is `hanzo-browser` (registered IAM <org>-<app> client)', () => {
|
||||
// hanzo-browser is the IAM application seeded for the extension, with
|
||||
// redirectUris including https://hanzo.ai/callback. The earlier value
|
||||
// `app-hanzo` was unregistered → token exchange returned invalid_client.
|
||||
expect(sharedConfig).toContain("DEFAULT_CLIENT_ID = 'hanzo-browser'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('webextension-polyfill integration', () => {
|
||||
it('polyfill is the FIRST import in every entrypoint', () => {
|
||||
// Order matters: the polyfill must define globalThis.browser before
|
||||
// any other import reads it. ESBuild preserves import order during
|
||||
// bundling, so the source order is what ships.
|
||||
const entrypoints = [
|
||||
['background.ts', chromeBg],
|
||||
['background-firefox.ts', firefoxBg],
|
||||
['content-script.ts', contentScript],
|
||||
['popup.ts', popupTs],
|
||||
];
|
||||
for (const [name, src] of entrypoints) {
|
||||
const polyfillIdx = src.indexOf("import 'webextension-polyfill'");
|
||||
expect(polyfillIdx, `${name} missing polyfill import`).toBeGreaterThanOrEqual(0);
|
||||
// No other `import` line should come before it.
|
||||
const before = src.slice(0, polyfillIdx);
|
||||
expect(before, `${name} has imports before the polyfill`)
|
||||
.not.toMatch(/^\s*import\s/m);
|
||||
}
|
||||
});
|
||||
|
||||
it('shared/auth.ts exports a unified webExtensionAdapter', () => {
|
||||
expect(sharedAuth).toMatch(/export\s+function\s+webExtensionAdapter\s*\(/);
|
||||
});
|
||||
|
||||
it('chromeAdapter and firefoxAdapter still exist as compatibility aliases', () => {
|
||||
// We cannot break callers that imported them.
|
||||
expect(sharedAuth).toMatch(/export\s+function\s+chromeAdapter\s*\(/);
|
||||
expect(sharedAuth).toMatch(/export\s+function\s+firefoxAdapter\s*\(/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Firefox register identity', () => {
|
||||
it('Firefox sends `browser: BROWSER_NAME` so multi-browser routing works', () => {
|
||||
// Pre-1.9.0 the register payload omitted `browser`, so the bridge
|
||||
// server stored the client as 'unknown' and the user couldn't route
|
||||
// commands by browser name. Verify the field is present.
|
||||
const registerBlock = firefoxBg.slice(
|
||||
firefoxBg.indexOf('private register():'),
|
||||
firefoxBg.indexOf('private register():') + 1500,
|
||||
);
|
||||
expect(registerBlock).toMatch(/browser:\s*BROWSER_NAME/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* executeScript pattern coverage.
|
||||
*
|
||||
* The pre-1.9.0 bug was: `chrome.scripting.executeScript({ func: new Function(...) })`
|
||||
* silently lost async results because:
|
||||
* - Default world is ISOLATED, which can't see the page's globals
|
||||
* (window.fetch in some sandboxes, framework instances, etc).
|
||||
* - The MV3 API doesn't await Promises returned by `func`.
|
||||
* - When the user's IIFE returned `undefined`, that JSON-encoded as `{}`
|
||||
* and the caller couldn't tell success from failure.
|
||||
*
|
||||
* The 1.9.0 fix mirrors the Firefox pattern:
|
||||
* - `world: 'MAIN'` so the page's own globals are visible.
|
||||
* - A `Function(codeStr)()` trampoline so dynamic code strings still work.
|
||||
* - Empty-{} detection that re-runs wrapped in `Promise.resolve(...)` to
|
||||
* capture the resolved value.
|
||||
*
|
||||
* These tests exercise the helpers in isolation. End-to-end coverage
|
||||
* lives in the e2e specs (Playwright with extension loaded).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { unwrapEvaluateResult, parseTabId, tabTarget } from '../src/shared/tab-id.js';
|
||||
|
||||
describe('Empty-{} Promise drop detection (the 1.8.x regression)', () => {
|
||||
it('unwrapEvaluateResult({}) returns undefined (sentinel for "Promise was dropped")', () => {
|
||||
expect(unwrapEvaluateResult({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('non-empty object that has none of the known keys is preserved', () => {
|
||||
const x = { foo: 1 };
|
||||
expect(unwrapEvaluateResult(x)).toEqual(x);
|
||||
});
|
||||
|
||||
it('arrays are preserved (NOT collapsed to undefined)', () => {
|
||||
expect(unwrapEvaluateResult([])).toEqual([]);
|
||||
expect(unwrapEvaluateResult([1, 2])).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTabId security and parity', () => {
|
||||
it('rejects URL-embedded tab-N (security: prevents tabId injection)', () => {
|
||||
expect(parseTabId('https://x.com/?tab-123')).toBeNull();
|
||||
expect(parseTabId('javascript:alert(tab-1)')).toBeNull();
|
||||
expect(parseTabId('data:tab-1,foo')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts the bare wire formats Target.getTargets returns', () => {
|
||||
expect(parseTabId('tab-1')).toBe(1);
|
||||
expect(parseTabId('tab-1888868904')).toBe(1888868904);
|
||||
expect(parseTabId('1888868904')).toBe(1888868904);
|
||||
});
|
||||
|
||||
it('returns null on every invalid input (no exceptions, no NaN)', () => {
|
||||
expect(parseTabId(undefined)).toBeNull();
|
||||
expect(parseTabId(null)).toBeNull();
|
||||
expect(parseTabId('')).toBeNull();
|
||||
expect(parseTabId('NaN')).toBeNull();
|
||||
expect(parseTabId('Infinity')).toBeNull();
|
||||
expect(parseTabId({ tabId: 1 })).toBeNull();
|
||||
expect(parseTabId(true)).toBeNull();
|
||||
});
|
||||
|
||||
it('the 1.8.4 anchored-regex hardening is in place', () => {
|
||||
// `1.5e10abc` and `123abc` were the cases the 1.8.4 hardening fixed.
|
||||
expect(parseTabId('1.5e10abc')).toBeNull();
|
||||
expect(parseTabId('123abc')).toBeNull();
|
||||
expect(parseTabId('abc123')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabTarget composes cleanly', () => {
|
||||
it('empty source returns empty (or extra-only) object', () => {
|
||||
expect(tabTarget(undefined)).toEqual({});
|
||||
expect(tabTarget(undefined, { url: 'x' })).toEqual({ url: 'x' });
|
||||
});
|
||||
|
||||
it('tabId wins over targetId', () => {
|
||||
expect(tabTarget({ tabId: 5, targetId: 'tab-9' })).toEqual({ tabId: 5 });
|
||||
});
|
||||
|
||||
it('targetId is fallback when tabId absent', () => {
|
||||
expect(tabTarget({ targetId: 'tab-9' })).toEqual({ tabId: 'tab-9' });
|
||||
});
|
||||
|
||||
it('null fields are dropped (so they do not collide with extras)', () => {
|
||||
expect(tabTarget({ tabId: null, targetId: null } as any)).toEqual({});
|
||||
});
|
||||
|
||||
it('preserves tabIndex when only that is provided', () => {
|
||||
expect(tabTarget({ tabIndex: 3 })).toEqual({ tabIndex: 3 });
|
||||
});
|
||||
});
|
||||
@@ -34,36 +34,6 @@ describe('hub message handler wiring', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Verify cdp-bridge-server.ts has all hub_* bridge actions
|
||||
describe('CDP bridge hub actions', () => {
|
||||
const bridgeSrc = fs.readFileSync(
|
||||
path.join(__dirname, '../src/cdp-bridge-server.ts'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const requiredActions = [
|
||||
'hub_search',
|
||||
'hub_search_gguf',
|
||||
'hub_search_mlx',
|
||||
'hub_model',
|
||||
'hub_model_card',
|
||||
'hub_model_stats',
|
||||
'hub_recommended',
|
||||
'hub_search_ollama',
|
||||
'hub_download_ollama',
|
||||
'hub_download_hf',
|
||||
'hub_all_models',
|
||||
];
|
||||
|
||||
for (const action of requiredActions) {
|
||||
it(`has case '${action}' action`, () => {
|
||||
expect(bridgeSrc).toContain(`case '${action}'`);
|
||||
});
|
||||
}
|
||||
|
||||
it('lists hub actions in status response', () => {
|
||||
for (const action of requiredActions) {
|
||||
expect(bridgeSrc).toContain(`'${action}'`);
|
||||
}
|
||||
});
|
||||
});
|
||||
// The node bridge (cdp-bridge-server.ts) and its snake_case hub_* action
|
||||
// namespace were removed with it — hub access now rides the ZAP surface
|
||||
// into the background.ts `hub.*` handlers asserted above.
|
||||
|
||||
@@ -81,9 +81,23 @@ describe('searchOllamaLibrary', () => {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HuggingFace API integration tests (real network calls)
|
||||
//
|
||||
// These verify OUR client against the live API. When HF itself refuses
|
||||
// service (rate-limit 429, outage, no network) there is nothing of ours to
|
||||
// verify — skip with the status logged instead of failing on the weather.
|
||||
// Any failure while HF answers 200 is still a real failure. Live calls get
|
||||
// a 15s budget (HF routinely exceeds vitest's 5s default under load).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('HuggingFace API', () => {
|
||||
const HF_LIVE_TIMEOUT = 15_000;
|
||||
const hfStatus = await fetch('https://huggingface.co/api/models?limit=1', {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
}).then((r) => r.status, () => 0);
|
||||
if (hfStatus !== 200) {
|
||||
console.warn(`[model-hub.test] skipping live HF tests — huggingface.co status ${hfStatus || 'unreachable'}`);
|
||||
}
|
||||
|
||||
describe.skipIf(hfStatus !== 200)('HuggingFace API', () => {
|
||||
it('searchHuggingFace returns models', async () => {
|
||||
const models = await searchHuggingFace('llama', { limit: 2 });
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
@@ -92,7 +106,7 @@ describe('HuggingFace API', () => {
|
||||
expect(m.id).toBeTruthy();
|
||||
expect(m.source).toBe('huggingface');
|
||||
}
|
||||
});
|
||||
}, HF_LIVE_TIMEOUT);
|
||||
|
||||
it('searchGGUF returns GGUF-tagged models', async () => {
|
||||
const models = await searchGGUF('qwen', 3);
|
||||
@@ -100,12 +114,12 @@ describe('HuggingFace API', () => {
|
||||
for (const m of models) {
|
||||
expect(m.tags).toBeInstanceOf(Array);
|
||||
}
|
||||
});
|
||||
}, HF_LIVE_TIMEOUT);
|
||||
|
||||
it('searchMLX returns MLX models', async () => {
|
||||
const models = await searchMLX('llama', 3);
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
});
|
||||
}, HF_LIVE_TIMEOUT);
|
||||
|
||||
it('getHuggingFaceModel returns model with files', async () => {
|
||||
// Use a well-known public model that doesn't require auth
|
||||
@@ -124,18 +138,18 @@ describe('HuggingFace API', () => {
|
||||
).toBe(true);
|
||||
expect(f.downloadUrl).toContain('huggingface.co');
|
||||
}
|
||||
});
|
||||
}, HF_LIVE_TIMEOUT);
|
||||
|
||||
it('getModelCard returns markdown', async () => {
|
||||
const card = await getModelCard('TheBloke/Llama-2-7B-Chat-GGUF');
|
||||
expect(typeof card).toBe('string');
|
||||
// Card may be empty for some models; just verify it's a string
|
||||
expect(card).toBeDefined();
|
||||
});
|
||||
}, HF_LIVE_TIMEOUT);
|
||||
|
||||
it('getModelStats returns download count', async () => {
|
||||
const stats = await getModelStats('TheBloke/Llama-2-7B-Chat-GGUF');
|
||||
expect(stats.downloads).toBeGreaterThan(0);
|
||||
expect(stats.tags).toBeInstanceOf(Array);
|
||||
});
|
||||
}, HF_LIVE_TIMEOUT);
|
||||
});
|
||||
|
||||
@@ -39,8 +39,11 @@ describe('IAM constants', () => {
|
||||
expect(KMS_API_URL).toBe('https://kms.hanzo.ai');
|
||||
});
|
||||
|
||||
it('DEFAULT_CLIENT_ID is app-hanzo', () => {
|
||||
expect(DEFAULT_CLIENT_ID).toBe('app-hanzo');
|
||||
it('DEFAULT_CLIENT_ID is hanzo-browser (a registered IAM client)', () => {
|
||||
// Must match an application seeded in IAM (init_data.json → "hanzo-browser")
|
||||
// whose redirectUris include DEFAULT_REDIRECT_URI. `app-hanzo` was never a
|
||||
// real client and made the PKCE token exchange fail with invalid_client.
|
||||
expect(DEFAULT_CLIENT_ID).toBe('hanzo-browser');
|
||||
});
|
||||
|
||||
it('DEFAULT_SCOPES includes openid', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user