Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86bc401545 | ||
|
|
a4fd6b1557 | ||
|
|
a5a8a79a17 | ||
|
|
9a9b1cacfe | ||
|
|
054dbd8ae8 | ||
|
|
7461549cd6 | ||
|
|
69310a0445 | ||
|
|
54375ec428 | ||
|
|
6edd326615 | ||
|
|
763e6b8eb0 | ||
|
|
bee346de71 | ||
|
|
c258e74b95 | ||
|
|
967297f2dc | ||
|
|
300337a57b | ||
|
|
8e722f22fe | ||
|
|
5a55ce029f | ||
|
|
abb2ad56cb | ||
|
|
4193d4889e | ||
|
|
8de63fa05f | ||
|
|
02b447d027 | ||
|
|
96807b05cf | ||
|
|
05a35ac763 | ||
|
|
a164395d4a | ||
|
|
4670a9ee91 | ||
|
|
a2c014b777 | ||
|
|
fc6d9bb805 | ||
|
|
ad6af1fd72 | ||
|
|
73370ce288 | ||
|
|
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 |
@@ -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 |
+135
-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,128 @@ 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 Epic (SMART-on-FHIR)
|
||||
run: pnpm --filter @hanzo/epic test
|
||||
|
||||
- name: Test Canvas LMS (education)
|
||||
run: pnpm --filter @hanzo/canvas-lms test
|
||||
|
||||
- name: Test Workday (HR)
|
||||
run: pnpm --filter @hanzo/workday test
|
||||
|
||||
- name: Test iManage (legal DMS)
|
||||
run: pnpm --filter @hanzo/imanage 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 +183,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 +207,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 +254,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 +331,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 +363,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
|
||||
|
||||
|
||||
+468
-81
@@ -1,5 +1,12 @@
|
||||
name: Publish
|
||||
|
||||
# Store-publish (Chrome Web Store, Firefox AMO, VS Code, Open VSX, JetBrains) and
|
||||
# every store credential have moved to the native pipeline on Hanzo Git:
|
||||
# .gitea/workflows/publish.yml — reads creds from KMS (deploy/kms/…), NOT GitHub.
|
||||
# GitHub retains only: build + the downloadable GitHub Release (mirror
|
||||
# distribution), and the live npm publish (interim — migrates to native once the
|
||||
# KMS NPM_TOKEN is seeded and the native run is verified green).
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
@@ -17,30 +24,21 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ─── Check available secrets ───
|
||||
# ─── npm publish gate (the one store credential still on GitHub) ───
|
||||
secrets:
|
||||
name: Load KMS Secrets
|
||||
runs-on: ubuntu-latest
|
||||
name: Check npm token
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
outputs:
|
||||
has-chrome: ${{ steps.check.outputs.has-chrome }}
|
||||
has-firefox: ${{ steps.check.outputs.has-firefox }}
|
||||
has-vsce: ${{ steps.check.outputs.has-vsce }}
|
||||
has-npm: ${{ steps.check.outputs.has-npm }}
|
||||
has-jetbrains: ${{ steps.check.outputs.has-jetbrains }}
|
||||
steps:
|
||||
- name: Check available secrets
|
||||
- name: Check npm token
|
||||
id: check
|
||||
run: |
|
||||
echo "has-chrome=${{ secrets.CHROME_EXTENSION_ID != '' }}" >> $GITHUB_OUTPUT
|
||||
echo "has-firefox=${{ secrets.AMO_API_KEY != '' }}" >> $GITHUB_OUTPUT
|
||||
echo "has-vsce=${{ secrets.VSCE_PAT != '' }}" >> $GITHUB_OUTPUT
|
||||
echo "has-npm=${{ secrets.NPM_TOKEN != '' }}" >> $GITHUB_OUTPUT
|
||||
echo "has-jetbrains=${{ secrets.JETBRAINS_TOKEN != '' }}" >> $GITHUB_OUTPUT
|
||||
run: echo "has-npm=${{ secrets.NPM_TOKEN != '' }}" >> $GITHUB_OUTPUT
|
||||
|
||||
# ─── 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 +56,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,46 +76,12 @@ 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
|
||||
run: npx web-ext lint --source-dir dist/browser-extension/firefox
|
||||
continue-on-error: true
|
||||
|
||||
- name: Publish to Chrome Web Store
|
||||
if: needs.secrets.outputs.has-chrome == 'true'
|
||||
working-directory: packages/browser
|
||||
run: |
|
||||
ACCESS_TOKEN=$(curl -s -X POST "https://oauth2.googleapis.com/token" \
|
||||
-d "client_id=${{ secrets.CHROME_CLIENT_ID }}" \
|
||||
-d "client_secret=${{ secrets.CHROME_CLIENT_SECRET }}" \
|
||||
-d "refresh_token=${{ secrets.CHROME_REFRESH_TOKEN }}" \
|
||||
-d "grant_type=refresh_token" | jq -r '.access_token')
|
||||
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
|
||||
curl -sf -X POST \
|
||||
"https://www.googleapis.com/chromewebstore/v1.1/items/${{ secrets.CHROME_EXTENSION_ID }}/publish" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "x-goog-api-version: 2" \
|
||||
-H "Content-Length: 0"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Publish to Firefox Add-ons
|
||||
if: needs.secrets.outputs.has-firefox == 'true'
|
||||
working-directory: packages/browser
|
||||
run: |
|
||||
npx web-ext sign \
|
||||
--source-dir dist/browser-extension/firefox \
|
||||
--api-key "${{ secrets.AMO_API_KEY }}" \
|
||||
--api-secret "${{ secrets.AMO_API_SECRET }}" \
|
||||
--channel listed \
|
||||
--id "hanzo-ai@hanzo.ai"
|
||||
continue-on-error: true
|
||||
|
||||
# ─── Safari (macOS + iOS) ───
|
||||
safari:
|
||||
name: Safari (macOS + iOS)
|
||||
@@ -149,10 +118,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
|
||||
@@ -198,22 +192,10 @@ jobs:
|
||||
name: hanzo-ai-ide
|
||||
path: packages/vscode/release-assets/*
|
||||
|
||||
- name: Publish to VS Code Marketplace
|
||||
if: needs.secrets.outputs.has-vsce == 'true'
|
||||
working-directory: packages/vscode
|
||||
run: vsce publish -p ${{ secrets.VSCE_PAT }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Publish to Open VSX
|
||||
if: needs.secrets.outputs.has-vsce == 'true'
|
||||
working-directory: packages/vscode
|
||||
run: ovsx publish *.vsix -p ${{ secrets.OVSX_PAT }}
|
||||
continue-on-error: true
|
||||
|
||||
# ─── npm (@hanzo/mcp) ───
|
||||
# ─── npm (all @hanzo/* workspace packages) ───
|
||||
npm:
|
||||
name: npm
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
if: needs.secrets.outputs.has-npm == 'true'
|
||||
steps:
|
||||
@@ -225,20 +207,43 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @hanzo/mcp run build
|
||||
- name: Publish @hanzo/mcp
|
||||
working-directory: packages/mcp
|
||||
run: npm publish --access public
|
||||
|
||||
# Shared libraries first: their consumers (slack, teams) import the built
|
||||
# dist at publish time, so @hanzo/cards must exist before the rest build.
|
||||
- name: Build shared libraries
|
||||
run: pnpm --filter @hanzo/cards build
|
||||
|
||||
# Build everything with a build script. Guarded with `|| true` so a package
|
||||
# whose toolchain is absent on the runner (jupyter→jupyterlab, desktop→cargo)
|
||||
# cannot abort the publish of the rest.
|
||||
- name: Build all packages
|
||||
run: pnpm -r --if-present run build || true
|
||||
|
||||
# Recursive publish: skips private (root, apps/site), skips versions already
|
||||
# on npm (e.g. @hanzo/mcp), rewrites workspace:* → the real version, and
|
||||
# publishes in topological order so @hanzo/cards lands before its consumers.
|
||||
# --ignore-scripts is required: raycast (hanzo-ai) and vscode (hanzo-ide)
|
||||
# define a `publish` lifecycle script targeting their own stores (Raycast /
|
||||
# VS Marketplace); without this flag those scripts run mid-publish, fail, and
|
||||
# abort every package after them. Builds already ran above, so no publish-time
|
||||
# script is needed. `|| true` keeps one bad package from blocking the rest.
|
||||
- name: Publish all
|
||||
run: |
|
||||
# setup-node writes a ${NODE_AUTH_TOKEN}-templated .npmrc that `npm` expands
|
||||
# but `pnpm publish` does not reliably read — write the token literally so
|
||||
# pnpm authenticates (a 404 on PUT is npm's obscured "unauthenticated").
|
||||
echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" >> .npmrc
|
||||
echo "@hanzo:registry=https://registry.npmjs.org/" >> .npmrc
|
||||
npm whoami || echo "::warning::npm whoami failed — NPM_TOKEN invalid or lacks @hanzo publish rights"
|
||||
pnpm -r publish --access public --no-git-checks --ignore-scripts --report-summary || true
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
# ─── 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 +251,343 @@ 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 publishPlugin
|
||||
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
|
||||
|
||||
# ─── 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:
|
||||
PUBLISH_TOKEN: ${{ secrets.JETBRAINS_TOKEN }}
|
||||
# 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 +616,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 +637,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/*
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Mirror GitHub → Hanzo Git (git.hanzo.ai) — the ONLY thing GitHub does as a
|
||||
# system of record. Hanzo Git is the source forge; its act_runners fire the
|
||||
# native pipeline (.gitea/workflows/*). GitHub is a public mirror.
|
||||
#
|
||||
# Credential: HANZO_GIT_TOKEN — a low-privilege Hanzo-Git push token, the ONE
|
||||
# secret GitHub still holds. It is NOT a store/publish credential (those live in
|
||||
# KMS only). Absent the token this no-ops with a warning, so it never blocks a
|
||||
# push before the operator seeds it. Alternatively the operator enables Gitea's
|
||||
# native pull-mirror server-side and this workflow becomes unnecessary.
|
||||
name: sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Push to git.hanzo.ai
|
||||
env:
|
||||
HANZO_GIT_TOKEN: ${{ secrets.HANZO_GIT_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$HANZO_GIT_TOKEN" ]; then
|
||||
echo "::warning::HANZO_GIT_TOKEN unset — mirror skipped. Seed it (or enable Gitea pull-mirror) to activate the native forge."
|
||||
exit 0
|
||||
fi
|
||||
git push --prune "https://x-access-token:${HANZO_GIT_TOKEN}@git.hanzo.ai/hanzoai/extension.git" \
|
||||
"+refs/remotes/origin/main:refs/heads/main" "+refs/tags/*:refs/tags/*"
|
||||
@@ -65,3 +65,4 @@ build/
|
||||
.next/
|
||||
*.log
|
||||
tmp/
|
||||
.npmrc
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Native CI — Hanzo Git act_runners (label hanzo-build-linux-amd64).
|
||||
# amd64 test + build gate; mirrors the amd64 lanes of .github/workflows/ci.yml.
|
||||
# The macOS (Safari) and cross-platform Playwright matrix stay on GitHub-hosted
|
||||
# runners until native macOS/Windows act_runners are green
|
||||
# (runners-act-migration.md Phase 2). act_runner reads this unmodified;
|
||||
# GitHub.com ignores .hanzo/workflows, so the GitHub release lanes are untouched.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
container: node:22-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: corepack enable
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# Shared workspace lib first — consumers import its built dist/.
|
||||
- run: pnpm --filter @hanzo/cards build
|
||||
- run: pnpm --filter @hanzo/browser-extension test
|
||||
- run: pnpm --filter @hanzo/aci test
|
||||
- run: pnpm --filter @hanzo/auth test
|
||||
|
||||
build:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
container: node:22-bookworm
|
||||
needs: [test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: corepack enable
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build browser extension
|
||||
working-directory: packages/browser
|
||||
run: node src/build.js
|
||||
|
||||
- name: Check bundle budgets
|
||||
working-directory: packages/browser
|
||||
run: |
|
||||
[ -f dist/browser-extension/background.js ] || node src/build.js
|
||||
pnpm run check:bundle-budget
|
||||
continue-on-error: true
|
||||
@@ -0,0 +1,102 @@
|
||||
# Native store-publish — Hanzo Git act_runner (label hanzo-build-linux-amd64).
|
||||
# This is the SYSTEM OF RECORD for publishing the browser extension to the
|
||||
# Chrome Web Store + Firefox AMO, and for the npm package. GitHub only mirrors.
|
||||
#
|
||||
# Secrets model — KMS ONLY. The single bootstrap credential is the KMS machine
|
||||
# identity (KMS_CLIENT_ID / KMS_CLIENT_SECRET, a git.hanzo.ai Actions secret).
|
||||
# Every store credential is pulled from KMS at publish time
|
||||
# (org `hanzo`, env `prod`, path `/extension-publish` — see
|
||||
# deploy/kms/extension-publish-kms-sync.yaml). No CWS/AMO/npm secret is ever
|
||||
# stored in GitHub or Gitea Actions secrets.
|
||||
#
|
||||
# amd64/Linux only. Safari (macOS) + Windows desktop stay on GitHub-hosted
|
||||
# runners until native macOS/Windows act_runners are green
|
||||
# (runners-act-migration.md Phase 2).
|
||||
name: publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
container: node:22-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Tools (jq, pnpm)
|
||||
run: |
|
||||
apt-get update && apt-get install -y --no-install-recommends jq curl
|
||||
corepack enable
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# ── The ONE secret hop: KMS machine identity → store creds ──
|
||||
# Login once, read each key from hanzo:/extension-publish, mask + export.
|
||||
# A missing key exports empty; each publish step below gates on its creds,
|
||||
# so partial provisioning publishes exactly what KMS has.
|
||||
- name: Load publish secrets from KMS
|
||||
env:
|
||||
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
|
||||
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
|
||||
run: |
|
||||
if [ -z "$KMS_CLIENT_ID" ] || [ -z "$KMS_CLIENT_SECRET" ]; then
|
||||
echo "::warning::KMS machine identity absent — cannot pull publish creds. Seed KMS_CLIENT_ID/SECRET as git.hanzo.ai Actions secrets."
|
||||
exit 0
|
||||
fi
|
||||
KMS=https://kms.hanzo.ai; ORG=hanzo; ENVN=prod; P=extension-publish
|
||||
TOKEN=$(curl -sf "$KMS/v1/kms/auth/login" -H 'Content-Type: application/json' \
|
||||
-d "{\"clientId\":\"$KMS_CLIENT_ID\",\"clientSecret\":\"$KMS_CLIENT_SECRET\"}" | jq -r .accessToken)
|
||||
[ -n "$TOKEN" ] && [ "$TOKEN" != null ] || { echo "::error::KMS login failed"; exit 1; }
|
||||
for KEY in CHROME_EXTENSION_ID CHROME_CLIENT_ID CHROME_CLIENT_SECRET CHROME_REFRESH_TOKEN AMO_API_KEY AMO_API_SECRET NPM_TOKEN; do
|
||||
VAL=$(curl -sf "$KMS/v1/kms/orgs/$ORG/secrets/$P/$KEY?env=$ENVN" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq -r '.secret.value // empty')
|
||||
if [ -n "$VAL" ]; then echo "::add-mask::$VAL"; fi
|
||||
echo "$KEY=$VAL" >> "$GITHUB_ENV"
|
||||
done
|
||||
|
||||
- name: Build browser extension
|
||||
working-directory: packages/browser
|
||||
run: node src/build.js
|
||||
|
||||
- name: Package (web-ext — no zip binary on the runner)
|
||||
working-directory: packages/browser
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
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
|
||||
|
||||
- name: Publish to Chrome Web Store
|
||||
if: env.CHROME_EXTENSION_ID != ''
|
||||
working-directory: packages/browser
|
||||
run: |
|
||||
ACCESS_TOKEN=$(curl -s -X POST "https://oauth2.googleapis.com/token" \
|
||||
-d "client_id=$CHROME_CLIENT_ID" -d "client_secret=$CHROME_CLIENT_SECRET" \
|
||||
-d "refresh_token=$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/$CHROME_EXTENSION_ID" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" -H "x-goog-api-version: 2" \
|
||||
-T dist/hanzo-ai-chrome-v${VERSION}.zip
|
||||
curl -sf -X POST "https://www.googleapis.com/chromewebstore/v1.1/items/$CHROME_EXTENSION_ID/publish" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" -H "x-goog-api-version: 2" -H "Content-Length: 0"
|
||||
|
||||
- name: Publish to Firefox Add-ons
|
||||
if: env.AMO_API_KEY != ''
|
||||
working-directory: packages/browser
|
||||
run: |
|
||||
npx web-ext sign --source-dir dist/browser-extension/firefox \
|
||||
--api-key "$AMO_API_KEY" --api-secret "$AMO_API_SECRET" \
|
||||
--channel listed --id "hanzo-ai@hanzo.ai"
|
||||
|
||||
# npm — canonical home is here. The .github npm job stays live until this
|
||||
# runs green (KMS NPM_TOKEN seeded), then it is deleted. pnpm publish skips
|
||||
# versions already on npm, so any transition overlap is idempotent.
|
||||
- name: Publish @hanzo/* to npm
|
||||
if: env.NPM_TOKEN != ''
|
||||
run: |
|
||||
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
|
||||
echo "@hanzo:registry=https://registry.npmjs.org/" >> .npmrc
|
||||
pnpm --filter @hanzo/cards build
|
||||
pnpm -r --if-present run build || true
|
||||
pnpm -r publish --access public --no-git-checks --ignore-scripts --report-summary || true
|
||||
@@ -1,54 +1,65 @@
|
||||
# LLM.md - Hanzo Extension
|
||||
# Hanzo Extension
|
||||
|
||||
## Overview
|
||||
Hanzo AI Development Platform Monorepo. The browser extension lives in
|
||||
`packages/browser` and ships to Chrome / Firefox / Safari.
|
||||
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
|
||||
- **Build**: esbuild via `packages/browser/src/build.js`
|
||||
- **Tests**: vitest
|
||||
## Architecture (native ZAP)
|
||||
|
||||
## Build & Run
|
||||
```bash
|
||||
pnpm install && pnpm build
|
||||
pnpm test
|
||||
```
|
||||
extension ─connectNative("ai.hanzo.zap")─► native host ─UDS─► zapd ◄─ hanzo-mcp
|
||||
|
||||
## Architecture (browser-extension 1.9.x — native ZAP)
|
||||
|
||||
The extension connects to the **one shared local `zapd` router** (see
|
||||
`~/work/zap`) through exactly one browser-native primitive — there is **no**
|
||||
WebSocket, no localhost port, no mDNS probing, no CDP bridge:
|
||||
|
||||
```
|
||||
extension ─connectNative("ai.hanzo.zap")─► native host ─UDS─► zapd ◄─ hanzo-mcp
|
||||
```
|
||||
|
||||
- **shared/native-zap.ts** — the ONE transport: `connectNativeZap()` opens the
|
||||
native-messaging port, registers as a provider (`browser:<chrome|firefox>/default`,
|
||||
the host stamps the device hostname), and dispatches inbound ROUTE commands.
|
||||
- `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) — `connectNativeZap(..., cdpBridge.dispatchMethod)`.
|
||||
Dispatch still uses `chrome.debugger` (CDP-to-tab) → Chrome's "debugging this
|
||||
browser" banner. **TODO:** native `browser.*` dispatch for banner-light parity.
|
||||
- **background-firefox.ts** — `connectNativeZap(..., hanzoExtension.dispatchMethod)`.
|
||||
Already native `browser.*` (no banner). Same transport as Chrome.
|
||||
- **browser-dispatch.ts** (was `cdp-bridge.ts`) — `chrome.debugger` actuation
|
||||
(`dispatchMethod`). The legacy `cdp-bridge-server.ts` WS bridge is **deleted**.
|
||||
- **shared/zap.ts** — legacy WebSocket ZAP wire; retained only for unrelated
|
||||
helpers (RAG/event broadcast no-op when disconnected). NOT the transport.
|
||||
- `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.
|
||||
|
||||
Native-host manifests: Chrome `…/Google/Chrome/NativeMessagingHosts/ai.hanzo.zap.json`
|
||||
(`allowed_origins`), Firefox `…/Mozilla/NativeMessagingHosts/ai.hanzo.zap.json`
|
||||
(`allowed_extensions: ["hanzo-ai@hanzo.ai"]`). Both `path` → `~/work/zap/host/zap_host.sh`.
|
||||
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`).
|
||||
|
||||
The wire to zapd is the **binary ZAP router envelope** (`len|type|flags|from|to|payload`,
|
||||
defined in `zap-proto/zapd/src/frame.rs`); the opaque command payload is a compact
|
||||
binary codec. The browser↔host hop is native-messaging JSON (Chrome-forced), base64'd,
|
||||
quarantined in the host.
|
||||
## 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).
|
||||
|
||||
## Versioning rule
|
||||
Always bump **patch** (X.Y.Z → X.Y.Z+1). Never bump major — the ZAP
|
||||
transport pivot ships as ordinary patches, not a 2.0. `package.json` and
|
||||
both `manifest*.json` must agree.
|
||||
## CI/CD (native — Hanzo Git, KMS, act_runner)
|
||||
Git of record is `git.hanzo.ai/hanzoai/extension`; GitHub is a mirror.
|
||||
- `.gitea/workflows/*` — native pipeline on `hanzo-build-linux-amd64` act_runners:
|
||||
`ci.yml` (amd64 test + build), `publish.yml` (Chrome Web Store + Firefox AMO + npm).
|
||||
- Secrets are **KMS only**. The pipeline logs in with the KMS machine identity
|
||||
(`KMS_CLIENT_ID`/`KMS_CLIENT_SECRET`) and pulls store creds from `hanzo:/extension-publish`
|
||||
(env `prod`). CR: `deploy/kms/extension-publish-kms-sync.yaml`. No store cred in
|
||||
any Actions secret store.
|
||||
- `.github/workflows/` mirrors + degrades gracefully: `sync.yml` pushes main+tags to
|
||||
Hanzo Git; `ci.yml`/`cross-platform-e2e.yml` keep the macOS(Safari)/Windows/matrix
|
||||
lanes that still need GitHub-hosted runners; `publish.yml` keeps only the GitHub
|
||||
Release + the interim npm publish (store-publish removed → native). See PUBLISHING.md.
|
||||
|
||||
## 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.
|
||||
|
||||
+32
-17
@@ -1,33 +1,48 @@
|
||||
# Publishing Hanzo Extensions
|
||||
|
||||
This document describes how to publish Hanzo extensions to various marketplaces.
|
||||
Publishing is NATIVE: it runs on Hanzo Git (`git.hanzo.ai`) act_runners, and
|
||||
every store credential lives in Hanzo KMS. GitHub only mirrors the repo.
|
||||
|
||||
## Required Secrets
|
||||
## Topology (one way)
|
||||
|
||||
Add these secrets to your GitHub repository settings:
|
||||
- **Git of record:** `git.hanzo.ai/hanzoai/extension`. GitHub is a public
|
||||
mirror; `.github/workflows/sync.yml` pushes `main` + tags to Hanzo Git.
|
||||
- **CI/CD:** `.gitea/workflows/*` on `hanzo-build-linux-amd64` act_runners —
|
||||
`ci.yml` (test + build) and `publish.yml` (Chrome Web Store + Firefox AMO + npm).
|
||||
- **Secrets — KMS ONLY.** The native pipeline logs in with the KMS machine
|
||||
identity (`KMS_CLIENT_ID` / `KMS_CLIENT_SECRET`) and pulls store creds from
|
||||
org `hanzo`, env `prod`, path `/extension-publish`
|
||||
(see `deploy/kms/extension-publish-kms-sync.yaml`). No store credential is
|
||||
ever a GitHub or Gitea Actions secret.
|
||||
- **amd64/Linux publishes natively.** Safari (macOS) + desktop (Windows) stay on
|
||||
GitHub-hosted runners until native macOS/Windows act_runners are green
|
||||
(`universe/docs/architecture/runners-act-migration.md`).
|
||||
|
||||
| Secret | Description | How to Get |
|
||||
|--------|-------------|------------|
|
||||
| `VSCE_PAT` | VS Code Marketplace token | [Create PAT](https://code.visualstudio.com/api/working-with-extensions/publishing-extension#get-a-personal-access-token) |
|
||||
| `OVSX_PAT` | Open VSX Registry token | [Create token](https://open-vsx.org/user-settings/tokens) |
|
||||
| `NPM_TOKEN` | npm publish token | `npm token create` or [npm.js tokens](https://www.npmjs.com/settings/~/tokens) |
|
||||
| `JETBRAINS_TOKEN` | JetBrains Marketplace token | [Generate token](https://plugins.jetbrains.com/author/me/tokens) |
|
||||
| `CERTIFICATE_CHAIN` | JetBrains plugin signing cert | [Plugin signing](https://plugins.jetbrains.com/docs/intellij/plugin-signing.html) |
|
||||
| `PRIVATE_KEY` | JetBrains plugin signing key | See above |
|
||||
| `PRIVATE_KEY_PASSWORD` | JetBrains key password | See above |
|
||||
## KMS keys the operator seeds (path `hanzo:/extension-publish`, env `prod`)
|
||||
|
||||
| Key(s) | Store |
|
||||
|--------|-------|
|
||||
| `CHROME_EXTENSION_ID`, `CHROME_CLIENT_ID`, `CHROME_CLIENT_SECRET`, `CHROME_REFRESH_TOKEN` | Chrome Web Store |
|
||||
| `AMO_API_KEY`, `AMO_API_SECRET` | Firefox Add-ons |
|
||||
| `NPM_TOKEN` | npm (`@hanzo/*`) |
|
||||
|
||||
## Publishing Methods
|
||||
|
||||
### Automatic (GitHub Actions)
|
||||
### Automatic (native)
|
||||
|
||||
1. Create a GitHub release with a version tag (e.g., `v1.6.0`)
|
||||
2. The `publish.yml` workflow will automatically publish to all marketplaces
|
||||
Push a `vX.Y.Z` tag. GitHub mirrors it to Hanzo Git, which fires
|
||||
`.gitea/workflows/publish.yml`. Or run `workflow_dispatch` on Hanzo Git.
|
||||
|
||||
Or manually trigger:
|
||||
```bash
|
||||
gh workflow run publish.yml
|
||||
# version lives in packages/browser/package.json (patch only, never major)
|
||||
git tag v1.9.35 && git push origin v1.9.35
|
||||
```
|
||||
|
||||
> Interim: the live npm publish still also runs from `.github/workflows/publish.yml`
|
||||
> until the KMS `NPM_TOKEN` is seeded and one native `publish.yml` run is verified
|
||||
> green; then the GitHub npm job is deleted. `pnpm publish` skips versions already
|
||||
> on npm, so the overlap is idempotent.
|
||||
|
||||
### Manual Publishing
|
||||
|
||||
#### VS Code Marketplace
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Store-publish credentials for the Hanzo browser extension — KMS is the ONLY home.
|
||||
#
|
||||
# The native publish pipeline (.gitea/workflows/publish.yml, act_runner on
|
||||
# git.hanzo.ai) reads these at publish time. No store credential lives in a
|
||||
# GitHub or Gitea Actions secret — the ONLY bootstrap credential is the KMS
|
||||
# machine identity (KMS_CLIENT_ID / KMS_CLIENT_SECRET), already provisioned.
|
||||
#
|
||||
# ── Operator steps (the VALUES are the store-account owner's; not in git) ──
|
||||
# 1. KMS (kms.hanzo.ai) → org `hanzo`, env `prod`, project path `/extension-publish`.
|
||||
# Add the keys below. These are the Chrome Web Store + Firefox AMO
|
||||
# credentials — the store-account owner drops the real values here:
|
||||
# CHROME_EXTENSION_ID Chrome Web Store item id
|
||||
# CHROME_CLIENT_ID Google OAuth client id (CWS API)
|
||||
# CHROME_CLIENT_SECRET Google OAuth client secret
|
||||
# CHROME_REFRESH_TOKEN Google OAuth refresh token
|
||||
# AMO_API_KEY addons.mozilla.org JWT issuer
|
||||
# AMO_API_SECRET addons.mozilla.org JWT secret
|
||||
# Optional (same path, if these stores are moved off GitHub too):
|
||||
# VSCE_PAT OVSX_PAT JETBRAINS_TOKEN NPM_TOKEN
|
||||
# kms secret set --project hanzo --env prod --path /extension-publish \
|
||||
# CHROME_EXTENSION_ID "<value>" # …repeat per key
|
||||
# 2. Grant the extension repo's KMS machine identity (the KMS_CLIENT_ID it
|
||||
# already uses) READ on project `hanzo`, path `/extension-publish`.
|
||||
# 3. Re-provision KMS_CLIENT_ID / KMS_CLIENT_SECRET as git.hanzo.ai Actions
|
||||
# secrets so the native runner can log in (they exist on GitHub today —
|
||||
# runners-act-migration.md Phase 1.2).
|
||||
#
|
||||
# Canonical GitOps home is universe/infra/k8s/kms-canonical-crs/hanzo/ (generated
|
||||
# by scripts/kms-canonical-v1alpha1-gen.py). This in-repo copy is the extension's
|
||||
# declared secret contract — reflect it into universe, do not diverge.
|
||||
---
|
||||
apiVersion: secrets.lux.network/v1alpha1
|
||||
kind: KMSSecret
|
||||
metadata:
|
||||
name: extension-publish-kms-sync
|
||||
namespace: hanzo
|
||||
labels:
|
||||
app.kubernetes.io/name: extension
|
||||
app.kubernetes.io/component: kms-secret
|
||||
app.kubernetes.io/part-of: hanzo-universe
|
||||
kms.hanzo.ai/org: hanzo
|
||||
kms.hanzo.ai/env: prod
|
||||
spec:
|
||||
hostAPI: http://kms.hanzo.svc
|
||||
resyncInterval: 60
|
||||
authentication:
|
||||
universalAuth:
|
||||
# Shared platform machine identity (same as the other hanzo CRs). The
|
||||
# operator scopes it to read /extension-publish.
|
||||
credentialsRef:
|
||||
secretName: hanzo-platform-iam-creds
|
||||
secretNamespace: hanzo
|
||||
secretsScope:
|
||||
projectSlug: hanzo
|
||||
envSlug: prod
|
||||
secretsPath: /extension-publish
|
||||
keys:
|
||||
- CHROME_EXTENSION_ID
|
||||
- CHROME_CLIENT_ID
|
||||
- CHROME_CLIENT_SECRET
|
||||
- CHROME_REFRESH_TOKEN
|
||||
- AMO_API_KEY
|
||||
- AMO_API_SECRET
|
||||
managedSecretReference:
|
||||
secretName: extension-publish-secrets
|
||||
secretNamespace: hanzo
|
||||
secretType: Opaque
|
||||
creationPolicy: Orphan
|
||||
+4
-4
@@ -53,9 +53,9 @@ The Hanzo agent automatically detects and uses available LLM providers in this o
|
||||
- Access to all models with credits
|
||||
- Managed API keys in Hanzo Cloud
|
||||
|
||||
## Complete Model Catalog (OpenRouter + LiteLLM Compatible)
|
||||
## Complete Model Catalog
|
||||
|
||||
Hanzo AI provides access to every model available through OpenRouter and LiteLLM, including:
|
||||
Hanzo AI provides access to every major model through one API, including:
|
||||
|
||||
- **OpenAI**: O3-Pro, O3, GPT-4o, GPT-4 Turbo, GPT-4, GPT-3.5 Turbo
|
||||
- **Anthropic**: Claude 4, Claude 3.5 Sonnet, Claude 3 Opus, Claude 3 Sonnet, Claude 3 Haiku
|
||||
@@ -159,7 +159,7 @@ export OLLAMA_BASE_URL=http://localhost:11434
|
||||
## Why AI Engineers Choose Hanzo AI
|
||||
|
||||
### 🎯 **The Complete Toolkit**
|
||||
- **Every Model**: OpenRouter + LiteLLM = access to every AI model
|
||||
- **Every Model**: one API across every major provider and our own Zen family
|
||||
- **Every Tool**: 4000+ MCP servers for specialized capabilities
|
||||
- **Every Provider**: OpenAI, Anthropic, Google, Meta, Mistral, and 50+ more
|
||||
|
||||
@@ -300,6 +300,6 @@ Error: Model xyz not available through Hanzo AI
|
||||
- **Dashboard**: [iam.hanzo.ai](https://iam.hanzo.ai)
|
||||
- **Support**: support@hanzo.ai
|
||||
|
||||
**Hanzo AI is the unified platform for building AI-powered companies.** Access 200+ LLMs (OpenRouter/LiteLLM compatible), 4000+ MCP servers, legendary programmer modes, unlimited memory with vector/graph/relational search, browser automation, and everything you need to supercharge AI development.
|
||||
**Hanzo AI is the unified platform for building AI-powered companies.** Access 200+ models through one API, 4000+ MCP servers, legendary programmer modes, unlimited memory with vector/graph/relational search, browser automation, and everything you need to supercharge AI development.
|
||||
|
||||
🚀 **[Get Started](https://iam.hanzo.ai)** | 📖 **[View Modes & Features](./HANZO_MODES.md)** | 💬 **[Join Discord](https://discord.gg/hanzoai)**
|
||||
+85
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/extension",
|
||||
"version": "1.9.20",
|
||||
"version": "1.9.36",
|
||||
"private": true,
|
||||
"description": "Hanzo AI Extension",
|
||||
"license": "MIT",
|
||||
@@ -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"
|
||||
@@ -47,5 +47,12 @@
|
||||
"homepage": "https://hanzo.ai",
|
||||
"bugs": {
|
||||
"url": "https://github.com/hanzoai/aci/issues"
|
||||
}
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,26 @@
|
||||
{
|
||||
"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",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# Permanent local-dev install of the Hanzo browser extension on Linux — NO snap,
|
||||
# NO web store, NO signing service. Builds from source and installs into:
|
||||
#
|
||||
# • Firefox ESR (/opt/firefox-esr) via an enterprise policy that
|
||||
# force-installs the unsigned XPI into EVERY profile (signatures disabled).
|
||||
# Truly permanent + launch-independent: any launch of that Firefox has it.
|
||||
#
|
||||
# • Brave (Chromium-family; arm64 has no Google-Chrome build) via a
|
||||
# --load-extension launcher (/usr/local/bin/hanzo-brave + a desktop entry).
|
||||
# The extension loads from the live dist dir, so a rebuild + relaunch picks
|
||||
# up new code — the correct dev loop (no re-pack per change).
|
||||
#
|
||||
# Why these two: on this box (aarch64) apt ships ONLY snap redirects for
|
||||
# firefox/chromium, and Google Chrome has no Linux arm64 build. Firefox ESR and
|
||||
# Brave are the reliable non-snap arm64 browsers. On x86_64 the same script
|
||||
# works; swap Brave for google-chrome if preferred.
|
||||
#
|
||||
# Re-run after any `node src/build.js` to redeploy. Idempotent.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
EXT_ROOT="$(cd "$REPO_DIR/../.." && pwd)" # ~/work/hanzo/extension
|
||||
DIST="$EXT_ROOT/dist"
|
||||
STABLE=/opt/hanzo-ext # stable install location
|
||||
FF=/opt/firefox-esr
|
||||
|
||||
echo "==> building extension from source"
|
||||
( cd "$REPO_DIR" && node src/build.js )
|
||||
[ -d "$DIST/firefox" ] && [ -d "$DIST/chrome" ] || { echo "build did not produce dist/{firefox,chrome}"; exit 1; }
|
||||
|
||||
echo "==> packaging Firefox XPI"
|
||||
XPI=/tmp/hanzo-ai.xpi
|
||||
( cd "$DIST/firefox" && rm -f "$XPI" && zip -qr -X "$XPI" . )
|
||||
GECKO_ID=$(python3 -c 'import json;m=json.load(open("'"$DIST"'/firefox/manifest.json"));print((m.get("browser_specific_settings") or m.get("applications") or {}).get("gecko",{}).get("id",""))')
|
||||
[ -n "$GECKO_ID" ] || { echo "firefox manifest has no gecko id"; exit 1; }
|
||||
|
||||
echo "==> staging stable copies under $STABLE (needs sudo)"
|
||||
sudo mkdir -p "$STABLE"
|
||||
sudo cp "$XPI" "$STABLE/hanzo-ai.xpi"
|
||||
sudo rm -rf "$STABLE/chrome" && sudo cp -r "$DIST/chrome" "$STABLE/chrome"
|
||||
|
||||
if [ -x "$FF/firefox" ]; then
|
||||
echo "==> Firefox ESR enterprise policy (force-install + signatures off)"
|
||||
sudo mkdir -p "$FF/distribution"
|
||||
sudo tee "$FF/distribution/policies.json" >/dev/null <<JSON
|
||||
{
|
||||
"policies": {
|
||||
"ExtensionSettings": {
|
||||
"$GECKO_ID": { "installation_mode": "force_installed", "install_url": "file://$STABLE/hanzo-ai.xpi" }
|
||||
},
|
||||
"Preferences": { "xpinstall.signatures.required": { "Value": false, "Status": "locked" } },
|
||||
"DisableAppUpdate": true
|
||||
}
|
||||
}
|
||||
JSON
|
||||
else
|
||||
echo "!! Firefox ESR not at $FF — install it (Mozilla arm64 ESR tarball) then re-run"
|
||||
fi
|
||||
|
||||
if command -v brave-browser >/dev/null 2>&1; then
|
||||
echo "==> Brave --load-extension launcher + desktop entry"
|
||||
sudo tee /usr/local/bin/hanzo-brave >/dev/null <<'SH'
|
||||
#!/bin/bash
|
||||
exec /usr/bin/brave-browser --load-extension=/opt/hanzo-ext/chrome "$@"
|
||||
SH
|
||||
sudo chmod +x /usr/local/bin/hanzo-brave
|
||||
sudo tee /usr/share/applications/brave-hanzo-dev.desktop >/dev/null <<'DESK'
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=Brave (Hanzo Dev)
|
||||
Comment=Brave with the local Hanzo browser extension
|
||||
Exec=/usr/local/bin/hanzo-brave %U
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=brave-browser
|
||||
Categories=Network;WebBrowser;
|
||||
DESK
|
||||
else
|
||||
echo "!! brave-browser not found — install it, then re-run"
|
||||
fi
|
||||
|
||||
CHROME_ID=$(python3 - <<PY
|
||||
import json,base64,hashlib
|
||||
m=json.load(open("$DIST/chrome/manifest.json")); k=m.get("key")
|
||||
if k:
|
||||
h=hashlib.sha256(base64.b64decode(k)).hexdigest()[:32]
|
||||
print(''.join(chr(ord('a')+int(c,16)) for c in h))
|
||||
PY
|
||||
)
|
||||
echo
|
||||
echo "DONE."
|
||||
echo " Firefox: launch $FF/firefox (extension $GECKO_ID force-installed, every profile)"
|
||||
echo " Brave: launch hanzo-brave (extension ${CHROME_ID:-<key-derived>} via --load-extension)"
|
||||
echo " Rebuild loop: node src/build.js && ./install-linux.sh (then relaunch the browser)"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.9.21",
|
||||
"version": "1.9.36",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
@@ -12,6 +12,8 @@
|
||||
"check:bundle-budget": "node scripts/check-bundle-budgets.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/ai": "^0.2.3",
|
||||
"@hanzo/usage": "^0.1.6",
|
||||
"@supabase/supabase-js": "^2.50.3",
|
||||
"axios": "^1.10.0",
|
||||
"chalk": "^4.1.2",
|
||||
@@ -30,11 +32,20 @@
|
||||
"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": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"images",
|
||||
"manifest.json"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createAiClient, type SearchEvent, type SearchMode, type SearchSource } from '@hanzo/ai';
|
||||
import { fetchNews, type NewsItem } from './news.js';
|
||||
import { AUTO_MODEL, fetchModelCatalog, type ChatModel, type ModelGroup } from './model-catalog.js';
|
||||
import { renderMarkdown } from './markdown.js';
|
||||
import { capture, contributeTrainingSample } from '../shared/consent.js';
|
||||
|
||||
/**
|
||||
* Hanzo Answer Engine — the shared AI-answer surface (query → streamed answer +
|
||||
* inline citations + sources + live news). Backend = api.hanzo.ai ONLY, reached
|
||||
* through the @hanzo/ai `search`/`deepResearch` primitive (the one place the
|
||||
* capability is defined). Rendered on the @hanzo/gui primitive surface; strict
|
||||
* monochrome per the Hanzo brand.
|
||||
*/
|
||||
|
||||
export interface AnswerEngineProps {
|
||||
apiBase: string;
|
||||
/** Resolves the current IAM bearer, or null when signed out. */
|
||||
getToken: () => Promise<string | null>;
|
||||
/** Kicks off IAM sign-in (opens the OAuth tab). */
|
||||
signIn: () => void;
|
||||
/** Optional initial query (from the omnibox / address bar `?q=`). */
|
||||
initialQuery?: string;
|
||||
}
|
||||
|
||||
const MODES: { id: SearchMode; label: string; hint: string }[] = [
|
||||
{ id: 'search', label: 'Search', hint: 'Fast grounded answer' },
|
||||
{ id: 'news', label: 'News', hint: 'Recency-biased' },
|
||||
{ id: 'research', label: 'Research', hint: 'Multi-source synthesis' },
|
||||
{ id: 'deep', label: 'Deep', hint: 'Plan + wide research' },
|
||||
];
|
||||
|
||||
const SOURCE_CHIPS = ['web', 'news', 'academic', 'github', 'reddit', 'x'];
|
||||
|
||||
// Hanzo tiers mirror @hanzo/plans (the SoT for pricing); shown in the picker's
|
||||
// upgrade banner. Full plan detail lives at console.hanzo.ai/pricing.
|
||||
const PLAN_TIERS = [
|
||||
{ name: 'Pro', price: '$20' },
|
||||
{ name: 'Max', price: '$100' },
|
||||
{ name: 'Ultra', price: '$200' },
|
||||
];
|
||||
|
||||
function HanzoMark({ size = 28 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 67 67" width={size} height={size} xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="currentColor" />
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="currentColor" />
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="currentColor" />
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="currentColor" />
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelPicker({
|
||||
groups,
|
||||
current,
|
||||
onPick,
|
||||
}: {
|
||||
groups: ModelGroup[];
|
||||
current: ChatModel;
|
||||
onPick: (m: ChatModel) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [open]);
|
||||
|
||||
const q = filter.trim().toLowerCase();
|
||||
const shown = useMemo(
|
||||
() =>
|
||||
groups
|
||||
.map((g) => ({
|
||||
...g,
|
||||
models: g.models.filter(
|
||||
(m) => !q || m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q),
|
||||
),
|
||||
}))
|
||||
.filter((g) => g.models.length),
|
||||
[groups, q],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ae-picker" ref={ref}>
|
||||
<button className="ae-picker-btn" onClick={() => setOpen((v) => !v)} title="Choose model">
|
||||
<span className="ae-dot" />
|
||||
{current.name}
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
|
||||
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ae-picker-menu">
|
||||
<input
|
||||
className="ae-picker-filter"
|
||||
placeholder="Search models…"
|
||||
value={filter}
|
||||
autoFocus
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<div className="ae-picker-list">
|
||||
<button
|
||||
className={'ae-model-row' + (current.id === AUTO_MODEL.id ? ' active' : '')}
|
||||
onClick={() => {
|
||||
onPick(AUTO_MODEL);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="ae-model-name">Auto</span>
|
||||
<span className="ae-model-desc">Routes to the best model</span>
|
||||
</button>
|
||||
{shown.map((g) => (
|
||||
<div key={g.family} className="ae-model-group">
|
||||
<div className="ae-model-group-label">{g.label}</div>
|
||||
{g.models.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={'ae-model-row' + (current.id === m.id ? ' active' : '')}
|
||||
onClick={() => {
|
||||
onPick(m);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="ae-model-name">
|
||||
{m.name}
|
||||
{m.tier && <span className="ae-badge">{m.tier}</span>}
|
||||
</span>
|
||||
{m.description && <span className="ae-model-desc">{m.description}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<a className="ae-plan-banner" href="https://console.hanzo.ai/pricing" target="_blank" rel="noreferrer">
|
||||
<span>Unlock every model</span>
|
||||
<span className="ae-plan-tiers">
|
||||
{PLAN_TIERS.map((t) => (
|
||||
<span key={t.name} className="ae-plan-tier">
|
||||
{t.name} <b>{t.price}</b>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceCard({ s, index }: { s: SearchSource; index: number }) {
|
||||
let host = '';
|
||||
try {
|
||||
host = new URL(s.url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
host = s.url;
|
||||
}
|
||||
return (
|
||||
<a className="ae-source" href={s.url} target="_blank" rel="noreferrer" title={s.title}>
|
||||
<span className="ae-source-head">
|
||||
{s.favicon ? <img src={s.favicon} alt="" width="14" height="14" /> : <span className="ae-dot" />}
|
||||
<span className="ae-source-host">{host}</span>
|
||||
<span className="ae-source-idx">{index + 1}</span>
|
||||
</span>
|
||||
<span className="ae-source-title">{s.title}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function InputCard({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
mode,
|
||||
setMode,
|
||||
sources,
|
||||
toggleSource,
|
||||
models,
|
||||
model,
|
||||
setModel,
|
||||
busy,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onSubmit: () => void;
|
||||
mode: SearchMode;
|
||||
setMode: (m: SearchMode) => void;
|
||||
sources: Set<string>;
|
||||
toggleSource: (s: string) => void;
|
||||
models: ModelGroup[];
|
||||
model: ChatModel;
|
||||
setModel: (m: ChatModel) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
useEffect(() => {
|
||||
const ta = taRef.current;
|
||||
if (!ta) return;
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = Math.min(ta.scrollHeight, 200) + 'px';
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="ae-card">
|
||||
<textarea
|
||||
ref={taRef}
|
||||
className="ae-input"
|
||||
placeholder="Type @ for sources or / for modes"
|
||||
value={value}
|
||||
rows={1}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="ae-card-row">
|
||||
<div className="ae-chips">
|
||||
{SOURCE_CHIPS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className={'ae-chip' + (sources.has(s) ? ' on' : '')}
|
||||
onClick={() => toggleSource(s)}
|
||||
title={`@${s}`}
|
||||
>
|
||||
@{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ae-card-right">
|
||||
<div className="ae-modes">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={'ae-mode' + (mode === m.id ? ' on' : '')}
|
||||
onClick={() => setMode(m.id)}
|
||||
title={m.hint}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ModelPicker groups={models} current={model} onPick={setModel} />
|
||||
<button className="ae-send" onClick={onSubmit} disabled={busy || !value.trim()} title="Search (Enter)">
|
||||
{busy ? (
|
||||
<span className="ae-spinner" />
|
||||
) : (
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
|
||||
<path d="M3 13V3l10 5-10 5z" fill="currentColor" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnswerEngine({ apiBase, getToken, signIn, initialQuery }: AnswerEngineProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [submitted, setSubmitted] = useState('');
|
||||
const [mode, setMode] = useState<SearchMode>('search');
|
||||
const [sources, setSources] = useState<Set<string>>(new Set());
|
||||
const [modelGroups, setModelGroups] = useState<ModelGroup[]>([]);
|
||||
const [model, setModel] = useState<ChatModel>(AUTO_MODEL);
|
||||
const [news, setNews] = useState<NewsItem[]>([]);
|
||||
|
||||
const [phase, setPhase] = useState<'idle' | 'running' | 'done'>('idle');
|
||||
const [status, setStatus] = useState('');
|
||||
const [answer, setAnswer] = useState('');
|
||||
const [answerSources, setAnswerSources] = useState<SearchSource[]>([]);
|
||||
const [followUps, setFollowUps] = useState<string[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [needAuth, setNeedAuth] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Load real model catalog + live news (both public — work signed out).
|
||||
// Default stays on Auto ("routes to the best model"); the picker changes it.
|
||||
useEffect(() => {
|
||||
fetchModelCatalog(apiBase)
|
||||
.then(({ groups }) => setModelGroups(groups))
|
||||
.catch(() => {});
|
||||
fetchNews().then(setNews).catch(() => {});
|
||||
}, [apiBase]);
|
||||
|
||||
const client = useMemo(
|
||||
() =>
|
||||
createAiClient({
|
||||
baseUrl: apiBase,
|
||||
getToken: async () => {
|
||||
const t = await getToken();
|
||||
if (!t) throw new Error('__NO_TOKEN__');
|
||||
return t;
|
||||
},
|
||||
}),
|
||||
[apiBase, getToken],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async (q: string) => {
|
||||
const question = q.trim();
|
||||
if (!question) return;
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
setSubmitted(question);
|
||||
setPhase('running');
|
||||
setStatus('Searching the web');
|
||||
setAnswer('');
|
||||
setAnswerSources([]);
|
||||
setFollowUps([]);
|
||||
setError('');
|
||||
setNeedAuth(false);
|
||||
void capture('answer_search', { mode, model: model.id });
|
||||
let fullAnswer = '';
|
||||
|
||||
const opts = {
|
||||
mode,
|
||||
model: model.id,
|
||||
sources: [...sources],
|
||||
signal: ctrl.signal,
|
||||
};
|
||||
try {
|
||||
for await (const ev of client.search(question, opts) as AsyncGenerator<SearchEvent>) {
|
||||
switch (ev.type) {
|
||||
case 'status':
|
||||
setStatus(
|
||||
ev.stage === 'planning'
|
||||
? 'Planning research'
|
||||
: ev.stage === 'searching'
|
||||
? 'Searching the web' + (ev.detail ? `: ${ev.detail}` : '')
|
||||
: ev.stage === 'reading'
|
||||
? 'Reading sources'
|
||||
: 'Writing the answer',
|
||||
);
|
||||
break;
|
||||
case 'sources':
|
||||
setAnswerSources(ev.sources);
|
||||
break;
|
||||
case 'text':
|
||||
fullAnswer += ev.delta;
|
||||
setAnswer((a) => a + ev.delta);
|
||||
break;
|
||||
case 'follow_ups':
|
||||
setFollowUps(ev.questions);
|
||||
break;
|
||||
case 'done':
|
||||
setPhase('done');
|
||||
void capture('answer_shown', { mode, model: model.id });
|
||||
// Opt-in only: no-ops unless the user turned on training
|
||||
// contribution (the consent module gates the send).
|
||||
void contributeTrainingSample({ query: question, answer: fullAnswer, model: model.id });
|
||||
break;
|
||||
case 'error':
|
||||
if (ev.error.includes('__NO_TOKEN__') || /401|invalid api key/i.test(ev.error)) {
|
||||
setNeedAuth(true);
|
||||
} else {
|
||||
setError(ev.error);
|
||||
}
|
||||
setPhase('done');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes('__NO_TOKEN__') || /401|invalid api key/i.test(msg)) setNeedAuth(true);
|
||||
else setError(msg);
|
||||
setPhase('done');
|
||||
}
|
||||
},
|
||||
[client, mode, model, sources],
|
||||
);
|
||||
|
||||
// Fire the initial (address-bar) query once models are ready enough.
|
||||
const didInit = useRef(false);
|
||||
useEffect(() => {
|
||||
if (didInit.current || !initialQuery) return;
|
||||
didInit.current = true;
|
||||
setQuery(initialQuery);
|
||||
void run(initialQuery);
|
||||
}, [initialQuery, run]);
|
||||
|
||||
const submit = useCallback(() => void run(query), [query, run]);
|
||||
const toggleSource = useCallback((s: string) => {
|
||||
setSources((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(s) ? next.delete(s) : next.add(s);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const newThread = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
setPhase('idle');
|
||||
setQuery('');
|
||||
setSubmitted('');
|
||||
setAnswer('');
|
||||
setAnswerSources([]);
|
||||
setFollowUps([]);
|
||||
setError('');
|
||||
setNeedAuth(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={'ae-root' + (collapsed ? ' collapsed' : '')}>
|
||||
<aside className="ae-sidebar">
|
||||
<div className="ae-side-top">
|
||||
<button className="ae-brand" onClick={newThread} title="Hanzo">
|
||||
<HanzoMark size={22} />
|
||||
{!collapsed && <span>Hanzo</span>}
|
||||
</button>
|
||||
<button className="ae-collapse" onClick={() => setCollapsed((v) => !v)} title="Toggle sidebar">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||
<path d="M6 3v10M3 3h10v10H3z" fill="none" stroke="currentColor" strokeWidth="1.3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button className="ae-new" onClick={newThread}>
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
|
||||
<path d="M8 3v10M3 8h10" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
{!collapsed && <span>New thread</span>}
|
||||
</button>
|
||||
<nav className="ae-nav">
|
||||
<a href="https://hanzo.chat" target="_blank" rel="noreferrer">Threads</a>
|
||||
<a href="https://docs.hanzo.ai" target="_blank" rel="noreferrer">Docs</a>
|
||||
<a href="https://hanzo.ai" target="_blank" rel="noreferrer">About</a>
|
||||
</nav>
|
||||
<div className="ae-side-bottom">
|
||||
<button className="ae-signin" onClick={signIn}>
|
||||
{collapsed ? '↪' : 'Sign in — free to start'}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="ae-main">
|
||||
{phase === 'idle' ? (
|
||||
<div className="ae-hero">
|
||||
<div className="ae-wordmark">
|
||||
<HanzoMark size={44} />
|
||||
<span>Hanzo</span>
|
||||
</div>
|
||||
<div className="ae-hero-card">
|
||||
<InputCard
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
onSubmit={submit}
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
sources={sources}
|
||||
toggleSource={toggleSource}
|
||||
models={modelGroups}
|
||||
model={model}
|
||||
setModel={setModel}
|
||||
busy={false}
|
||||
/>
|
||||
</div>
|
||||
{news.length > 0 && (
|
||||
<div className="ae-news">
|
||||
<div className="ae-news-head">Top stories</div>
|
||||
<div className="ae-news-grid">
|
||||
{news.slice(0, 6).map((n) => (
|
||||
<a key={n.url} className="ae-news-item" href={n.url} target="_blank" rel="noreferrer">
|
||||
<span className="ae-news-title">{n.title}</span>
|
||||
<span className="ae-news-meta">
|
||||
{n.source}
|
||||
{typeof n.points === 'number' ? ` · ${n.points} pts` : ''}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ae-answer-wrap">
|
||||
<div className="ae-answer-scroll">
|
||||
<h1 className="ae-question">{submitted}</h1>
|
||||
|
||||
{answerSources.length > 0 && (
|
||||
<div className="ae-sources">
|
||||
{answerSources.map((s, i) => (
|
||||
<SourceCard key={s.url} s={s} index={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'running' && !answer && (
|
||||
<div className="ae-status">
|
||||
<span className="ae-spinner" /> {status}…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needAuth ? (
|
||||
<div className="ae-auth">
|
||||
<p>Sign in with Hanzo to get AI answers.</p>
|
||||
<button className="ae-signin big" onClick={signIn}>
|
||||
Sign in — free to start
|
||||
</button>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="ae-error">{error}</div>
|
||||
) : (
|
||||
<div
|
||||
className="ae-answer markdown"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(answer) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{followUps.length > 0 && (
|
||||
<div className="ae-followups">
|
||||
<div className="ae-followups-head">Follow-up</div>
|
||||
{followUps.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
className="ae-followup"
|
||||
onClick={() => {
|
||||
setQuery(f);
|
||||
void run(f);
|
||||
}}
|
||||
>
|
||||
{f}
|
||||
<span className="ae-followup-arrow">→</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ae-answer-composer">
|
||||
<InputCard
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
onSubmit={submit}
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
sources={sources}
|
||||
toggleSource={toggleSource}
|
||||
models={modelGroups}
|
||||
model={model}
|
||||
setModel={setModel}
|
||||
busy={phase === 'running'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Minimal, XSS-safe Markdown → HTML for the streamed answer. Escapes first,
|
||||
* then applies a small whitelist of inline/block transforms. Only http(s) links
|
||||
* are emitted. No dependency — the answer only needs headings, bold/italic,
|
||||
* code, inline `[text](url)` citations, and bullet/number lists.
|
||||
*/
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function inline(s: string): string {
|
||||
let out = escapeHtml(s);
|
||||
// Inline code first so its contents aren't further transformed.
|
||||
out = out.replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`);
|
||||
// [text](url) — only http(s) URLs; text keeps prior transforms applied after.
|
||||
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, text, url) => {
|
||||
const safeUrl = url.replace(/"/g, '%22');
|
||||
return `<a href="${safeUrl}" target="_blank" rel="noreferrer">${text}</a>`;
|
||||
});
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
out = out.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
||||
return out;
|
||||
}
|
||||
|
||||
export function renderMarkdown(md: string): string {
|
||||
const lines = md.replace(/\r\n/g, '\n').split('\n');
|
||||
const html: string[] = [];
|
||||
let listType: 'ul' | 'ol' | null = null;
|
||||
|
||||
const closeList = () => {
|
||||
if (listType) {
|
||||
html.push(`</${listType}>`);
|
||||
listType = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd();
|
||||
if (!line.trim()) {
|
||||
closeList();
|
||||
continue;
|
||||
}
|
||||
const heading = /^(#{1,4})\s+(.*)$/.exec(line);
|
||||
if (heading) {
|
||||
closeList();
|
||||
const level = heading[1].length + 1; // h2..h5
|
||||
html.push(`<h${level}>${inline(heading[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
const bullet = /^[-*]\s+(.*)$/.exec(line);
|
||||
if (bullet) {
|
||||
if (listType !== 'ul') {
|
||||
closeList();
|
||||
html.push('<ul>');
|
||||
listType = 'ul';
|
||||
}
|
||||
html.push(`<li>${inline(bullet[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
const numbered = /^\d+\.\s+(.*)$/.exec(line);
|
||||
if (numbered) {
|
||||
if (listType !== 'ol') {
|
||||
closeList();
|
||||
html.push('<ol>');
|
||||
listType = 'ol';
|
||||
}
|
||||
html.push(`<li>${inline(numbered[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
closeList();
|
||||
html.push(`<p>${inline(line)}</p>`);
|
||||
}
|
||||
closeList();
|
||||
return html.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Hanzo model catalog for the answer-engine model picker. Reads the REAL
|
||||
* catalog from `GET api.hanzo.ai/v1/models` (public, no auth) and keeps the
|
||||
* text/chat families — never a hardcoded or fabricated list. Switching the
|
||||
* picker actually changes the `model` passed to the @hanzo/ai search primitive.
|
||||
*/
|
||||
|
||||
export interface ChatModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tier: string;
|
||||
family: string;
|
||||
context: number | null;
|
||||
}
|
||||
|
||||
export interface ModelGroup {
|
||||
family: string;
|
||||
label: string;
|
||||
models: ChatModel[];
|
||||
}
|
||||
|
||||
interface ModelRow {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
description?: string;
|
||||
tier?: string;
|
||||
context?: number | null;
|
||||
}
|
||||
interface FamilyRow {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
models?: string[];
|
||||
}
|
||||
interface ModelsResponse {
|
||||
data?: ModelRow[];
|
||||
families?: FamilyRow[];
|
||||
}
|
||||
|
||||
/** Text/chat families to surface in the picker (embedding/image/audio excluded). */
|
||||
const CHAT_FAMILIES = ["enso", "zen5", "zen3"];
|
||||
|
||||
/** The "let the gateway route" option — mirrors Scira's Auto. */
|
||||
export const AUTO_MODEL: ChatModel = {
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Routes to the best model",
|
||||
tier: "",
|
||||
family: "auto",
|
||||
context: null,
|
||||
};
|
||||
|
||||
export async function fetchModelCatalog(
|
||||
apiBase: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ groups: ModelGroup[]; flat: ChatModel[] }> {
|
||||
const res = await fetch(`${apiBase}/v1/models`, signal ? { signal } : {});
|
||||
if (!res.ok) throw new Error(`models ${res.status}`);
|
||||
const body = (await res.json()) as ModelsResponse;
|
||||
|
||||
const byId = new Map<string, ModelRow>();
|
||||
for (const row of body.data ?? []) byId.set(row.id, row);
|
||||
|
||||
const groups: ModelGroup[] = [];
|
||||
for (const fam of body.families ?? []) {
|
||||
if (!CHAT_FAMILIES.includes(fam.id)) continue;
|
||||
const models: ChatModel[] = [];
|
||||
for (const id of fam.models ?? []) {
|
||||
const row = byId.get(id);
|
||||
models.push({
|
||||
id,
|
||||
name: row?.name || row?.fullName || id,
|
||||
description: row?.description || "",
|
||||
tier: row?.tier || "",
|
||||
family: fam.id,
|
||||
context: row?.context ?? null,
|
||||
});
|
||||
}
|
||||
if (models.length) {
|
||||
groups.push({ family: fam.id, label: fam.name || fam.id, models });
|
||||
}
|
||||
}
|
||||
|
||||
const flat = groups.flatMap((g) => g.models);
|
||||
return { groups, flat };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Realtime news / world feed for the Hanzo answer engine.
|
||||
*
|
||||
* ONE provider interface, two implementations:
|
||||
* - `hanzo` — the Hanzo cloud feed (api.hanzo.ai). This is the intended
|
||||
* source; the cloud endpoint does not exist yet (see the Scira→clients/ai
|
||||
* capability-port plan), so it is the clearly-marked SEAM. When cloud ships
|
||||
* a news endpoint, set DEFAULT_FEED = 'hanzo' and nothing else changes.
|
||||
* - `hackernews` — a real, keyless, CORS-open live feed (Hacker News via the
|
||||
* Algolia API). The working default today. NOT a fabricated feed.
|
||||
*/
|
||||
|
||||
export interface NewsItem {
|
||||
title: string;
|
||||
url: string;
|
||||
source: string;
|
||||
points?: number;
|
||||
comments?: number;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
export interface FeedProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
fetch(signal?: AbortSignal): Promise<NewsItem[]>;
|
||||
}
|
||||
|
||||
/** Real live feed — Hacker News front page (keyless, `access-control-allow-origin: *`). */
|
||||
const hackerNews: FeedProvider = {
|
||||
id: "hackernews",
|
||||
label: "Top stories",
|
||||
async fetch(signal) {
|
||||
const res = await fetch(
|
||||
"https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=12",
|
||||
signal ? { signal } : {},
|
||||
);
|
||||
if (!res.ok) throw new Error(`news ${res.status}`);
|
||||
const data = (await res.json()) as { hits?: Array<Record<string, unknown>> };
|
||||
return (data.hits ?? [])
|
||||
.map((h) => ({
|
||||
title: String(h.title ?? ""),
|
||||
url: String(h.url ?? ""),
|
||||
source: "Hacker News",
|
||||
points: typeof h.points === "number" ? h.points : undefined,
|
||||
comments: typeof h.num_comments === "number" ? h.num_comments : undefined,
|
||||
author: typeof h.author === "string" ? h.author : undefined,
|
||||
}))
|
||||
.filter((n) => n.title && n.url);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Hanzo cloud feed SEAM. Wire to `GET api.hanzo.ai/v1/news` once the cloud
|
||||
* capability port lands; until then it throws so callers fall back to the real
|
||||
* feed above. Kept here so the swap is one line (DEFAULT_FEED = 'hanzo').
|
||||
*/
|
||||
export function hanzoFeed(apiBase: string, getToken: () => Promise<string | null>): FeedProvider {
|
||||
return {
|
||||
id: "hanzo",
|
||||
label: "Hanzo News",
|
||||
async fetch(signal) {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${apiBase}/v1/news`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`hanzo news ${res.status}`);
|
||||
const data = (await res.json()) as { items?: NewsItem[] };
|
||||
return data.items ?? [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Active feed. Flip to 'hanzo' when the cloud news endpoint ships. */
|
||||
export const DEFAULT_FEED = "hackernews";
|
||||
|
||||
export async function fetchNews(signal?: AbortSignal): Promise<NewsItem[]> {
|
||||
try {
|
||||
return await hackerNews.fetch(signal);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
type ZapManager,
|
||||
} from './shared/zap.js';
|
||||
import { connectNativeZap, type NativeZapState } from './shared/native-zap.js';
|
||||
import { fetchAllUsage } from './shared/usage.js';
|
||||
import { pickEvaluable, wrapEvaluable } from './shared/evaluable.js';
|
||||
import {
|
||||
getRagConfig,
|
||||
normalizeRagSnippets,
|
||||
@@ -108,7 +110,11 @@ class HanzoFirefoxExtension {
|
||||
|
||||
constructor() {
|
||||
console.log('[Hanzo] Firefox extension initializing...');
|
||||
this.connect();
|
||||
// Legacy ws://:9223 CDP bridge is OPT-IN (globalThis.HANZO_LEGACY_CDP). The
|
||||
// default (and only) transport is the ZAP native host — see
|
||||
// discoverZapServers() at EOF — matching Chrome. Without a bridge server the
|
||||
// WS just error-looped, so it no longer runs unless explicitly enabled.
|
||||
if ((globalThis as { HANZO_LEGACY_CDP?: boolean }).HANZO_LEGACY_CDP) this.connect();
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
@@ -607,34 +613,26 @@ class HanzoFirefoxExtension {
|
||||
case 'Runtime.evaluate':
|
||||
case 'evaluate': {
|
||||
if (!tabId) throw new Error('No active tab');
|
||||
// Accept both `expression` (CDP) and `code` (MCP/SDK convention).
|
||||
const expression = (params.expression as string) ?? (params.code as string) ?? '';
|
||||
// Default 30s — Porkbun-class pages with 1500+ DOM nodes routinely
|
||||
// need more than 10s for first-paint queries. Caller can override.
|
||||
const timeout = (params.timeout as number) || 30000;
|
||||
// Caller can opt into Promise-await semantics. CDP names this flag
|
||||
// `awaitPromise`; the MCP/SDK callers pass `await_promise`.
|
||||
const awaitPromise = Boolean(params.awaitPromise ?? params.await_promise);
|
||||
|
||||
// If the caller already wrote a `return` or wrote a function/IIFE,
|
||||
// wrap differently so we don't double-wrap and break syntax.
|
||||
const trimmed = expression.trim();
|
||||
const code = /^\s*return\b/.test(trimmed)
|
||||
? trimmed
|
||||
: trimmed.endsWith(';')
|
||||
? `${trimmed} return undefined;`
|
||||
: `return (${trimmed});`;
|
||||
|
||||
// When awaitPromise is requested, resolve in-page so executeScript's
|
||||
// structured-clone serialization sees the resolved value rather than
|
||||
// a Promise (which it can't transfer).
|
||||
const evalCode = awaitPromise
|
||||
? `
|
||||
return Promise.resolve((function(){ ${code} })()).then(function(__v){
|
||||
try { return JSON.parse(JSON.stringify(__v)); } catch(e) { return String(__v); }
|
||||
});
|
||||
`
|
||||
: code;
|
||||
// pickEvaluable accepts every code-param alias (expression / code /
|
||||
// script / function / js); wrapEvaluable is the ONE cross-browser
|
||||
// rule that turns statement bodies (`const b=…; b.click()`) into an
|
||||
// async IIFE with the trailing value auto-returned — the previous
|
||||
// `return (${code})` wrap was a SyntaxError on any multi-statement
|
||||
// body. Always resolve in-page so executeScript's structured-clone
|
||||
// serialization sees the settled value, never a Promise; rejections
|
||||
// ride the __hanzo_error channel runInPage already throws on.
|
||||
const evalCode = `
|
||||
return Promise.resolve(${wrapEvaluable(pickEvaluable(params))}).then(function(__v){
|
||||
if (__v === undefined || __v === null) return null;
|
||||
try { return JSON.parse(JSON.stringify(__v)); } catch(e) { return String(__v); }
|
||||
}, function(__e){
|
||||
return { __hanzo_error: (__e && __e.message) || String(__e) };
|
||||
});
|
||||
`;
|
||||
|
||||
let value: unknown;
|
||||
let evalError: string | null = null;
|
||||
@@ -3007,6 +3005,17 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
|
||||
sendResponse({ success: false, error: 'Tab filesystem requires CDP bridge.' });
|
||||
break;
|
||||
|
||||
// AI provider usage (Claude + Codex, via @hanzo/usage)
|
||||
case 'usage.fetch': {
|
||||
try {
|
||||
const providers = await fetchAllUsage();
|
||||
sendResponse({ success: true, providers });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
sendResponse({ success: false, error: `Unknown action: ${request.action}` });
|
||||
break;
|
||||
@@ -3079,4 +3088,32 @@ browser.tabs.onRemoved.addListener((tabId) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Omnibox → Hanzo answer engine ────────────────────────────────────────
|
||||
// Firefox's address bar can't be silently hijacked (a security boundary), and
|
||||
// `chrome_settings_overrides.search_provider` default-engine takeover is policy
|
||||
// gated. The WebExtension mechanism that DOES work without opt-in is the
|
||||
// omnibox keyword: type `hanzo <query>` in the address bar → the query loads
|
||||
// the Hanzo answer engine (AI answer + sources + news) with ?q=. Same surface
|
||||
// as the new-tab landing.
|
||||
try {
|
||||
const omnibox = (browser as any).omnibox;
|
||||
if (omnibox) {
|
||||
omnibox.setDefaultSuggestion({
|
||||
description: 'Search Hanzo AI — AI answers with sources and realtime news',
|
||||
});
|
||||
omnibox.onInputEntered.addListener((text: string, disposition: string) => {
|
||||
const url = browser.runtime.getURL('newtab.html') + '?q=' + encodeURIComponent(text);
|
||||
if (disposition === 'newForegroundTab') {
|
||||
void browser.tabs.create({ url });
|
||||
} else if (disposition === 'newBackgroundTab') {
|
||||
void browser.tabs.create({ url, active: false });
|
||||
} else {
|
||||
void browser.tabs.update({ url });
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Hanzo] omnibox unavailable:', e);
|
||||
}
|
||||
|
||||
console.log('[Hanzo] Firefox background script loaded');
|
||||
|
||||
@@ -38,6 +38,7 @@ import { BrowserControl } from './browser-control';
|
||||
import { WebGPUAI } from './webgpu-ai';
|
||||
import { getCDPBridge, CDPBridge } from './browser-dispatch';
|
||||
import * as auth from './auth';
|
||||
import { fetchAllUsage } from './shared/usage.js';
|
||||
import { listModels, chatCompletion, ChatMessage } from './chat-client';
|
||||
import { PageMonitor } from './page-monitor';
|
||||
import {
|
||||
@@ -282,9 +283,18 @@ async function stopControlSession(): Promise<void> {
|
||||
void loadDebugFlagFromStorage();
|
||||
|
||||
// Initialize WebGPU, Ollama, and CDP on install
|
||||
chrome.runtime.onInstalled.addListener(async () => {
|
||||
chrome.runtime.onInstalled.addListener(async (details) => {
|
||||
debugLog('[Hanzo] Extension installed, initializing...');
|
||||
|
||||
// First install → open the consent onboarding once (insights + opt-in training).
|
||||
if (details?.reason === 'install') {
|
||||
try {
|
||||
await chrome.tabs.create({ url: chrome.runtime.getURL('onboarding.html') });
|
||||
} catch {
|
||||
/* non-fatal — settings still reachable from the popup */
|
||||
}
|
||||
}
|
||||
|
||||
// WebGPU init
|
||||
const gpuAvailable = await webgpuAI.initialize();
|
||||
if (gpuAvailable) {
|
||||
@@ -1508,7 +1518,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
|
||||
@@ -1787,6 +1797,19 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
break;
|
||||
}
|
||||
|
||||
// --- AI provider usage (Claude + Codex, via @hanzo/usage) ---
|
||||
case 'usage.fetch': {
|
||||
(async () => {
|
||||
try {
|
||||
const providers = await fetchAllUsage();
|
||||
sendResponse({ success: true, providers });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
})();
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 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;
|
||||
@@ -239,7 +240,7 @@ export class CDPBridge {
|
||||
// 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,
|
||||
expression: wrapEvaluable(expression),
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
});
|
||||
@@ -389,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,
|
||||
});
|
||||
|
||||
@@ -5,8 +5,18 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// ONE output root: the extension repo root's `dist/` (NOT packages/browser/dist),
|
||||
// so every build lands at a short, stable path — `<extension-root>/dist/chrome`
|
||||
// is the Load-unpacked / native-messaging target, regardless of where `node
|
||||
// src/build.js` (or `pnpm build`) is invoked from. Derived from __dirname
|
||||
// (packages/browser/src → ../../../) so it's cwd-independent. Per-browser bundles
|
||||
// sit directly under OUT; the shared staging bundles + npm files sit at OUT root.
|
||||
const OUT = path.resolve(__dirname, '..', '..', '..', 'dist');
|
||||
const out = (...p) => path.join(OUT, ...p);
|
||||
|
||||
async function build() {
|
||||
console.log('Building browser extension...');
|
||||
console.log('Output root:', OUT);
|
||||
|
||||
// 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
|
||||
@@ -16,17 +26,27 @@ async function build() {
|
||||
const hanzoGuiShim = path.join(__dirname, 'gui-primitives.tsx');
|
||||
console.log('Using @hanzo/gui local primitives shim:', hanzoGuiShim);
|
||||
|
||||
// The Usage panel runs the headless @hanzo/usage engine in the background
|
||||
// context. It's a published npm dependency (^0.1.6) — esbuild resolves it
|
||||
// from node_modules like any package, so the build works identically locally
|
||||
// and on CI (no sibling-repo path assumption).
|
||||
|
||||
// The answer engine (new-tab landing) consumes the @hanzo/ai SDK's `search`
|
||||
// primitive — the ONE place the search/deep-research capability is defined.
|
||||
// It's a published npm dependency (^0.2.3), so esbuild resolves it from
|
||||
// node_modules like any other package — works identically locally and on CI
|
||||
// (no sibling-repo path assumption).
|
||||
|
||||
// Ensure dist directories exist
|
||||
fs.mkdirSync('dist/browser-extension', { recursive: true });
|
||||
fs.mkdirSync('dist/browser-extension/chrome', { recursive: true });
|
||||
fs.mkdirSync('dist/browser-extension/firefox', { recursive: true });
|
||||
fs.mkdirSync('dist/browser-extension/safari', { recursive: true });
|
||||
for (const d of ['', 'chrome', 'firefox', 'safari']) {
|
||||
fs.mkdirSync(out(d), { recursive: true });
|
||||
}
|
||||
|
||||
// Build content script
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/content-script.ts'],
|
||||
bundle: true,
|
||||
outfile: 'dist/browser-extension/content-script.js',
|
||||
outfile: out('content-script.js'),
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'firefox91', 'safari14'],
|
||||
sourcemap: 'inline'
|
||||
@@ -36,29 +56,29 @@ async function build() {
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/background.ts'],
|
||||
bundle: true,
|
||||
outfile: 'dist/browser-extension/background.js',
|
||||
outfile: out('background.js'),
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'safari14'],
|
||||
format: 'esm',
|
||||
external: ['chrome', 'browser']
|
||||
external: ['chrome', 'browser'],
|
||||
});
|
||||
|
||||
// Build Firefox-specific background script (no ESM export, uses browser.* APIs)
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/background-firefox.ts'],
|
||||
bundle: true,
|
||||
outfile: 'dist/browser-extension/background-firefox.js',
|
||||
outfile: out('background-firefox.js'),
|
||||
platform: 'browser',
|
||||
target: ['firefox91'],
|
||||
format: 'iife', // Immediately-invoked function expression (no exports)
|
||||
external: ['browser']
|
||||
external: ['browser'],
|
||||
});
|
||||
|
||||
// Build WebGPU AI module
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/webgpu-ai.ts'],
|
||||
bundle: true,
|
||||
outfile: 'dist/browser-extension/webgpu-ai.js',
|
||||
outfile: out('webgpu-ai.js'),
|
||||
platform: 'browser',
|
||||
target: 'es2020',
|
||||
format: 'esm'
|
||||
@@ -73,7 +93,7 @@ async function build() {
|
||||
await esbuild.build({
|
||||
entryPoints: [fs.existsSync('src/sidebar.ts') ? 'src/sidebar.ts' : 'src/sidebar.js'],
|
||||
bundle: true,
|
||||
outfile: 'dist/browser-extension/sidebar.js',
|
||||
outfile: out('sidebar.js'),
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'firefox91', 'safari14'],
|
||||
sourcemap: 'inline',
|
||||
@@ -83,17 +103,41 @@ async function build() {
|
||||
await esbuild.build({
|
||||
entryPoints: [fs.existsSync('src/popup.ts') ? 'src/popup.ts' : 'src/popup.js'],
|
||||
bundle: true,
|
||||
outfile: 'dist/browser-extension/popup.js',
|
||||
outfile: out('popup.js'),
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'firefox91', 'safari14'],
|
||||
sourcemap: 'inline',
|
||||
});
|
||||
|
||||
// Answer engine (new-tab landing + omnibox/address-bar search target).
|
||||
// React 18 surface consuming the @hanzo/ai `search` primitive; monochrome.
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/newtab.ts'],
|
||||
bundle: true,
|
||||
outfile: out('newtab.js'),
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'firefox91', 'safari14'],
|
||||
// New-tab page loads on every tab — ship it lean (minified, no inline map).
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
});
|
||||
|
||||
// Onboarding (new-install consent ask: insights + opt-in training).
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/onboarding.ts'],
|
||||
bundle: true,
|
||||
outfile: out('onboarding.js'),
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'firefox91', 'safari14'],
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
});
|
||||
|
||||
// Build browser control module
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/browser-control.ts'],
|
||||
bundle: true,
|
||||
outfile: 'dist/browser-extension/browser-control.js',
|
||||
outfile: out('browser-control.js'),
|
||||
platform: 'browser',
|
||||
target: 'es2020',
|
||||
format: 'esm'
|
||||
@@ -103,7 +147,7 @@ async function build() {
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/cli.ts'],
|
||||
bundle: true,
|
||||
outfile: 'dist/browser-extension/cli.js',
|
||||
outfile: out('cli.js'),
|
||||
platform: 'node',
|
||||
target: 'node16',
|
||||
packages: 'external'
|
||||
@@ -111,16 +155,9 @@ async function build() {
|
||||
|
||||
// Make CLI executable
|
||||
if (process.platform !== 'win32') {
|
||||
execSync('chmod +x dist/browser-extension/cli.js');
|
||||
fs.chmodSync(out('cli.js'), 0o755);
|
||||
}
|
||||
|
||||
// Build TypeScript declarations
|
||||
console.log('Building TypeScript declarations...');
|
||||
// Check if we should run tsc in root or src, based on where tsconfig is.
|
||||
// Assuming root tsconfig covers src, or src has one.
|
||||
// There is a tsconfig in src.
|
||||
// execSync('cd src && npx tsc --noEmit', { stdio: 'inherit' });
|
||||
|
||||
// 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).
|
||||
@@ -130,12 +167,12 @@ async function build() {
|
||||
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');
|
||||
writeManifest('src/manifest.json', out('manifest.json'));
|
||||
writeManifest('src/manifest.json', out('chrome', 'manifest.json'));
|
||||
writeManifest('src/manifest-firefox.json', out('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"?>
|
||||
fs.writeFileSync(out('safari', 'Info.plist'), `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
@@ -159,36 +196,29 @@ async function build() {
|
||||
|
||||
// Copy common files to each browser directory
|
||||
['chrome', 'firefox', 'safari'].forEach(browserName => {
|
||||
const dir = `dist/browser-extension/${browserName}`;
|
||||
const dir = out(browserName);
|
||||
|
||||
fs.copyFileSync(
|
||||
'dist/browser-extension/content-script.js',
|
||||
`${dir}/content-script.js`
|
||||
);
|
||||
fs.copyFileSync(out('content-script.js'), path.join(dir, 'content-script.js'));
|
||||
// Firefox uses IIFE background (no ESM service workers); Chrome/Safari use ESM
|
||||
if (browserName === 'firefox') {
|
||||
fs.copyFileSync(
|
||||
'dist/browser-extension/background-firefox.js',
|
||||
`${dir}/background.js`
|
||||
);
|
||||
fs.copyFileSync(out('background-firefox.js'), path.join(dir, 'background.js'));
|
||||
} else {
|
||||
fs.copyFileSync(
|
||||
'dist/browser-extension/background.js',
|
||||
`${dir}/background.js`
|
||||
);
|
||||
fs.copyFileSync(out('background.js'), path.join(dir, 'background.js'));
|
||||
}
|
||||
|
||||
// Copy popup + sidebar HTML/CSS
|
||||
for (const f of ['popup.html', 'popup.css', 'sidebar.html', 'sidebar.css', 'callback.html', 'ai-worker.js']) {
|
||||
// Copy popup + sidebar + answer-engine HTML/CSS
|
||||
for (const f of ['popup.html', 'popup.css', 'sidebar.html', 'sidebar.css', 'newtab.html', 'newtab.css', 'onboarding.html', 'onboarding.css', 'callback.html', 'ai-worker.js']) {
|
||||
const src = path.join('src', f);
|
||||
if (fs.existsSync(src)) {
|
||||
fs.copyFileSync(src, path.join(dir, f));
|
||||
}
|
||||
}
|
||||
|
||||
// Copy compiled popup/sidebar scripts
|
||||
fs.copyFileSync('dist/browser-extension/popup.js', path.join(dir, 'popup.js'));
|
||||
fs.copyFileSync('dist/browser-extension/sidebar.js', path.join(dir, 'sidebar.js'));
|
||||
// Copy compiled popup/sidebar/newtab scripts
|
||||
fs.copyFileSync(out('popup.js'), path.join(dir, 'popup.js'));
|
||||
fs.copyFileSync(out('sidebar.js'), path.join(dir, 'sidebar.js'));
|
||||
fs.copyFileSync(out('newtab.js'), path.join(dir, 'newtab.js'));
|
||||
fs.copyFileSync(out('onboarding.js'), path.join(dir, 'onboarding.js'));
|
||||
|
||||
// Copy icons into each browser directory
|
||||
for (const icon of ['icon16.png', 'icon48.png', 'icon128.png']) {
|
||||
@@ -202,13 +232,10 @@ async function build() {
|
||||
// Copy package.json for npm
|
||||
const pkg = JSON.parse(fs.readFileSync('src/package.json', 'utf8'));
|
||||
pkg.main = 'cli.js';
|
||||
fs.writeFileSync(
|
||||
'dist/browser-extension/package.json',
|
||||
JSON.stringify(pkg, null, 2)
|
||||
);
|
||||
fs.writeFileSync(out('package.json'), JSON.stringify(pkg, null, 2));
|
||||
|
||||
// Create README for npm package
|
||||
fs.writeFileSync('dist/browser-extension/README.md', `# Hanzo Browser DevTools
|
||||
fs.writeFileSync(out('README.md'), `# Hanzo Browser DevTools
|
||||
|
||||
Click-to-code navigation for web developers with MCP integration.
|
||||
|
||||
@@ -257,32 +284,33 @@ server.on('elementSelected', (data) => {
|
||||
['icon16.png', 'icon48.png', 'icon128.png'].forEach(icon => {
|
||||
const iconPath = path.join('images', icon);
|
||||
if (fs.existsSync(iconPath)) {
|
||||
fs.copyFileSync(iconPath, path.join('dist/browser-extension', icon));
|
||||
fs.copyFileSync(iconPath, out(icon));
|
||||
}
|
||||
});
|
||||
|
||||
// Generate Safari Xcode project (macOS + iOS) if safari-web-extension-converter exists
|
||||
// Generate Safari Xcode project (macOS + iOS) if safari-web-extension-converter exists.
|
||||
// Project goes to dist/safari-xcode/ so it never collides with the dist/safari/ web-ext dir.
|
||||
try {
|
||||
execSync('xcrun --find safari-web-extension-converter', { stdio: 'ignore' });
|
||||
console.log('Generating Safari Xcode project (macOS + iOS)...');
|
||||
execSync(
|
||||
`xcrun safari-web-extension-converter dist/browser-extension/chrome/ ` +
|
||||
`--project-location dist/safari ` +
|
||||
`xcrun safari-web-extension-converter ${out('chrome')}/ ` +
|
||||
`--project-location ${out('safari-xcode')} ` +
|
||||
`--app-name "Hanzo AI" ` +
|
||||
`--bundle-identifier ai.hanzo.browser-extension ` +
|
||||
`--no-prompt --no-open --force`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
console.log('📦 Safari: dist/safari/ (macOS + iOS Xcode project)');
|
||||
console.log(`📦 Safari: ${out('safari-xcode')} (macOS + iOS Xcode project)`);
|
||||
} catch {
|
||||
console.log('⚠️ Safari: skipped (Xcode not available)');
|
||||
}
|
||||
|
||||
console.log('✅ Browser extension built successfully!');
|
||||
console.log('📦 Chrome: dist/browser-extension/chrome/');
|
||||
console.log('📦 Firefox: dist/browser-extension/firefox/');
|
||||
console.log('📦 NPM package ready in: dist/browser-extension/');
|
||||
console.log('\nTo publish to npm: cd dist/browser-extension && npm publish');
|
||||
console.log(`📦 Chrome: ${out('chrome')}/`);
|
||||
console.log(`📦 Firefox: ${out('firefox')}/`);
|
||||
console.log(`📦 NPM package ready in: ${OUT}/`);
|
||||
console.log(`\nTo publish to npm: cd ${OUT} && npm publish`);
|
||||
}
|
||||
|
||||
build().catch((error) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.9.21",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"version": "1.9.34",
|
||||
"description": "AI answer engine, search, and dev assistant — Hanzo AI in a new tab, address-bar search, source-cited answers, realtime news, WebGPU AI, and MCP.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"identity",
|
||||
@@ -17,6 +17,8 @@
|
||||
"host_permissions": [
|
||||
"http://localhost/*",
|
||||
"https://localhost/*",
|
||||
"https://claude.ai/*",
|
||||
"https://chatgpt.com/*",
|
||||
"<all_urls>"
|
||||
],
|
||||
"content_security_policy": {
|
||||
@@ -47,6 +49,12 @@
|
||||
"128": "icon128.png"
|
||||
}
|
||||
},
|
||||
"chrome_url_overrides": {
|
||||
"newtab": "newtab.html"
|
||||
},
|
||||
"omnibox": {
|
||||
"keyword": "hanzo"
|
||||
},
|
||||
"sidebar_action": {
|
||||
"default_title": "Hanzo AI",
|
||||
"default_panel": "sidebar.html",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.9.21",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"version": "1.9.34",
|
||||
"description": "AI answer engine, search, and dev assistant — Hanzo AI in a new tab, address-bar search, source-cited answers, realtime news, WebGPU AI, and MCP.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxfXGh0lPUT5m04GKfjUwrLsV6pLaK3VcZuFRPogqAir2tzyLYnQPRtHynue9yvDyguIVnlkwvcwfDOYZrvH76zbw4s6onPBG8HqTKO6LQ9K3kdO1qBBkMMjdOgULQ1MrWThEbpU7NSTiwLYpEta/jAvrKRCAeKIlQE8p6htZmPy9aRUZuae66JgLcAlzD2vviX9sVB1asFABJVswL1RgZ55/8IzZaUrFjzOo9OHK4hmEOtudzkML+5silsAYdC+1BZugph2x94ai17YmZTCL1XyUa5Ke4q80cj+i9rOTgzhZs+mruyhL/AvNVOXilsgqCdNqSz77naWzC3pVGbxOewIDAQAB",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
@@ -19,6 +19,8 @@
|
||||
"host_permissions": [
|
||||
"http://localhost/*",
|
||||
"https://localhost/*",
|
||||
"https://claude.ai/*",
|
||||
"https://chatgpt.com/*",
|
||||
"<all_urls>"
|
||||
],
|
||||
"content_security_policy": {
|
||||
@@ -52,6 +54,19 @@
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
},
|
||||
"chrome_url_overrides": {
|
||||
"newtab": "newtab.html"
|
||||
},
|
||||
"chrome_settings_overrides": {
|
||||
"search_provider": {
|
||||
"name": "Hanzo",
|
||||
"keyword": "hanzo",
|
||||
"search_url": "https://hanzo.chat/c/new?q={searchTerms}&submit=true",
|
||||
"favicon_url": "https://hanzo.ai/favicon.ico",
|
||||
"encoding": "UTF-8",
|
||||
"is_default": true
|
||||
}
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": [
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/* Hanzo Answer Engine — strict monochrome (brand primaryColor #FFFFFF).
|
||||
Shared tokens mirror popup.css / sidebar.css. */
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #0f0f0f;
|
||||
--surface-2: #171717;
|
||||
--surface-3: #1e1e1e;
|
||||
--text: #fafafa;
|
||||
--text-2: #a3a3a3;
|
||||
--text-3: #666666;
|
||||
--border: rgba(255, 255, 255, 0.10);
|
||||
--border-2: rgba(255, 255, 255, 0.16);
|
||||
--glass: rgba(255, 255, 255, 0.04);
|
||||
--glass-2: rgba(255, 255, 255, 0.07);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--radius-xs: 6px;
|
||||
--sidebar-w: 232px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Geist Sans', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.ae-root {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w) 1fr;
|
||||
height: 100vh;
|
||||
transition: grid-template-columns 0.18s ease;
|
||||
}
|
||||
.ae-root.collapsed { grid-template-columns: 60px 1fr; }
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────────── */
|
||||
.ae-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 12px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
min-width: 0;
|
||||
}
|
||||
.ae-side-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.ae-brand {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
background: none; border: none; color: var(--text);
|
||||
font-size: 15px; font-weight: 600; letter-spacing: -0.01em;
|
||||
cursor: pointer; padding: 4px; min-width: 0;
|
||||
}
|
||||
.ae-brand span { white-space: nowrap; overflow: hidden; }
|
||||
.ae-collapse, .ae-new, .ae-signin {
|
||||
cursor: pointer; font-family: inherit;
|
||||
}
|
||||
.ae-collapse {
|
||||
background: none; border: none; color: var(--text-3);
|
||||
padding: 4px; border-radius: var(--radius-xs); display: flex;
|
||||
}
|
||||
.ae-collapse:hover { color: var(--text); background: var(--glass); }
|
||||
.ae-new {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-top: 8px; padding: 9px 12px;
|
||||
background: var(--text); color: #000; border: none;
|
||||
border-radius: var(--radius-sm); font-size: 13px; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.ae-new:hover { background: #fff; }
|
||||
.ae-nav { display: flex; flex-direction: column; margin-top: 12px; gap: 1px; }
|
||||
.ae-nav a {
|
||||
padding: 8px 10px; border-radius: var(--radius-xs);
|
||||
color: var(--text-2); font-size: 13px; white-space: nowrap;
|
||||
}
|
||||
.ae-nav a:hover { background: var(--glass); color: var(--text); }
|
||||
.ae-side-bottom { margin-top: auto; }
|
||||
.ae-signin {
|
||||
width: 100%; padding: 9px 12px;
|
||||
background: var(--glass-2); border: 1px solid var(--border);
|
||||
color: var(--text); border-radius: var(--radius-sm);
|
||||
font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.ae-signin:hover { border-color: var(--border-2); background: var(--surface-2); }
|
||||
.ae-signin.big { width: auto; padding: 10px 18px; font-size: 14px; }
|
||||
.collapsed .ae-brand span, .collapsed .ae-new span, .collapsed .ae-nav { display: none; }
|
||||
|
||||
/* ── Main ────────────────────────────────────────────────── */
|
||||
.ae-main { min-width: 0; overflow: hidden; display: flex; flex-direction: column; }
|
||||
|
||||
/* Hero (empty state) */
|
||||
.ae-hero {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 26px; padding: 24px; overflow-y: auto;
|
||||
}
|
||||
.ae-wordmark {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
font-size: 46px; font-weight: 600; letter-spacing: -0.03em; color: var(--text);
|
||||
}
|
||||
.ae-hero-card { width: 100%; max-width: 720px; }
|
||||
.ae-news { width: 100%; max-width: 720px; }
|
||||
.ae-news-head, .ae-followups-head, .ae-model-group-label {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
|
||||
color: var(--text-3); margin-bottom: 10px;
|
||||
}
|
||||
.ae-news-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.ae-news-item {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
padding: 11px 13px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); background: var(--surface);
|
||||
}
|
||||
.ae-news-item:hover { border-color: var(--border-2); background: var(--surface-2); }
|
||||
.ae-news-title { font-size: 13px; color: var(--text); line-height: 1.35; }
|
||||
.ae-news-meta { font-size: 11px; color: var(--text-3); }
|
||||
|
||||
/* ── Input card ──────────────────────────────────────────── */
|
||||
.ae-card {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ae-input {
|
||||
display: block; width: 100%; resize: none;
|
||||
padding: 16px 18px 6px; min-height: 30px; max-height: 200px;
|
||||
background: transparent; border: none; outline: none;
|
||||
color: var(--text); font-family: inherit; font-size: 15px; line-height: 1.5;
|
||||
}
|
||||
.ae-input::placeholder { color: var(--text-3); }
|
||||
.ae-card-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 8px; padding: 8px 10px 10px; flex-wrap: wrap;
|
||||
}
|
||||
.ae-chips { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.ae-chip {
|
||||
padding: 4px 9px; border-radius: 999px; cursor: pointer;
|
||||
background: var(--glass); border: 1px solid var(--border);
|
||||
color: var(--text-3); font-size: 11px; font-family: inherit;
|
||||
}
|
||||
.ae-chip:hover { color: var(--text-2); }
|
||||
.ae-chip.on { background: var(--text); color: #000; border-color: var(--text); }
|
||||
.ae-card-right { display: flex; align-items: center; gap: 6px; }
|
||||
.ae-modes { display: flex; gap: 2px; background: var(--glass); border-radius: var(--radius-xs); padding: 2px; }
|
||||
.ae-mode {
|
||||
padding: 4px 9px; border: none; background: none; cursor: pointer;
|
||||
color: var(--text-3); font-size: 11.5px; font-family: inherit; border-radius: 5px;
|
||||
}
|
||||
.ae-mode:hover { color: var(--text-2); }
|
||||
.ae-mode.on { background: var(--surface-3); color: var(--text); }
|
||||
.ae-send {
|
||||
width: 34px; height: 34px; flex-shrink: 0; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--text); color: #000; border: none; border-radius: 50%;
|
||||
}
|
||||
.ae-send:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
/* ── Model picker ────────────────────────────────────────── */
|
||||
.ae-picker { position: relative; }
|
||||
.ae-picker-btn {
|
||||
display: flex; align-items: center; gap: 6px; cursor: pointer;
|
||||
padding: 6px 10px; background: var(--glass); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs); color: var(--text-2); font-size: 12px; font-family: inherit;
|
||||
}
|
||||
.ae-picker-btn:hover { color: var(--text); border-color: var(--border-2); }
|
||||
.ae-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-2); }
|
||||
.ae-picker-menu {
|
||||
position: absolute; bottom: calc(100% + 6px); right: 0; z-index: 40;
|
||||
width: 320px; max-height: 420px; display: flex; flex-direction: column;
|
||||
background: var(--surface-2); border: 1px solid var(--border-2);
|
||||
border-radius: var(--radius-sm); box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6); overflow: hidden;
|
||||
}
|
||||
.ae-picker-filter {
|
||||
margin: 8px; padding: 8px 10px; background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-xs);
|
||||
color: var(--text); font-family: inherit; font-size: 13px; outline: none;
|
||||
}
|
||||
.ae-picker-list { overflow-y: auto; padding: 0 6px 6px; }
|
||||
.ae-model-group-label { padding: 8px 8px 4px; margin: 0; }
|
||||
.ae-model-row {
|
||||
display: flex; flex-direction: column; gap: 2px; width: 100%; text-align: left;
|
||||
padding: 8px 10px; background: none; border: none; cursor: pointer;
|
||||
border-radius: var(--radius-xs); color: var(--text); font-family: inherit;
|
||||
}
|
||||
.ae-model-row:hover { background: var(--glass); }
|
||||
.ae-model-row.active { background: var(--glass-2); }
|
||||
.ae-model-name { font-size: 13px; display: flex; align-items: center; gap: 7px; }
|
||||
.ae-model-desc { font-size: 11px; color: var(--text-3); line-height: 1.3; }
|
||||
.ae-badge {
|
||||
font-size: 9px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
padding: 1px 5px; border-radius: 4px; background: var(--glass-2); color: var(--text-2);
|
||||
}
|
||||
.ae-plan-banner {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 10px 12px; border-top: 1px solid var(--border);
|
||||
font-size: 11.5px; color: var(--text-2); background: var(--surface);
|
||||
}
|
||||
.ae-plan-tiers { display: flex; gap: 8px; }
|
||||
.ae-plan-tier b { color: var(--text); }
|
||||
|
||||
/* ── Answer view ─────────────────────────────────────────── */
|
||||
.ae-answer-wrap { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
.ae-answer-scroll { flex: 1; overflow-y: auto; padding: 32px 28px 20px; }
|
||||
.ae-answer-scroll > * { max-width: 760px; margin-left: auto; margin-right: auto; }
|
||||
.ae-question { font-size: 26px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 20px; }
|
||||
.ae-sources { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 16px; margin-bottom: 8px; }
|
||||
.ae-source {
|
||||
flex: 0 0 190px; display: flex; flex-direction: column; gap: 6px;
|
||||
padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
.ae-source:hover { border-color: var(--border-2); background: var(--surface-2); }
|
||||
.ae-source-head { display: flex; align-items: center; gap: 6px; }
|
||||
.ae-source-head img { border-radius: 3px; }
|
||||
.ae-source-host { font-size: 11px; color: var(--text-3); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ae-source-idx { font-size: 10px; color: var(--text-3); border: 1px solid var(--border); border-radius: 4px; padding: 0 5px; }
|
||||
.ae-source-title { font-size: 12.5px; line-height: 1.35; color: var(--text); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
|
||||
.ae-status { display: flex; align-items: center; gap: 10px; color: var(--text-2); font-size: 14px; padding: 8px 0; }
|
||||
|
||||
.ae-answer.markdown { font-size: 15px; line-height: 1.65; color: var(--text); }
|
||||
.markdown h2 { font-size: 20px; margin: 22px 0 10px; font-weight: 600; }
|
||||
.markdown h3 { font-size: 17px; margin: 18px 0 8px; font-weight: 600; }
|
||||
.markdown h4, .markdown h5 { font-size: 15px; margin: 14px 0 6px; font-weight: 600; }
|
||||
.markdown p { margin: 0 0 12px; }
|
||||
.markdown ul, .markdown ol { margin: 0 0 12px; padding-left: 22px; }
|
||||
.markdown li { margin: 4px 0; }
|
||||
.markdown a { color: var(--text); text-decoration: underline; text-decoration-color: var(--text-3); text-underline-offset: 2px; }
|
||||
.markdown a:hover { text-decoration-color: var(--text); }
|
||||
.markdown code { background: var(--surface-2); padding: 1px 5px; border-radius: 4px; font-size: 0.9em; font-family: 'Geist Mono', ui-monospace, monospace; }
|
||||
|
||||
.ae-followups { margin-top: 26px; border-top: 1px solid var(--border); padding-top: 16px; }
|
||||
.ae-followup {
|
||||
display: flex; align-items: center; justify-content: space-between; width: 100%;
|
||||
padding: 12px 4px; background: none; border: none; border-bottom: 1px solid var(--border);
|
||||
color: var(--text); font-family: inherit; font-size: 14px; text-align: left; cursor: pointer;
|
||||
}
|
||||
.ae-followup:hover { color: var(--text); }
|
||||
.ae-followup:hover .ae-followup-arrow { color: var(--text); transform: translateX(2px); }
|
||||
.ae-followup-arrow { color: var(--text-3); transition: transform 0.12s ease; }
|
||||
|
||||
.ae-error { color: var(--text-2); padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
|
||||
.ae-auth { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; padding: 8px 0; }
|
||||
.ae-auth p { margin: 0; color: var(--text-2); font-size: 15px; }
|
||||
|
||||
.ae-answer-composer { padding: 12px 28px 20px; border-top: 1px solid var(--border); background: var(--bg); }
|
||||
.ae-answer-composer > .ae-card { max-width: 760px; margin: 0 auto; }
|
||||
|
||||
/* ── Spinner ─────────────────────────────────────────────── */
|
||||
.ae-spinner {
|
||||
width: 14px; height: 14px; border-radius: 50%; display: inline-block;
|
||||
border: 2px solid var(--border-2); border-top-color: var(--text);
|
||||
animation: ae-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes ae-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ae-root, .ae-root.collapsed { grid-template-columns: 1fr; }
|
||||
.ae-sidebar { display: none; }
|
||||
.ae-news-grid { grid-template-columns: 1fr; }
|
||||
.ae-hero { gap: 20px; padding: 20px 16px; justify-content: flex-start; padding-top: 48px; }
|
||||
.ae-wordmark { font-size: 34px; }
|
||||
.ae-answer-scroll { padding: 20px 16px 16px; }
|
||||
.ae-answer-composer { padding: 10px 16px 16px; }
|
||||
.ae-question { font-size: 21px; }
|
||||
/* Input card: let the control row wrap so the model picker + send stay
|
||||
reachable at narrow widths (mobile-first). */
|
||||
.ae-card-row { flex-direction: column; align-items: stretch; gap: 10px; }
|
||||
.ae-card-right { justify-content: space-between; }
|
||||
.ae-modes { flex: 1; justify-content: space-between; }
|
||||
.ae-source { flex-basis: 150px; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hanzo</title>
|
||||
<link rel="stylesheet" href="newtab.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="newtab.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
// Hanzo Answer Engine — new-tab landing + omnibox / address-bar search target.
|
||||
// Mounts the shared AnswerEngine React surface; talks to api.hanzo.ai ONLY,
|
||||
// through the @hanzo/ai `search` primitive. Auth is brokered by the background
|
||||
// (same bridge the popup/sidebar use) so the token never lives in the page.
|
||||
import 'webextension-polyfill';
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { AnswerEngine } from './answer/AnswerEngine.js';
|
||||
import { API_BASE_URL } from './shared/config.js';
|
||||
|
||||
declare const browser: typeof chrome;
|
||||
const runtime: typeof chrome = (globalThis as any).browser ?? (globalThis as any).chrome;
|
||||
|
||||
/** Resolve the IAM bearer via the background broker (null when signed out). */
|
||||
async function getToken(): Promise<string | null> {
|
||||
try {
|
||||
const resp: any = await runtime.runtime.sendMessage({ action: 'auth.getToken' });
|
||||
return resp?.token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function signIn(): void {
|
||||
runtime.runtime.sendMessage({ action: 'auth.login' }).catch(() => {});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const initialQuery = params.get('q') ?? undefined;
|
||||
|
||||
const el = document.getElementById('root');
|
||||
if (!el) return;
|
||||
createRoot(el).render(
|
||||
React.createElement(AnswerEngine, {
|
||||
apiBase: API_BASE_URL,
|
||||
getToken,
|
||||
signIn,
|
||||
...(initialQuery ? { initialQuery } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
:root {
|
||||
--bg: #0b0b0c;
|
||||
--card: #131315;
|
||||
--line: #26262a;
|
||||
--text: #f4f4f5;
|
||||
--muted: #9a9aa2;
|
||||
--accent: #f4f4f5;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
body { display: grid; place-items: center; padding: 32px; }
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
}
|
||||
.brand { font-weight: 700; letter-spacing: 0.02em; opacity: 0.7; margin-bottom: 20px; }
|
||||
h1 { font-size: 24px; margin: 0 0 8px; }
|
||||
.lede { color: var(--muted); margin: 0 0 24px; }
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
}
|
||||
.row input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.switch {
|
||||
flex: 0 0 auto;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: #3a3a40;
|
||||
position: relative;
|
||||
transition: background 0.15s;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.switch::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.row input:checked + .switch { background: var(--accent); }
|
||||
.row input:checked + .switch::after { transform: translateX(16px); background: #0b0b0c; }
|
||||
.text { display: flex; flex-direction: column; gap: 2px; }
|
||||
.text strong { font-weight: 600; }
|
||||
.text small { color: var(--muted); }
|
||||
.cta {
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--accent);
|
||||
color: #0b0b0c;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cta:active { transform: translateY(1px); }
|
||||
.fine { color: var(--muted); font-size: 12px; text-align: center; margin: 16px 0 0; }
|
||||
.fine a { color: var(--text); }
|
||||
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Welcome to Hanzo</title>
|
||||
<link rel="stylesheet" href="onboarding.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<div class="brand">Hanzo</div>
|
||||
<h1>Welcome to Hanzo AI</h1>
|
||||
<p class="lede">
|
||||
Search, research and browse with Hanzo's own models. Before you start,
|
||||
choose what you'd like to share — you can change this anytime in settings
|
||||
or on your Hanzo account.
|
||||
</p>
|
||||
|
||||
<label class="row">
|
||||
<input type="checkbox" id="ob-insights" checked />
|
||||
<span class="switch"></span>
|
||||
<span class="text">
|
||||
<strong>Anonymous usage insights</strong>
|
||||
<small>Helps us improve Hanzo. No search text, no personal data — just a random per-install id.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
<input type="checkbox" id="ob-training" />
|
||||
<span class="switch"></span>
|
||||
<span class="text">
|
||||
<strong>Contribute to training Hanzo's open models</strong>
|
||||
<small>Share your own searches & answers so Hanzo's open models get better. Opt in — off unless you turn it on.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button id="ob-continue" class="cta">Get started</button>
|
||||
<p class="fine">
|
||||
Signed in? These choices save to your Hanzo account and stay in sync
|
||||
everywhere. <a href="https://hanzo.ai/privacy" target="_blank" rel="noopener">Privacy</a>
|
||||
</p>
|
||||
</main>
|
||||
<script src="onboarding.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
// onboarding.ts — the new-install consent ask. Opened once by the background on
|
||||
// install. Records the user's choice as their consent (account-canonical when
|
||||
// signed in — setConsent write-throughs to /v1/iam/consent), marks onboarding
|
||||
// done so it never reopens, and closes.
|
||||
import 'webextension-polyfill';
|
||||
import { setConsent, markOnboarded, capture } from './shared/consent.js';
|
||||
|
||||
const runtime: typeof chrome = (globalThis as any).browser ?? (globalThis as any).chrome;
|
||||
|
||||
const insights = document.getElementById('ob-insights') as HTMLInputElement | null;
|
||||
const training = document.getElementById('ob-training') as HTMLInputElement | null;
|
||||
const cta = document.getElementById('ob-continue') as HTMLButtonElement | null;
|
||||
|
||||
cta?.addEventListener('click', async () => {
|
||||
const consent = {
|
||||
insights: insights?.checked ?? true,
|
||||
shareTraining: training?.checked ?? false,
|
||||
};
|
||||
await setConsent(consent);
|
||||
await markOnboarded();
|
||||
await capture('onboarding_complete', { shareTraining: consent.shareTraining });
|
||||
try {
|
||||
const tab = await runtime.tabs.getCurrent();
|
||||
if (tab?.id) await runtime.tabs.remove(tab.id);
|
||||
else window.close();
|
||||
} catch {
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
@@ -57,6 +57,18 @@
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
|
||||
<!-- Embedded search: run a query through the Hanzo answer engine. -->
|
||||
<form id="search-form" style="display:flex;gap:6px;margin-bottom:10px;">
|
||||
<input id="search-input" type="text" placeholder="Search Hanzo AI…" autocomplete="off"
|
||||
style="flex:1;min-width:0;padding:9px 12px;background:var(--surface-light,#171717);border:1px solid var(--border,rgba(255,255,255,0.12));border-radius:8px;color:var(--text,#fafafa);font-size:13px;font-family:inherit;outline:none;">
|
||||
<button id="search-btn" type="submit" title="Search" aria-label="Search"
|
||||
style="width:36px;height:36px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--primary,#fafafa);border:none;border-radius:8px;color:#000;cursor:pointer;">
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="7" cy="7" r="4.5"/><path d="M11 11l3.5 3.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 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">
|
||||
@@ -96,6 +108,22 @@
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- AI Usage (Claude + Codex, from the current browser session) -->
|
||||
<div class="usage-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<h3>AI Usage</h3>
|
||||
<button id="usage-refresh" class="btn-icon" title="Refresh usage">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M23 4v6h-6M1 20v-6h6"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="usage-list" class="usage-list" style="display:flex;flex-direction:column;gap:8px;"></div>
|
||||
<a href="https://console.hanzo.ai/ai-accounts" target="_blank"
|
||||
style="display:inline-block;margin-top:8px;font-size:11px;color:var(--text-muted,#888);">Manage AI accounts →</a>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Tools -->
|
||||
<h3>Tools</h3>
|
||||
<div class="tools-grid">
|
||||
@@ -194,6 +222,34 @@
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Privacy & data -->
|
||||
<h3>Privacy & data</h3>
|
||||
<div class="features">
|
||||
<div class="feature-toggle">
|
||||
<label>
|
||||
<input type="checkbox" id="insights-enabled" checked>
|
||||
<span class="toggle"></span>
|
||||
<div class="feature-info">
|
||||
<strong>Usage insights</strong>
|
||||
<small>Anonymous, helps improve Hanzo. Never your search text.</small>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="feature-toggle">
|
||||
<label>
|
||||
<input type="checkbox" id="share-training">
|
||||
<span class="toggle"></span>
|
||||
<div class="feature-info">
|
||||
<strong>Contribute to training</strong>
|
||||
<small>Opt in to share your searches & answers to train Hanzo's open models. Off by default.</small>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Browser Backend -->
|
||||
<div class="setting-group backend-picker">
|
||||
<h3>Backend</h3>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
shortcutFromEvent,
|
||||
type Shortcut,
|
||||
} from './shared/shortcut.js';
|
||||
import { getConsent, setConsent, capture } from './shared/consent.js';
|
||||
|
||||
declare const browser: typeof chrome & {
|
||||
sidebarAction?: {
|
||||
@@ -134,6 +135,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Embedded search (feature: query → Hanzo answer engine) ---
|
||||
const searchForm = document.getElementById('search-form') as HTMLFormElement | null;
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement | null;
|
||||
searchForm?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const q = searchInput?.value.trim();
|
||||
const target = q
|
||||
? chrome.runtime.getURL('newtab.html') + '?q=' + encodeURIComponent(q)
|
||||
: chrome.runtime.getURL('newtab.html');
|
||||
chrome.tabs.create({ url: target });
|
||||
window.close();
|
||||
});
|
||||
|
||||
// --- Open chat ---
|
||||
// Default surface: in-page edge-pinned overlay (content-script). Honours
|
||||
// the user's `overlayEnabled`, `overlaySide`, and `overlayWidth` prefs.
|
||||
@@ -216,6 +230,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
syncSettingsUi(enabled, side, width);
|
||||
});
|
||||
|
||||
// Privacy & data toggles — reflect stored consent, persist on change.
|
||||
const insightsCheckbox = document.getElementById('insights-enabled') as HTMLInputElement | null;
|
||||
const shareTrainingCheckbox = document.getElementById('share-training') as HTMLInputElement | null;
|
||||
getConsent().then((c) => {
|
||||
if (insightsCheckbox) insightsCheckbox.checked = c.insights;
|
||||
if (shareTrainingCheckbox) shareTrainingCheckbox.checked = c.shareTraining;
|
||||
});
|
||||
insightsCheckbox?.addEventListener('change', (e) => {
|
||||
void setConsent({ insights: (e.target as HTMLInputElement).checked });
|
||||
});
|
||||
shareTrainingCheckbox?.addEventListener('change', (e) => {
|
||||
void setConsent({ shareTraining: (e.target as HTMLInputElement).checked });
|
||||
});
|
||||
void capture('popup_opened');
|
||||
|
||||
function isInjectableTab(tab: chrome.tabs.Tab | undefined): boolean {
|
||||
if (!tab || !tab.id) return false;
|
||||
const url = tab.url || '';
|
||||
@@ -693,6 +722,90 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- AI Usage (Claude + Codex) — data fetched by the background context
|
||||
// where host permissions attach the live claude.ai / chatgpt.com cookies. ---
|
||||
const usageList = document.getElementById('usage-list');
|
||||
const usageRefresh = document.getElementById('usage-refresh');
|
||||
|
||||
interface UsageWindow { usedPercent: number; resetsAt?: string }
|
||||
interface UsageSnapshotLite {
|
||||
primary?: UsageWindow;
|
||||
secondary?: UsageWindow;
|
||||
identity?: { plan?: string };
|
||||
}
|
||||
interface ProviderUsageLite {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
color?: string;
|
||||
snapshot?: UsageSnapshotLite;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/[&<>"]/g, (c) => (({ '&': '&', '<': '<', '>': '>', '"': '"' }) as Record<string, string>)[c]);
|
||||
|
||||
function resetLabel(iso?: string): string {
|
||||
if (!iso) return '';
|
||||
const ms = new Date(iso).getTime() - Date.now();
|
||||
if (!Number.isFinite(ms)) return '';
|
||||
if (ms <= 0) return 'resets now';
|
||||
const m = Math.round(ms / 60000);
|
||||
if (m < 60) return `resets in ${m}m`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 24) return `resets in ${h}h`;
|
||||
return `resets in ${Math.round(h / 24)}d`;
|
||||
}
|
||||
|
||||
function lane(label: string, w: UsageWindow | undefined, color?: string): string {
|
||||
if (!w || typeof w.usedPercent !== 'number') return '';
|
||||
const pct = Math.max(0, Math.min(100, Math.round(w.usedPercent)));
|
||||
return `
|
||||
<div class="usage-lane" style="display:flex;align-items:center;gap:8px;font-size:11px;margin-top:4px;">
|
||||
<span style="width:52px;color:var(--text-muted,#888);">${label}</span>
|
||||
<span style="flex:1;height:6px;border-radius:3px;background:var(--surface-2,#222);overflow:hidden;">
|
||||
<span style="display:block;height:100%;width:${pct}%;background:${color || 'var(--accent,#d97757)'};"></span>
|
||||
</span>
|
||||
<span style="width:34px;text-align:right;">${pct}%</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderUsage(providers: ProviderUsageLite[]) {
|
||||
if (!usageList) return;
|
||||
usageList.innerHTML = providers
|
||||
.map((p) => {
|
||||
const body = p.error
|
||||
? `<div style="font-size:11px;color:var(--text-muted,#888);margin-top:4px;">${esc(p.error)}</div>`
|
||||
: `${lane('Session', p.snapshot?.primary, p.color)}${lane('Weekly', p.snapshot?.secondary, p.color)}
|
||||
<div style="font-size:10px;color:var(--text-muted,#888);margin-top:3px;">${esc(resetLabel(p.snapshot?.primary?.resetsAt))}</div>`;
|
||||
const plan = p.snapshot?.identity?.plan;
|
||||
return `
|
||||
<div class="usage-row" style="padding:8px;border:1px solid var(--border,#2a2a2a);border-radius:8px;">
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:${p.color || '#888'};"></span>
|
||||
<strong style="font-size:12px;">${esc(p.displayName)}</strong>
|
||||
${plan ? `<span style="font-size:10px;color:var(--text-muted,#888);">${esc(plan)}</span>` : ''}
|
||||
</div>
|
||||
${body}
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function loadUsage() {
|
||||
if (!usageList) return;
|
||||
usageList.innerHTML = '<div style="font-size:11px;color:var(--text-muted,#888);">Loading…</div>';
|
||||
chrome.runtime.sendMessage({ action: 'usage.fetch' }, (resp) => {
|
||||
if (chrome.runtime.lastError || !resp?.success) {
|
||||
usageList.innerHTML = '<div style="font-size:11px;color:var(--text-muted,#888);">Usage unavailable</div>';
|
||||
return;
|
||||
}
|
||||
renderUsage(resp.providers as ProviderUsageLite[]);
|
||||
});
|
||||
}
|
||||
|
||||
usageRefresh?.addEventListener('click', loadUsage);
|
||||
loadUsage();
|
||||
|
||||
// Init
|
||||
checkAuth();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,185 @@
|
||||
// consent.ts — ONE data-consent value, one source of truth.
|
||||
//
|
||||
// The user's data-sharing consent lives on their Hanzo ACCOUNT (asked at
|
||||
// hanzo.id signup, editable in the extension + on hanzo.ai). This module keeps
|
||||
// the extension in lockstep with that one value:
|
||||
// • Signed in → the account is canonical. getConsent() reads it from
|
||||
// GET /v1/iam/consent; setConsent() writes it with PUT /v1/iam/consent.
|
||||
// The local copy is just a cache so the UI paints instantly.
|
||||
// • Signed out → the local copy (from onboarding) is used, and is pushed to
|
||||
// the account on next sign-in so nothing is lost or duplicated.
|
||||
//
|
||||
// TWO switches:
|
||||
// • insights (default ON) — anonymous product events. No query/answer
|
||||
// text, no PII: only which surface fired, under a random per-install id.
|
||||
// • shareTraining (OPT-IN) — contribute the user's OWN searches + answers
|
||||
// to train Hanzo's open models. Asked explicitly at signup/onboarding.
|
||||
//
|
||||
// Everything is fail-soft: a network error NEVER breaks a user action, and
|
||||
// nothing is sent for a switch that is off.
|
||||
import { API_BASE_URL } from './config.js';
|
||||
|
||||
const KEYS = {
|
||||
insights: 'hanzo_insights_enabled',
|
||||
training: 'hanzo_share_training',
|
||||
onboarded: 'hanzo_onboarded',
|
||||
anonId: 'hanzo_anon_id',
|
||||
} as const;
|
||||
|
||||
export interface Consent {
|
||||
insights: boolean;
|
||||
shareTraining: boolean;
|
||||
}
|
||||
|
||||
const runtime: typeof chrome | undefined =
|
||||
(globalThis as any).browser ?? (globalThis as any).chrome;
|
||||
|
||||
function area(): chrome.storage.StorageArea | undefined {
|
||||
return (globalThis as any).chrome?.storage?.local ?? (globalThis as any).browser?.storage?.local;
|
||||
}
|
||||
|
||||
function getLocal(keys: string[]): Promise<Record<string, any>> {
|
||||
return new Promise((resolve) => {
|
||||
const s = area();
|
||||
if (!s) return resolve({});
|
||||
try {
|
||||
s.get(keys, (v) => resolve(v || {}));
|
||||
} catch {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setLocal(obj: Record<string, any>): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const s = area();
|
||||
if (!s) return resolve();
|
||||
try {
|
||||
s.set(obj, () => resolve());
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Bearer for the signed-in user, brokered by the background (null when out). */
|
||||
async function token(): Promise<string | null> {
|
||||
try {
|
||||
const resp: any = await runtime?.runtime?.sendMessage?.({ action: 'auth.getToken' });
|
||||
return resp?.token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function localConsent(v: Record<string, any>): Consent {
|
||||
return { insights: v[KEYS.insights] !== false, shareTraining: v[KEYS.training] === true };
|
||||
}
|
||||
|
||||
/** Read consent: the account is canonical when signed in; local otherwise. */
|
||||
export async function getConsent(): Promise<Consent> {
|
||||
const local = localConsent(await getLocal([KEYS.insights, KEYS.training]));
|
||||
const tok = await token();
|
||||
if (!tok) return local;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/v1/iam/consent`, {
|
||||
headers: { authorization: `Bearer ${tok}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const a = await res.json();
|
||||
const acct: Consent = { insights: a.insights !== false, shareTraining: a.shareTraining === true };
|
||||
// Cache so the UI paints instantly next time, keeping one value in sync.
|
||||
await setLocal({ [KEYS.insights]: acct.insights, [KEYS.training]: acct.shareTraining });
|
||||
return acct;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to local */
|
||||
}
|
||||
return local;
|
||||
}
|
||||
|
||||
/** Write consent: local cache + the account (when signed in) — one value. */
|
||||
export async function setConsent(c: Partial<Consent>): Promise<void> {
|
||||
const cur = localConsent(await getLocal([KEYS.insights, KEYS.training]));
|
||||
const next: Consent = {
|
||||
insights: c.insights ?? cur.insights,
|
||||
shareTraining: c.shareTraining ?? cur.shareTraining,
|
||||
};
|
||||
await setLocal({ [KEYS.insights]: next.insights, [KEYS.training]: next.shareTraining });
|
||||
const tok = await token();
|
||||
if (!tok) return;
|
||||
try {
|
||||
await fetch(`${API_BASE_URL}/v1/iam/consent`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${tok}` },
|
||||
body: JSON.stringify(next),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
/* fail-soft */
|
||||
}
|
||||
}
|
||||
|
||||
export async function isOnboarded(): Promise<boolean> {
|
||||
const v = await getLocal([KEYS.onboarded]);
|
||||
return v[KEYS.onboarded] === true;
|
||||
}
|
||||
export async function markOnboarded(): Promise<void> {
|
||||
await setLocal({ [KEYS.onboarded]: true });
|
||||
}
|
||||
|
||||
async function anonId(): Promise<string> {
|
||||
const v = await getLocal([KEYS.anonId]);
|
||||
if (typeof v[KEYS.anonId] === 'string') return v[KEYS.anonId];
|
||||
const id =
|
||||
globalThis.crypto?.randomUUID?.() ??
|
||||
`a-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
await setLocal({ [KEYS.anonId]: id });
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Anonymous product-usage event. No query/answer text, no PII. No-op when
|
||||
* insights is off. Never throws. */
|
||||
export async function capture(event: string, props?: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
const c = await getConsent();
|
||||
if (!c.insights) return;
|
||||
const anon_id = await anonId();
|
||||
await fetch(`${API_BASE_URL}/v1/insights`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ event, props: props ?? {}, anon_id, ts: Date.now(), source: 'extension' }),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
/* fail-soft */
|
||||
}
|
||||
}
|
||||
|
||||
/** Contribute one search+answer sample to train Hanzo's open models. No-op
|
||||
* unless the user opted in. Sent with the IAM bearer so it's the user's own,
|
||||
* attributable data. Never throws. */
|
||||
export async function contributeTrainingSample(sample: {
|
||||
query: string;
|
||||
answer: string;
|
||||
model?: string;
|
||||
sources?: string[];
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const c = await getConsent();
|
||||
if (!c.shareTraining) return;
|
||||
if (!sample.query || !sample.answer) return;
|
||||
const tok = await token();
|
||||
await fetch(`${API_BASE_URL}/v1/training/contributions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(tok ? { authorization: `Bearer ${tok}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ ...sample, source: 'extension:answer-engine', ts: Date.now() }),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
/* fail-soft */
|
||||
}
|
||||
}
|
||||
@@ -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} })()`;
|
||||
}
|
||||
@@ -1,47 +1,49 @@
|
||||
/**
|
||||
* KMS (Key Management / Secrets) client — shared across all browser targets.
|
||||
*
|
||||
* Lightweight fetch-based client for Hanzo KMS (kms.hanzo.ai).
|
||||
* Uses IAM bearer tokens for auth — no separate KMS credentials needed.
|
||||
* Lightweight fetch-based client for Hanzo KMS. 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
|
||||
* `${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`
|
||||
* so the day the gateway adds the rewrite we update one constant.
|
||||
* Every path is `/v1/kms/...`, which is the Hanzo `/v1/<service>/<endpoint>`
|
||||
* convention and, more importantly, what the server actually serves:
|
||||
*
|
||||
* GET {KMS_PATH_PREFIX}/secrets/raw → list
|
||||
* GET {KMS_PATH_PREFIX}/secrets/raw/:name → get
|
||||
* POST {KMS_PATH_PREFIX}/secrets/raw/:name → create
|
||||
* PATCH {KMS_PATH_PREFIX}/secrets/raw/:name → update
|
||||
* DELETE {KMS_PATH_PREFIX}/secrets/raw/:name → delete
|
||||
* GET /v1/kms/orgs/{org}/secrets?path=&env= → list names
|
||||
* GET /v1/kms/orgs/{org}/secrets/{path}/{name}?env= → read one value
|
||||
* POST /v1/kms/orgs/{org}/secrets → create or replace
|
||||
* DELETE /v1/kms/orgs/{org}/secrets/{path}/{name}?env= → delete
|
||||
*
|
||||
* This file used to speak Infisical's shape under an `/api/v3` prefix, with a
|
||||
* header comment reporting that `/v1/kms/...` had been *verified* to return SPA
|
||||
* HTML while `/api/v3/secrets/raw` returned JSON — so `/api/v3` was pinned as
|
||||
* the working path and a rewrite was awaited. The observation was real and the
|
||||
* conclusion was backwards: the KMS binary embedded a console SPA under a root
|
||||
* catch-all, so it answered EVERY unmatched path with 200 text/html. The HTML
|
||||
* was the 404. luxfi/kms 1.12.8 removed the catch-all, and the same probe now
|
||||
* reads the other way round — `/v1/kms/...` answers JSON, `/api/v3/...` is a
|
||||
* JSON 404. There is no gateway rewrite to wait for.
|
||||
*
|
||||
* Create and update are ONE call because the server has one upsert, not two
|
||||
* endpoints. Listing arrived in luxfi/kms 1.12.9; before that the HTTP surface
|
||||
* had no list at all, which is what made the Infisical prefix look necessary.
|
||||
*/
|
||||
|
||||
import { KMS_API_URL } from './config.js';
|
||||
|
||||
/** Prefix for every KMS request. See file header. */
|
||||
const KMS_PATH_PREFIX = '/api/v3';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface KmsSecret {
|
||||
secretKey: string;
|
||||
secretValue?: string;
|
||||
version?: number;
|
||||
type?: string;
|
||||
environment?: string;
|
||||
secretPath?: string;
|
||||
/** Where a secret lives. `path` groups secrets; `name` identifies one. */
|
||||
export interface KmsRef {
|
||||
/** Org scope — also the scope of the bearer token that reads it. */
|
||||
org: string;
|
||||
/** Environment slug (`dev` / `test` / `main`). Defaults to `default`. */
|
||||
env?: string;
|
||||
/** Grouping path, e.g. `gateway`. Omit to address the org root. */
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface KmsListParams {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
secretPath?: string;
|
||||
}
|
||||
|
||||
export interface KmsSecretParams extends KmsListParams {
|
||||
secretName: string;
|
||||
/** A ref plus the name of a single secret. */
|
||||
export interface KmsSecretRef extends KmsRef {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
@@ -57,121 +59,94 @@ function kmsUrl(path: string, baseUrl?: string): string {
|
||||
return `${baseUrl || KMS_API_URL}${path}`;
|
||||
}
|
||||
|
||||
function buildQuery(params: Record<string, string | undefined>): string {
|
||||
const entries = Object.entries(params).filter(([, v]) => v !== undefined) as [string, string][];
|
||||
if (!entries.length) return '';
|
||||
return '?' + new URLSearchParams(entries).toString();
|
||||
/** The collection URL for an org, with `path`/`env` as query. */
|
||||
function collectionUrl(ref: KmsRef, baseUrl?: string): string {
|
||||
const q = new URLSearchParams();
|
||||
if (ref.path) q.set('path', ref.path);
|
||||
q.set('env', ref.env || 'default');
|
||||
return kmsUrl(`/v1/kms/orgs/${encodeURIComponent(ref.org)}/secrets?${q}`, baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL of one secret. The server splits the trailing `{rest...}` at its LAST
|
||||
* slash into (path, name), so each segment is escaped on its own — escaping the
|
||||
* joined string would encode the separators and the server would read one long
|
||||
* name.
|
||||
*/
|
||||
function secretUrl(ref: KmsSecretRef, baseUrl?: string): string {
|
||||
const segs = [...(ref.path ? ref.path.split('/') : []), ref.name]
|
||||
.filter((s) => s.length > 0)
|
||||
.map(encodeURIComponent)
|
||||
.join('/');
|
||||
const q = new URLSearchParams({ env: ref.env || 'default' });
|
||||
return kmsUrl(`/v1/kms/orgs/${encodeURIComponent(ref.org)}/secrets/${segs}?${q}`, baseUrl);
|
||||
}
|
||||
|
||||
async function fail(op: string, resp: Response): Promise<never> {
|
||||
throw new Error(`KMS ${op} failed: ${resp.status} ${await resp.text()}`);
|
||||
}
|
||||
|
||||
// ── API Functions ───────────────────────────────────────────────────────
|
||||
|
||||
/** List secrets in a workspace/environment */
|
||||
/** List the names of the secrets under a path. */
|
||||
export async function kmsListSecrets(
|
||||
token: string,
|
||||
params: KmsListParams,
|
||||
ref: KmsRef,
|
||||
baseUrl?: string,
|
||||
): Promise<KmsSecret[]> {
|
||||
const query = buildQuery({
|
||||
workspaceId: params.workspaceId,
|
||||
environment: params.environment,
|
||||
secretPath: params.secretPath,
|
||||
});
|
||||
const resp = await fetch(kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw${query}`, baseUrl), {
|
||||
headers: kmsHeaders(token),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`KMS list failed: ${resp.status} ${await resp.text()}`);
|
||||
): Promise<string[]> {
|
||||
const resp = await fetch(collectionUrl(ref, baseUrl), { headers: kmsHeaders(token) });
|
||||
if (!resp.ok) return fail('list', resp);
|
||||
const data = await resp.json();
|
||||
return data.secrets || data;
|
||||
return data.names ?? [];
|
||||
}
|
||||
|
||||
/** Get a single secret by name */
|
||||
/** Read one secret's value. */
|
||||
export async function kmsGetSecret(
|
||||
token: string,
|
||||
params: KmsSecretParams,
|
||||
ref: KmsSecretRef,
|
||||
baseUrl?: string,
|
||||
): Promise<KmsSecret> {
|
||||
const query = buildQuery({
|
||||
workspaceId: params.workspaceId,
|
||||
environment: params.environment,
|
||||
secretPath: params.secretPath,
|
||||
});
|
||||
const resp = await fetch(
|
||||
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}${query}`, baseUrl),
|
||||
{ headers: kmsHeaders(token) },
|
||||
);
|
||||
if (!resp.ok) throw new Error(`KMS get failed: ${resp.status} ${await resp.text()}`);
|
||||
): Promise<string> {
|
||||
const resp = await fetch(secretUrl(ref, baseUrl), { headers: kmsHeaders(token) });
|
||||
if (!resp.ok) return fail('get', resp);
|
||||
const data = await resp.json();
|
||||
return data.secret || data;
|
||||
return data.secret?.value ?? '';
|
||||
}
|
||||
|
||||
/** Create a new secret */
|
||||
export async function kmsCreateSecret(
|
||||
/**
|
||||
* Create a secret, or replace it if the name already exists. One call, because
|
||||
* the server has one upsert — there is no separate create and update.
|
||||
*/
|
||||
export async function kmsPutSecret(
|
||||
token: string,
|
||||
params: KmsSecretParams,
|
||||
ref: KmsSecretRef,
|
||||
value: string,
|
||||
baseUrl?: string,
|
||||
): Promise<KmsSecret> {
|
||||
): Promise<void> {
|
||||
const resp = await fetch(
|
||||
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
|
||||
kmsUrl(`/v1/kms/orgs/${encodeURIComponent(ref.org)}/secrets`, baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: kmsHeaders(token),
|
||||
body: JSON.stringify({
|
||||
workspaceId: params.workspaceId,
|
||||
environment: params.environment,
|
||||
secretPath: params.secretPath,
|
||||
secretValue: value,
|
||||
type: 'shared',
|
||||
path: ref.path ?? '',
|
||||
name: ref.name,
|
||||
env: ref.env || 'default',
|
||||
value,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`KMS create failed: ${resp.status} ${await resp.text()}`);
|
||||
const data = await resp.json();
|
||||
return data.secret || data;
|
||||
if (!resp.ok) return fail('put', resp);
|
||||
}
|
||||
|
||||
/** Update an existing secret */
|
||||
export async function kmsUpdateSecret(
|
||||
token: string,
|
||||
params: KmsSecretParams,
|
||||
value: string,
|
||||
baseUrl?: string,
|
||||
): Promise<KmsSecret> {
|
||||
const resp = await fetch(
|
||||
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: kmsHeaders(token),
|
||||
body: JSON.stringify({
|
||||
workspaceId: params.workspaceId,
|
||||
environment: params.environment,
|
||||
secretPath: params.secretPath,
|
||||
secretValue: value,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`KMS update failed: ${resp.status} ${await resp.text()}`);
|
||||
const data = await resp.json();
|
||||
return data.secret || data;
|
||||
}
|
||||
|
||||
/** Delete a secret */
|
||||
/** Delete a secret. */
|
||||
export async function kmsDeleteSecret(
|
||||
token: string,
|
||||
params: KmsSecretParams,
|
||||
ref: KmsSecretRef,
|
||||
baseUrl?: string,
|
||||
): Promise<void> {
|
||||
const resp = await fetch(
|
||||
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: kmsHeaders(token),
|
||||
body: JSON.stringify({
|
||||
workspaceId: params.workspaceId,
|
||||
environment: params.environment,
|
||||
secretPath: params.secretPath,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`KMS delete failed: ${resp.status} ${await resp.text()}`);
|
||||
const resp = await fetch(secretUrl(ref, baseUrl), {
|
||||
method: 'DELETE',
|
||||
headers: kmsHeaders(token),
|
||||
});
|
||||
if (!resp.ok) return fail('delete', resp);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,11 @@ export interface NativeZapState {
|
||||
|
||||
export type Dispatch = (method: string, params: any) => Promise<any> | any;
|
||||
|
||||
const RECONNECT_MS = 3000;
|
||||
// Gentle safe poll: with no MCP client running there is no router to reach, so
|
||||
// the host connects-then-exits; probe every 30s instead of hammering (and
|
||||
// re-launching the native host) every 3s. A live consumer is picked up within
|
||||
// one interval.
|
||||
const RECONNECT_MS = 30_000;
|
||||
|
||||
/** Connect to zapd via the native host, register as a browser provider, and
|
||||
* dispatch inbound ROUTE commands to the browser. Self-heals on disconnect. */
|
||||
@@ -77,6 +81,13 @@ export function connectNativeZap(
|
||||
// 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');
|
||||
@@ -125,7 +136,9 @@ export function connectNativeZap(
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Usage engine bridge — runs the @hanzo/usage providers inside the extension
|
||||
// background context, where host permissions let fetch() attach the user's
|
||||
// claude.ai / chatgpt.com session cookies (credentials: 'include') and bypass
|
||||
// CORS. There is no filesystem in a browser, so file ops are no-ops and the
|
||||
// OAuth/CLI strategies simply report "unavailable": the web strategy carries
|
||||
// Claude, and a small local fetch carries Codex (whose provider only ships an
|
||||
// auth.json OAuth lane, unusable without disk access).
|
||||
|
||||
import {
|
||||
runPipeline,
|
||||
claudeProvider,
|
||||
codexProvider,
|
||||
type UsageHost,
|
||||
type HttpRequest,
|
||||
type HttpResponse,
|
||||
type UsageSnapshot,
|
||||
type RateWindow,
|
||||
type ProviderFetchOutcome,
|
||||
} from '@hanzo/usage';
|
||||
|
||||
/** Serialisable per-provider result sent to the popup. */
|
||||
export interface ProviderUsage {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
color?: string;
|
||||
dashboardUrl?: string;
|
||||
snapshot?: UsageSnapshot;
|
||||
/** User-facing failure reason (e.g. "Open chatgpt.com to sign in"). */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const http = async (req: HttpRequest): Promise<HttpResponse> => {
|
||||
const controller = new AbortController();
|
||||
const timeout = req.timeoutMs
|
||||
? setTimeout(() => controller.abort(), req.timeoutMs)
|
||||
: undefined;
|
||||
try {
|
||||
const res = await fetch(req.url, {
|
||||
method: req.method ?? 'GET',
|
||||
headers: req.headers,
|
||||
body: req.body,
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
});
|
||||
const headers: Record<string, string> = {};
|
||||
res.headers.forEach((v, k) => {
|
||||
headers[k] = v;
|
||||
});
|
||||
return { status: res.status, headers, text: await res.text() };
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
/** Browser host: network only; no fs, env, or home directory in an extension. */
|
||||
const browserHost: UsageHost = {
|
||||
readTextFile: async () => undefined,
|
||||
listDir: async () => [],
|
||||
writeTextFile: async () => {},
|
||||
http,
|
||||
env: () => undefined,
|
||||
homeDir: () => '',
|
||||
now: () => new Date(),
|
||||
};
|
||||
|
||||
const isAuthError = (text: string): boolean => /\b(401|403)\b/.test(text);
|
||||
|
||||
const outcomeError = (providerHost: string, outcome: ProviderFetchOutcome): string => {
|
||||
const attempt = [...outcome.attempts].reverse().find((a) => a.error);
|
||||
const detail = attempt?.error ?? 'no data';
|
||||
return isAuthError(detail) ? `Open ${providerHost} to sign in` : detail;
|
||||
};
|
||||
|
||||
const fetchClaude = async (): Promise<ProviderUsage> => {
|
||||
const { displayName, color, dashboardUrl } = claudeProvider.metadata;
|
||||
const base: ProviderUsage = { providerId: 'claude', displayName, color, dashboardUrl };
|
||||
try {
|
||||
// Empty cookieHeader passes the web strategy's `typeof === 'string'` gate;
|
||||
// the real session cookie is attached by credentials: 'include'.
|
||||
const outcome = await runPipeline(claudeProvider, {
|
||||
host: browserHost,
|
||||
sourceMode: 'web',
|
||||
settings: { cookieHeader: '' },
|
||||
});
|
||||
if (outcome.result) return { ...base, snapshot: outcome.result.usage };
|
||||
return { ...base, error: outcomeError('claude.ai', outcome) };
|
||||
} catch (e) {
|
||||
return { ...base, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
};
|
||||
|
||||
// Codex wire shape (chatgpt.com/backend-api/wham/usage) — mirrors the private
|
||||
// interface in @hanzo/usage's codex provider, mapped onto the package's public
|
||||
// UsageSnapshot / RateWindow types.
|
||||
interface CodexWindow {
|
||||
used_percent?: number;
|
||||
reset_at?: number;
|
||||
limit_window_seconds?: number;
|
||||
}
|
||||
interface CodexUsageResponse {
|
||||
plan_type?: string;
|
||||
rate_limit?: { primary_window?: CodexWindow; secondary_window?: CodexWindow };
|
||||
}
|
||||
|
||||
const toWindow = (w: CodexWindow | undefined): RateWindow | undefined => {
|
||||
if (!w || typeof w.used_percent !== 'number') return undefined;
|
||||
return {
|
||||
usedPercent: w.used_percent,
|
||||
windowMinutes: w.limit_window_seconds ? Math.round(w.limit_window_seconds / 60) : undefined,
|
||||
resetsAt: w.reset_at ? new Date(w.reset_at * 1000).toISOString() : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const fetchCodex = async (): Promise<ProviderUsage> => {
|
||||
const { displayName, color, dashboardUrl } = codexProvider.metadata;
|
||||
const base: ProviderUsage = { providerId: 'codex', displayName, color, dashboardUrl };
|
||||
try {
|
||||
const res = await http({
|
||||
url: 'https://chatgpt.com/backend-api/wham/usage',
|
||||
headers: { Accept: 'application/json' },
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
return {
|
||||
...base,
|
||||
error: isAuthError(String(res.status))
|
||||
? 'Open chatgpt.com to sign in'
|
||||
: `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
const body = JSON.parse(res.text) as CodexUsageResponse;
|
||||
const snapshot: UsageSnapshot = {
|
||||
providerId: 'codex',
|
||||
primary: toWindow(body.rate_limit?.primary_window),
|
||||
secondary: toWindow(body.rate_limit?.secondary_window),
|
||||
identity: { providerId: 'codex', plan: body.plan_type, loginMethod: 'web' },
|
||||
dataConfidence: 'percentOnly',
|
||||
updatedAt: browserHost.now().toISOString(),
|
||||
};
|
||||
return { ...base, snapshot };
|
||||
} catch (e) {
|
||||
return { ...base, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
};
|
||||
|
||||
/** Fetch Claude + Codex usage in parallel from the current browser session. */
|
||||
export const fetchAllUsage = async (): Promise<ProviderUsage[]> =>
|
||||
Promise.all([fetchClaude(), fetchCodex()]);
|
||||
@@ -9,6 +9,10 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@hanzo/usage": ["../../../../usage/packages/core/dist/index.d.ts"]
|
||||
},
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user