Compare commits

...
137 Commits
Author SHA1 Message Date
Hanzo AI 86bc401545 feat(consent): insights + opt-in training contribution, account-canonical
publish / publish (push) Failing after 32s
ci / build (push) Successful in 27s
ci / test (push) Successful in 28s
- shared/consent.ts: ONE consent value. Signed in → the Hanzo account is the
  source of truth (GET/PUT /v1/iam/consent); signed out → local, pushed on next
  sign-in. Two switches: anonymous usage insights (default on, no query text) +
  opt-in training contribution (share your own searches/answers; off until asked).
- Onboarding on new install (background onInstalled → onboarding.html) explicitly
  ASKS consent; settings toggles in the popup mirror the same value.
- Answer engine instrumented: anonymous answer_search/answer_shown events +
  contributeTrainingSample (no-op unless opted in). All fail-soft.
- release: browser-extension 1.9.36
2026-07-26 11:12:06 -07:00
Hanzo AI a4fd6b1557 release: browser-extension 1.9.35 — answer engine (fixed build path)
ci / build (push) Successful in 26s
publish / publish (push) Failing after 1m11s
ci / test (push) Successful in 41s
Ships the Hanzo AI answer engine (new-tab landing + address-bar search over the
@hanzo/ai search primitive) with the newtab out()-path fix. v1.9.34 was the
pre-fix tag; this is the buildable release.
2026-07-26 10:35:24 -07:00
Hanzo AI a5a8a79a17 fix(build): newtab.js compiles to the out() root, matching the copy step
The answer-engine's esbuild wrote newtab.js to a literal dist/browser-extension/
path while every other bundle + the copy step use out() (=<root>/dist), so the
per-browser copy would miss it. Unify on out('newtab.js'). Build verified: Chrome/
Firefox/Safari bundles + newtab.js produced.
2026-07-26 10:34:30 -07:00
Hanzo AI 9a9b1cacfe fix(build): resolve @hanzo/ai + @hanzo/usage from npm, not sibling paths → 1.9.34
The @hanzo/usage sibling-path alias pointed at /…/usage that doesn't exist on
CI, breaking every release build. Both are now published deps resolved from
node_modules — CI build works. Bump 1.9.33→1.9.34 (1.9.33 store build failed here).
2026-07-26 10:32:50 -07:00
Hanzo AI 054dbd8ae8 feat(answer-engine): Hanzo AI answer engine — new-tab landing + address-bar search
New-tab landing + omnibox/address-bar search + default search provider,
streaming source-cited answers via @hanzo/ai search primitive (api.hanzo.ai),
live news, model switcher from /v1/models, popup embedded search. v1.9.33.

- src/answer/: AnswerEngine (React18) + news + model-catalog + markdown
- newtab.{html,css,ts}; chrome_url_overrides.newtab + search_provider (Chrome);
  omnibox 'hanzo' + background handler (Firefox); popup embedded search
- @hanzo/ai@0.2.3 resolved from npm (lockfile updated)
2026-07-26 10:32:50 -07:00
zeekayandClaude Opus 5 7461549cd6 browser/kms: talk to /v1/kms — the SPA had been lying about the path
This client spoke Infisical under an /api/v3 prefix, and its header comment
explained why: "verified: kms.hanzo.ai/api/v3/secrets/raw -> 401 JSON,
/v1/kms/... -> SPA HTML", so /api/v3 was pinned as the live path and a gateway
rewrite was awaited.

The observation was real and the conclusion was backwards. The KMS binary
embedded a console SPA under a root catch-all and answered EVERY unmatched path
with 200 text/html — the HTML was the 404. So the path that worked looked broken
and the path that did not looked fine. luxfi/kms 1.12.8 removed the catch-all;
the same probe now reads the other way round (/v1/kms/... -> JSON,
/api/v3/... -> JSON 404). There is no rewrite to wait for.

  GET    /v1/kms/orgs/{org}/secrets?path=&env=          -> {"names": []}
  GET    /v1/kms/orgs/{org}/secrets/{path}/{name}?env=  -> {"secret":{"value"}}
  POST   /v1/kms/orgs/{org}/secrets                     {path,name,env,value}
  DELETE /v1/kms/orgs/{org}/secrets/{path}/{name}?env=

Create and update collapse into one kmsPutSecret, because the server has one
upsert rather than two endpoints. Listing is a real call now — the HTTP surface
had no list until luxfi/kms 1.12.9, which is a large part of why the Infisical
prefix looked necessary in the first place.

Params move from Infisical vocabulary (workspaceId/environment/secretPath/
secretName) to the server-s own: KmsRef {org, env, path} and KmsSecretRef adding
name. Only the tests consumed this module, so nothing downstream breaks.

Path segments are escaped one at a time: the server splits {rest...} at its LAST
slash into (path, name), and escaping the joined string would encode the
separators into a single long name. A test pins that, and another asserts no
request may contain /api/ again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:07:35 -07:00
Hanzo AI 69310a0445 ci: .gitea/workflows -> .hanzo/workflows
ci / test (push) Successful in 41s
ci / build (push) Failing after 20s
De-brand the native-CI dir to the Hanzo convention. .hanzo is the highest-
priority WORKFLOW_DIR on git.hanzo.ai and GitHub ignores it exactly as it
ignored .gitea, so this is behavior-neutral on GitHub and canonical on the
native runner.
2026-07-25 18:39:56 -07:00
z 54375ec428 docs: stop describing Hanzo as LiteLLM/OpenRouter-compatible
Standing brand rule: we are not LiteLLM and do not position ourselves as one.
This file did it four times — a heading 'Complete Model Catalog (OpenRouter +
LiteLLM Compatible)', 'every model available through OpenRouter and LiteLLM',
'OpenRouter + LiteLLM = access to every AI model', and '200+ LLMs
(OpenRouter/LiteLLM compatible)' — framing our catalog as someone else's reach
rather than our own API.

Fixed HERE rather than in hanzoai/docs: that repo's content/docs/projects/ tree
is gitignored because it is synced from source repos, so an edit there is
reverted on the next sync. This file is the source.
2026-07-25 18:16:46 -07:00
Hanzo AI 6edd326615 ci: align to Hanzo native forge — Gitea CI/CD + KMS secrets, GitHub → mirror
Git of record moves to git.hanzo.ai; GitHub becomes a mirror.

- .gitea/workflows/{ci,publish}.yml — native pipeline on hanzo-build-linux-amd64
  act_runners. publish.yml pulls Chrome Web Store + Firefox AMO + npm creds from
  KMS via the machine identity (no store cred in any Actions secret store).
- deploy/kms/extension-publish-kms-sync.yaml — KMSSecret CR; canonical KMS path
  hanzo:/extension-publish (env prod) for the store creds.
- .github/workflows/sync.yml — mirror main+tags to git.hanzo.ai (the only thing
  GitHub does as system of record; no-op until HANZO_GIT_TOKEN seeded).
- .github/workflows/publish.yml — remove store-publish steps (CWS/AMO/VSCE/OVSX/
  JetBrains — all no-ops today, those secrets never existed on GitHub). Keep the
  live npm publish (interim) + the downloadable GitHub Release.
- docs: PUBLISHING.md + LLM.md rewritten to the native topology.

Non-breaking: npm publish (@hanzo/browser-extension) untouched. No store publish
in this pass. Gated on operator: seed KMS store values, Gitea secrets, mirror.
2026-07-24 23:33:15 -07:00
hanzo-dev 763e6b8eb0 build(browser): ONE stable output root at <extension-root>/dist
Derive OUT from __dirname (../../../dist) so `node src/build.js` and `pnpm
build` both land bundles at the repo-root dist/ regardless of invocation cwd.
install-linux.sh consumes dist/{firefox,chrome} at that fixed path — the
permanent non-snap dev install (Firefox ESR force-install + Brave
--load-extension) points at it.
2026-07-15 10:01:46 -07:00
hanzo-dev bee346de71 extension: install-linux.sh — permanent non-snap dev install (Firefox ESR policy force-install + Brave --load-extension)
aarch64 has no Google-Chrome build and apt ships only snap redirects for
firefox/chromium, so the reliable non-snap browsers are Firefox ESR (Mozilla
arm64 tarball) and Brave (arm64 apt). Firefox ESR gets an enterprise policy that
force-installs the unsigned XPI into every profile with signature enforcement
disabled — permanent + launch-independent. Brave loads the live dist/chrome via
a --load-extension launcher + desktop entry (rebuild → relaunch = new code, the
correct dev loop). Verified: FF addon active (unsigned), Brave MV3 SW live.
2026-07-15 10:01:46 -07:00
Hanzo Dev c258e74b95 browser: legacy CDP opt-in + gentle native-zap poll
Firefox no longer dials the dead ws://:9223/cdp bridge by default (it
error-looped); it's opt-in via globalThis.HANZO_LEGACY_CDP, matching
Chrome's native-ZAP-only default. And the ZAP native-host reconnect goes
3s -> 30s: with no MCP client there's no router, so hammering (and
re-launching the host) every 3s was pure noise — a live consumer is still
picked up within one interval. Pairs with zapd host connect-only.
2026-07-08 13:12:34 -07:00
Hanzo AI 967297f2dc Usage panel: Claude + Codex session/weekly usage in popup
Add an AI Usage card to the action popup showing Claude and Codex
session/weekly % used + reset time, a refresh button, and a link to
console.hanzo.ai/ai-accounts.

- shared/usage.ts: browser UsageHost (fetch with credentials:'include',
  no-op fs) running the @hanzo/usage engine in the background context,
  where host permissions attach live claude.ai/chatgpt.com cookies and
  bypass CORS. Claude via the package web strategy (empty cookieHeader
  passes its gate; real cookie rides credentials:'include'); Codex via a
  local fetch to /backend-api/wham/usage mapped onto the package's public
  UsageSnapshot/RateWindow types (its provider only ships an auth.json
  OAuth lane, unusable without disk).
- background.ts + background-firefox.ts: usage.fetch message handler.
- popup: AI Usage card + renderer, refresh, accounts link.
- build.js: alias @hanzo/usage to the sibling repo's built ESM for both
  background bundles (same pattern as the @hanzo/gui shim).
- manifests: host_permissions for claude.ai + chatgpt.com (Chrome MV3 +
  Firefox).
- version 1.9.31 -> 1.9.32 (patch).
2026-07-07 15:14:45 -07:00
Hanzo Dev 300337a57b chore(npm): mark hanzo-ai (raycast) + hanzo-ide (vscode) npm-private
npm rejects the unscoped 'hanzo-ai' name (too similar to existing 'hanzoai'), and
both publish to their OWN stores (Raycast Store / VS Marketplace) via dedicated
jobs — they don't belong on npm. Setting private so pnpm -r publish skips them.
2026-07-04 03:45:58 -07:00
Hanzo Dev 8e722f22fe fix(npm): write authToken to .npmrc explicitly for pnpm publish
The recursive publish 404'd on every package (npm's obscured 'unauthenticated'):
setup-node's ${NODE_AUTH_TOKEN}-templated .npmrc is read by npm but not reliably
by pnpm publish. Write the token literally into .npmrc + set @hanzo:registry, and
add npm whoami as an auth diagnostic. .npmrc gitignored.
2026-07-04 03:38:11 -07:00
Hanzo Dev 5a55ce029f chore(npm): publish all workspace packages to public npm at 1.0.0
Normalize every packages/* package for public npm publish:
- version: pre-1.0 (0.x) → 1.0.0; already-≥1.0 kept (mcp 2.4.1,
  cli-tools 1.8.0, dxt 1.8.1, browser/hanzo-ide/office/outlook 1.9.31,
  gworkspace 1.9.30, aci/auth 1.0.0)
- remove "private" from all 22 gated packages/* (apps/site + repo root
  stay private — site is a deployed website, not an npm library)
- add publishConfig.access=public everywhere (scoped @hanzo/* need it)
- add files[] + valid main pointing at built output so no empty tarballs;
  no-build packages ship src+README

CI npm job now publishes the whole workspace:
- build @hanzo/cards first (slack/teams import its dist), then
  pnpm -r --if-present run build || true
- pnpm -r publish --access public --no-git-checks --ignore-scripts
  --ignore-scripts prevents raycast/vscode `publish` lifecycle scripts
  (Raycast Store / VS Marketplace) from hijacking and aborting the run.
  workspace:* (auth, cards) is rewritten to the real version at publish.
2026-07-04 03:26:04 -07:00
Hanzo Dev abb2ad56cb feat(verticals): iManage (@hanzo/imanage) — legal DMS — Wave 5 COMPLETE
iManage Work OAuth2 + Work API (workspaces/documents/search) + content extraction,
AI actions (summarize doc / extract clauses-dates-parties / search-synthesize);
server-side tokens, never logs content, grounded extraction, 'not legal advice'.
121 tests. On published @hanzo/ai@0.2.0.

All 6 Wave 5 verticals now on main: epic(109) procore(102) quickbooks(91)
canvas-lms(119) workday(113) imanage(121). CI step added.
2026-07-04 02:02:39 -07:00
Hanzo Dev 4193d4889e Merge remote-tracking branch 'origin/feat/imanage' 2026-07-04 02:02:14 -07:00
Hanzo Dev 8de63fa05f feat(imanage): Hanzo AI for iManage Work — legal DMS panel + OAuth/Work-API proxy
New package @hanzo/imanage: an embedded-app panel over @hanzo/ai for iManage
Work (the dominant legal document management system), mirroring the
packages/procore OAuth + API-proxy + pure-core shape.

- OAuth2 Authorization Code against the iManage Control Center
  (/auth/oauth2/authorize + /token); server-side token exchange + refresh, the
  client secret never reaches the browser.
- Work API v2 wrappers (workspaces, folder children, document profile +
  content, search, gated profile write-back) scoped by customer + library in
  the path, X-Auth-Token auth, envelope/offset-limit pagination. Pure.
- Pure legal document-context assembly with text extraction (binary/OCR out of
  scope, reported honestly) and one truncation-honest windowing algorithm.
- Four AI actions — summarize document, extract key clauses/dates/parties,
  search-and-synthesize a matter, compare a document set — plus freeform ask,
  over a single grounded ask() path.
- Same-origin proxy keeps tokens + secret server-side; NEVER logs document
  content. Legal confidentiality posture documented in the README.
- 121 vitest tests (config/oauth/api/parse/documents/hanzo/actions/panel),
  tsc --noEmit clean, node build.js green.

Registers packages/imanage in pnpm-workspace.yaml (one line).
2026-07-04 02:01:19 -07:00
Hanzo Dev 02b447d027 feat(verticals): Workday (@hanzo/workday) — HR/HCM
Workday OAuth2 (HTTP Basic client auth, non-rotating refresh) + REST (staffing/
recruiting/absence) + RaaS custom reports, AI actions (summarize worker/team/org,
draft requisition); read-only proxy (405 on writes). 113 tests. On published
@hanzo/ai@0.2.0. CI step added.
2026-07-04 01:56:42 -07:00
Hanzo Dev 96807b05cf Merge remote-tracking branch 'origin/feat/workday' 2026-07-04 01:56:20 -07:00
Hanzo Dev 05a35ac763 feat(workday): Hanzo AI for Workday — read-only HCM app
@hanzo/workday: an embedded-app panel + read-only OAuth + API-proxy service
over Workday people data, built on @hanzo/ai + @hanzo/iam via api.hanzo.ai.
Mirrors packages/procore's pure-core shape.

- OAuth2 Authorization Code + refresh against the tenant token endpoint
  (…/ccx/oauth2/{tenant}/token) with HTTP Basic client auth (secret in the
  header, never the body); non-rotating refresh carried forward.
- Workday REST wrappers (staffing v6 workers/orgs/directReports, recruiting v4
  requisitions, absenceManagement v1 absence types) with limit/offset + the
  { total, data } envelope, plus RaaS custom-report reads ({ Report_Entry }).
- Pure people-context assembly with honest budget/truncation windowing.
- Five HR-guardrailed AI actions: summarize worker, draft job description,
  org & headcount, answer HR question, draft review feedback.
- Read-only by design: the proxy is GET-only (405 otherwise); write-back is
  documented as a gated future addition.
- 113 vitest tests; tsc --noEmit clean; esbuild build green.

Registers packages/workday in pnpm-workspace.yaml.
2026-07-04 01:55:15 -07:00
Hanzo Dev a164395d4a feat(verticals): Canvas LMS (@hanzo/canvas-lms) — education
Canvas OAuth2 (per-institution host, non-rotating refresh) + REST v1 (courses/
assignments/submissions/pages) w/ Link-header pagination, AI actions (summarize
course/draft feedback/generate questions), write-back submission comments +
announcements. 119 tests. On published @hanzo/ai@0.2.0. CI step added.
2026-07-04 01:55:03 -07:00
Hanzo Dev 4670a9ee91 Merge remote-tracking branch 'origin/feat/canvas-lms' 2026-07-04 01:54:36 -07:00
Hanzo Dev a2c014b777 feat(canvas-lms): Hanzo AI for Canvas LMS — OAuth + API-proxy app
@hanzo/canvas-lms: an embedded-app panel + OAuth/API-proxy service over the
Canvas REST API, built on @hanzo/ai (api.hanzo.ai /v1) and @hanzo/iam, mirroring
packages/procore.

- Canvas OAuth2 Authorization-Code (server-side secret + non-rotating refresh)
- Pure Canvas REST v1 wrappers (courses/assignments/submissions/discussions/
  pages/modules/enrollments) + RFC 5988 Link-header pagination + HTML->text
- Course-context windowing with honest truncation
- Five AI actions: summarize course, draft feedback, generate questions,
  summarize submissions, draft announcement; gated write-backs (submission
  comment via PUT, announcement via POST)
- 119 vitest tests; tsc --noEmit clean; esbuild build green
- Registered in pnpm-workspace.yaml
2026-07-04 01:53:47 -07:00
Hanzo Dev fc6d9bb805 feat(verticals): Epic SMART-on-FHIR (@hanzo/epic) — healthcare
SMART-on-FHIR app: EHR/standalone launch, PKCE + server-side token exchange,
FHIR R4 read (Patient/Condition/MedicationRequest/AllergyIntolerance/Observation/
DocumentReference) w/ Bundle pagination, 4 AI actions (summarize patient / draft
note / extract problems&meds / ask). PHI stays server-side, never logged,
read-only. 109 tests. On published @hanzo/ai@0.2.0. CI step added.
2026-07-04 01:48:28 -07:00
Hanzo Dev ad6af1fd72 Merge remote-tracking branch 'origin/feat/epic' 2026-07-04 01:48:09 -07:00
Hanzo Dev 73370ce288 feat(epic): Hanzo AI for Epic (SMART on FHIR)
Read+assist clinical copilot embedded in the EHR via SMART on FHIR. Loads the
launched patient's chart over FHIR R4 (problems, meds, allergies, labs/vitals,
notes) and runs four actions — summarize patient, draft SOAP note, extract
problems & meds, ask — through the api.hanzo.ai gateway via @hanzo/ai@^0.2.0.

PHI posture: the FHIR/PHI access token is confined server-side (server.ts);
the browser holds only an opaque HttpOnly session cookie and a Hanzo gateway
key. No PHI is ever logged; SMART is auth-code + PKCE (S256) with the
confidential secret read from the environment; the proxy is scoped to the six
read resources and the session's own FHIR base (SSRF/cross-tenant guards).
Read + assist only — no clinical write-back, no *.write scope.

Tests: 109 vitest across config/smart-oauth/fhir-client/chart/hanzo/auth/server.
Workspace-wired like every sibling: test/ dir, root pnpm-lock importer, no
package-level lock.
2026-07-04 01:45:59 -07:00
Hanzo Dev a263c67aa2 fix(release): desktop installers to workspace-relative dir (Windows upload)
Windows built both installers (.msi 1.82MB + .exe 1.27MB) but upload-artifact's
path: /tmp/desktop/* didn't match Git-Bash's /tmp on windows-latest → 'No files
were found'. macOS/Linux worked (consistent /tmp). Use a workspace-relative
desktop-artifacts/ dir — identical resolution for the bash shell and the upload
action on all three OSes.
2026-07-04 01:38:50 -07:00
Hanzo Dev bb5528f0df feat(verticals): Procore + QuickBooks — Wave 5 batch 1 (partial)
- @hanzo/procore — construction: Procore OAuth2 + REST v1.0 (RFIs/submittals/docs),
  summarize/draft-RFI/action-items, write-back RFI replies (102 tests).
- @hanzo/quickbooks — finance: Intuit OAuth2 + QBO Accounting v3, summarize
  financials/explain-report/draft-descriptions (91 tests).
Both server-side token exchange + API proxy, on published @hanzo/ai@0.2.0. CI steps added.
2026-07-04 01:32:48 -07:00
Hanzo Dev bf61ccd98a Merge remote-tracking branch 'origin/feat/quickbooks'
# Conflicts:
#	pnpm-workspace.yaml
2026-07-04 01:32:12 -07:00
Hanzo Dev 5603d5695d feat(finance): Hanzo AI for QuickBooks Online (@hanzo/quickbooks)
Intuit OAuth2 + QBO Accounting API v3 (reports/invoices/entities) + AI actions
(summarize financials, explain report, draft line descriptions, categorize
transactions); server-side token exchange + QBO proxy; on published @hanzo/ai@0.2.0.
2026-07-04 01:32:10 -07:00
Hanzo Dev a202069b2f Merge remote-tracking branch 'origin/feat/procore' 2026-07-04 01:31:25 -07:00
Hanzo Dev f52eff1b68 feat(procore): Hanzo AI for Procore — RFI/submittal/doc AI over @hanzo/ai
Adds packages/procore (@hanzo/procore): an embedded-app panel + OAuth/API-proxy
service that brings Hanzo AI to Procore construction projects.

- OAuth2 Authorization-Code (login[-sandbox].procore.com) with server-side
  secret + rotating refresh; tokens never reach the browser.
- Pure Procore REST v1.0 wrappers (projects, RFIs, submittals, documents,
  observations, daily logs) with Procore-Company-Id scoping + pagination.
- Pure project-context assembly with honest truncation.
- AI actions over @hanzo/ai (imported, not reimplemented): summarize RFI,
  draft RFI response, extract action items/risks, project status, plus ask.
- Optional explicit RFI reply write-back through the same proxy.
- 102 vitest tests; tsc --noEmit clean; esbuild build green.
- Registers packages/procore in pnpm-workspace.yaml.
2026-07-04 01:30:19 -07:00
Hanzo Dev 1a967f8bb2 fix(release): desktop Rust E0277 + jupyter wheel jlpm-in-pnpm
Two build bugs (16 other v1.9.31 assets shipped fine; these were the last two):
- desktop: register_hotkey returned tauri::Result, but global_shortcut register()
  yields global_shortcut::Error with no From into tauri::Error (E0277). Return
  Box<dyn Error> — both ? sites convert, and setup() already expects it.
- jupyter: hatch-jupyter-builder ran 'jlpm install' (yarn) at the pnpm monorepo
  root → rejected. Build the prod labextension via pnpm first (creates the
  skip-if-exists target), so the wheel build never invokes jlpm.
2026-07-04 01:28:16 -07:00
Hanzo Dev 66f12b08f1 fix(release): install JupyterLab before labextension build + make jupyter job non-blocking
The 'Build labextension' step calls jlpm (provided by jupyterlab), but jupyterlab
was only installed in the later wheel step → 'jlpm: not found' failed the job, and
since jupyter is in release.needs it SKIPPED the whole GitHub Release (15 good
artifacts, no release). Install the JupyterLab toolchain first + continue-on-error
on every jupyter step so a wheel failure can never block the release again.
2026-07-04 01:16:37 -07:00
Hanzo Dev c62346e94c chore(release): 1.9.31 — installable artifacts for every packageable surface
New release binaries alongside browser/IDE/office/outlook: Figma plugin, Sketch
.sketchplugin, Teams app, Zendesk ZAF app, Desktop installers (dmg/msi/AppImage
per-OS), Jupyter wheel.
2026-07-04 01:07:23 -07:00
Hanzo Dev 4faa92e902 ci(release): build installable artifacts for figma, sketch, teams, zendesk, desktop, jupyter
Add producer jobs mirroring the office/outlook pattern so a version tag
attaches downloadable artifacts for every surface that can be one:

- figma/sketch/zendesk: pnpm build -> bestzip dist/ (arc runner has no zip)
- teams: build +  script -> dist/hanzo-teams.zip (nil-GUID app id
  placeholder so the release asset always builds without a bot secret)
- desktop: matrix [macos, windows, ubuntu] Tauri build -> per-OS installer
  (.dmg / .msi+.exe / .AppImage+.deb), continue-on-error (signing is future)
- jupyter: build labextension -> python -m build --wheel

Wire all into the release job needs/files and extend the download table
with Design Tools / Team Apps / Desktop / Data Science sections. Every
advertised link resolves to a produced basename.
2026-07-04 01:05:31 -07:00
Hanzo Dev e37b8e7a07 chore(sdk): remove orphaned @hanzo/ai AgentKit copy from the extension
The extension's packages/ai was a duplicate 'AgentKit with MCP' (canonical home:
hanzoai/ai) that squatted the @hanzo/ai name and collided with the published AI
client. Nothing consumes it anymore (all 16 adapters resolve @hanzo/ai to the
published 0.2.0 client from npm; no @hanzo/ai/server|react subpath imports; not
in the release publish). Removing it makes @hanzo/ai in the workspace
unambiguously the AI client.

Taxonomy (per direction): @hanzo/ai = AI client · @hanzo/agent = Agent SDK ·
@hanzo/ui = all v8 components (hanzoai/ui, agent UI → @hanzo/ui/agent) ·
@hanzo/gui = v8 framework (hanzoai/gui, Tauri+Tamagui unified). Removed the
@hanzo/ai CI test step. github 84 + slack 68 still green.
2026-07-04 00:19:31 -07:00
Hanzo Dev 6baf19e04c chore(sdk): align all adapters on published @hanzo/ai@^0.2.0
5 early adapters (docusign/meetings/notion/slack/teams) pinned @hanzo/ai@^0.1.1,
which resolved to the LOCAL packages/ai AgentKit (no createAiClient) — the reason
they carry stand-in client.ts files. Bump them to ^0.2.0 so every adapter's
declared dep points at the real published headless client. Tests unchanged and
green (docusign 103, meetings 70, notion 104, slack 68, teams 69).

Remaining SDK cleanup (separate pass): rename the extension's own @hanzo/ai
AgentKit → @hanzo/agentkit to end the name collision, then replace each adapter's
stand-in client.ts with a one-line re-export of @hanzo/ai.
2026-07-03 22:15:14 -07:00
Hanzo Dev 90a9383a6e feat(ambient): Hanzo desktop app — Tauri menubar + global hotkey (@hanzo/desktop)
Cross-platform (macOS/Windows/Linux) tray app: Cmd/Ctrl+Shift+Space spotlight,
quick actions over the clipboard (ask/summarize/explain copy; rewrite/translate/
fix paste-back via synthesized paste), streaming over published @hanzo/ai@0.2.0
(66 tests). Native installers = per-OS CI job (tauri build), never built locally.
Wave 4 dev/ambient (TS-tractable) complete.
2026-07-03 21:47:29 -07:00
Hanzo Dev 04699e3f9a Merge remote-tracking branch 'origin/feat/desktop' 2026-07-03 21:47:00 -07:00
Hanzo Dev 16620df721 feat(desktop): @hanzo/desktop — the ambient omnipresence layer (Tauri v2)
A cross-platform (macOS/Windows/Linux) menubar/tray app with a global hotkey
(Cmd/Ctrl+Shift+Space) that pops a Hanzo assistant over any app. Ask, summarize
the clipboard, rewrite, explain, translate, fix grammar — streamed over
api.hanzo.ai via the published @hanzo/ai. Built on Tauri v2 (Rust shell + web
frontend), not Electron.

Reuses the raycast/office design one-to-one: pure host-agnostic action catalog
(actions.ts — prompts, clipboard-context assembly/truncation, message shaping,
reply parsing), a thin @hanzo/ai wrapper (hanzo.ts — run/stream/list), a local
settings store (store.ts — hk- key + model, parse/merge/serialize), and the only
Tauri-touching module (clipboard.ts). app.ts is glue with one run path serving the
panel, the tray quick actions, and the hotkey.

Rust shell (src-tauri): tray + menu, global-shortcut toggle, spotlight window
(small, always-on-top, hidden-on-blur), and hide_window/paste commands (enigo
synthesizes Cmd/Ctrl+V so rewrite/translate/fix land over the frontmost app).

The hk- key lives only in the webview's local store and is validated before use;
CSP allows connecting only to api.hanzo.ai. Frontend builds with esbuild + tsc
clean; 66 vitest tests green. Native installers (.dmg/.msi/.AppImage) are a
per-OS CI job (tauri build) — never built locally.

Registers packages/desktop in pnpm-workspace.yaml.
2026-07-03 21:45:49 -07:00
Hanzo Dev de93905ce3 feat(dev): GitLab integration (@hanzo/gitlab)
Mirrors @hanzo/github for GitLab: MR review (reconstructed diff, windowed),
issue triage (labels ∩ project labels), @hanzo mentions; X-Gitlab-Token
constant-time verify; on published @hanzo/ai@0.2.0 (97 tests). CI step added.
2026-07-03 21:43:35 -07:00
Hanzo Dev bceb7176d9 feat(gitlab): Hanzo AI for GitLab — MR review, issue triage, @hanzo mentions
New package @hanzo/gitlab mirrors @hanzo/github for the GitLab ecosystem
(self-managed + gitlab.com). Webhook service over GitLab Project/Group
webhooks + REST API v4, built on the published @hanzo/ai and @hanzo/iam.

- webhook.ts: constant-time X-Gitlab-Token verify + X-Gitlab-Event routing (pure)
- review.ts: GitLab changes → unified diff → windowing/truncation → note (pure)
- triage.ts: issue → prompt; parse severity/labels ∩ project labels (pure)
- mention.ts: MR/issue thread + @hanzo question → answer (pure)
- gitlab-api.ts: thin REST v4 fetch wrappers (getMRChanges, listLabels,
  createMRNote/createIssueNote, addIssueLabels, getNotes)
- hanzo.ts: one path to the model via @hanzo/ai (api.hanzo.ai /v1)
- config.ts: env boundary (GITLAB_BASE_URL/TOKEN/WEBHOOK_SECRET, HANZO_API_KEY)
- app.ts + server.ts: compose the pure cores; HTTP boundary with token gate,
  health probe, 202 ACK + graceful shutdown

97 vitest tests green, tsc --noEmit clean, build produces dist. Registered
packages/gitlab in pnpm-workspace.yaml.
2026-07-03 21:42:29 -07:00
Hanzo Dev 85eebae869 feat(dev): JupyterLab extension — Wave 4 batch 1 complete
@hanzo/jupyter — JupyterLab 4 labextension: side panel (active-cell/selection/
notebook context) + cell actions (explain/fix-error/docstring/optimize/generate),
insert-below write-back; pure notebook context extraction (ANSI-stripped
tracebacks); on published @hanzo/ai@0.2.0 (58 tests). CI step added.
2026-07-03 21:28:49 -07:00
Hanzo Dev abe3ad8b65 Merge remote-tracking branch 'origin/feat/jupyter' 2026-07-03 21:28:10 -07:00
Hanzo Dev 5d54f72aa9 feat(jupyter): Hanzo AI for JupyterLab — side panel + cell actions
@hanzo/jupyter: a JupyterLab 4 labextension. A side-panel assistant (model
picker + prompt + streaming over @hanzo/ai) that reads active-cell / selection
/ whole-notebook context, plus cell actions (explain, fix from traceback,
document, optimize, generate-a-cell). Model calls route through api.hanzo.ai
/v1 via the published @hanzo/ai createAiClient; the hk- key lives in the
JupyterLab settings registry (schema/plugin.json).

Pure core (notebook.ts context/traceback/truncation, hanzo.ts action catalog +
request shaping) is unit-tested with vitest (58 tests); the widget layer
(index.ts plugin, panel.tsx) is the thin imperative shell. tsc --noEmit clean.
Ships a hatch-jupyter-builder pyproject for pip install / PyPI.
2026-07-03 21:27:08 -07:00
Hanzo Dev 2911113fda feat(dev): GitHub App + Raycast — Wave 4 batch 1
- @hanzo/github — GitHub App: auto PR review (diff windowed), issue triage
  (labels intersected with real repo labels), @hanzo mentions; HMAC-verified
  webhooks; all via @hanzo/ai (84 tests).
- hanzo-ai (Raycast) — command bar: Ask (streaming) + Summarize/Explain/Rewrite/
  Translate/Fix-grammar over selection/clipboard, paste-back (51 tests).

Both on published @hanzo/ai@0.2.0 + @hanzo/iam. CI steps added.
2026-07-03 21:26:10 -07:00
Hanzo Dev 1374690f9f Merge remote-tracking branch 'origin/feat/github' 2026-07-03 21:25:40 -07:00
Hanzo Dev 51d6b45667 feat(github): Hanzo AI GitHub App — PR review, issue triage, @hanzo mentions
A GitHub App (Octokit + webhooks) built on @hanzo/ai (headless model client)
and @hanzo/iam (identity). Reviews pull requests, triages new issues, and
answers @hanzo mentions in issue/PR comments.

Pure cores (network-free, exhaustively unit-tested):
- webhook.ts  X-Hub-Signature-256 HMAC verify (constant time) + event routing
- review.ts   diff windowing/truncation, prompt assembly, review formatting
- triage.ts   issue prompt, severity/label parsing (labels ∩ repo labels)
- mention.ts  thread-context answer prompt

Impure edges compose them: config (env boundary), hanzo (thin @hanzo/ai — the
one model path), github-api (thin Octokit .request wrappers), app (App +
handlers → route → dispatch), server (health + graceful shutdown).

84 vitest tests green; tsc --noEmit clean; build succeeds.
Webhook path /v1/github/webhooks (Hanzo /v1, never /api/). Secrets env-only.
2026-07-03 21:24:23 -07:00
Hanzo Dev aa8444aff6 feat(raycast): Hanzo AI for Raycast — Ask + quick actions over @hanzo/ai
New @hanzo/raycast package (Raycast manifest name: hanzo-ai): the Hanzo
command bar. One view command (Ask Hanzo — Form → streaming Detail) and
five no-view quick actions (Summarize, Explain, Rewrite, Translate, Fix
Grammar) that read the frontmost app's selection (clipboard fallback),
run it through the published @hanzo/ai over api.hanzo.ai /v1, and paste
or copy the result back.

Pure core, thin adapters (mirrors @hanzo/canva + @hanzo/figma):
- config.ts   gateway URLs, default model (zen5), hk- key guard
- actions.ts  action catalog, prompts, context assembly + truncation,
              capture policy (chooseSubject), response parsing
- hanzo.ts    the only caller of @hanzo/ai: runChat / streamChat / listModels
- selection.ts the only caller of @raycast/api I/O
- run-action.ts shared quick-action runner; per-command one-liners
- ask.tsx     the streaming view command

Auth: the hk- key is a secure Raycast preference (never committed).
51 vitest tests (catalog, prompts, context, request shaping incl.
streaming, parsing, capture policy). tsc --noEmit clean; ray build
succeeds; ray lint clean (ESLint 9 flat config + Prettier).

Registers packages/raycast in pnpm-workspace.yaml.
2026-07-03 21:23:57 -07:00
Hanzo Dev 6772773b40 feat(design): Figma + Sketch + Canva — Wave 3 complete
Design tools on the shared design-core (rewrite/localize/critique/variants/
content-fill), all on published @hanzo/ai + @hanzo/iam:
- @hanzo/figma — plugin: code.ts↔iframe messaging, text-node write-back, a11y
  critique using real layer colors, insertable variants (56 tests).
- @hanzo/sketch — .sketchplugin: WebView panel + sketch/dom write-back (43 tests).
- @hanzo/canva — Apps SDK panel: generate/rewrite/translate/ideas, insert/replace
  text elements (40 tests).

CI: figma/sketch/canva test steps.
2026-07-03 21:10:45 -07:00
Hanzo Dev b5a44c8b66 Merge remote-tracking branch 'origin/feat/figma' 2026-07-03 21:10:11 -07:00
Hanzo Dev 0f0def8287 Merge remote-tracking branch 'origin/feat/canva' 2026-07-03 21:09:38 -07:00
Hanzo Dev 71f76cd97e feat(canva): Hanzo AI for Canva — Apps SDK copy assistant
A side-panel Canva app over the published @hanzo/ai: generate copy, rewrite
the selected text in place, translate, brainstorm content ideas, and ask.

- design-core.ts (PURE): the five-action catalog, prompt builders, design-
  context assembly + budget truncation, and response parsing (fence/quote/
  list-marker stripping). Mirrors @hanzo/figma's design-core. Fully unit-tested.
- hanzo.ts: thin over @hanzo/ai (createAiClient) — the only model-gateway path.
- canva.ts: thin over @canva/design — observe the plaintext selection, replace
  it via a fresh draft, insert a new text element at the cursor.
- app.tsx: the @canva/app-ui-kit panel (model picker, action picker, output,
  ideas). index.tsx mounts it under AppUiProvider.
- config.ts: api.hanzo.ai /v1 gateway, default zen5, hk- key guard.

40 vitest tests green, tsc --noEmit clean, esbuild build succeeds. Registered
in pnpm-workspace.yaml.
2026-07-03 21:08:04 -07:00
Hanzo Dev c262c6e506 feat(sketch): Hanzo AI for Sketch — native design-AI plugin
@hanzo/sketch — a Sketch plugin adapter over the shared Hanzo design-AI
action catalog, mirroring @hanzo/figma/@hanzo/canva. Five commands:
summarize/critique, rewrite, translate, content-fill, ask.

- design-core.ts (PURE, 43 vitest tests): the action catalog, selection
  context assembly/truncation, @hanzo/ai request shaping (createAiClient,
  @hanzo/ai@^0.2.0), and per-layer / prose response parsing.
- sketch-bridge.cjs: the only sketch/dom boundary — readSelection,
  applyLayerEdits (layer.text = …), insertNote.
- command.cjs: opens the WebView, bridges messages; key stored via
  sketch/settings (never bundled/committed).
- webview/: HTML + TS panel that runs @hanzo/ai (model call where fetch
  exists), previews edits, applies them back.
- build.js: assembles the .sketchplugin bundle (esbuild, two runtimes).

Model calls route through api.hanzo.ai /v1 via @hanzo/ai only.
2026-07-03 21:07:07 -07:00
Hanzo Dev 7c5557a901 feat(figma): Hanzo AI for Figma plugin over @hanzo/ai
New @hanzo/figma package: a Figma + FigJam plugin that rewrites, translates,
content-fills, critiques (with a11y notes), renames layers, and generates copy
variants for the selected layers — built on the published @hanzo/ai headless
client against api.hanzo.ai.

Two-thread architecture per the Figma plugin model:
- code.ts (main): serializes the selection into a plain SelectionSnapshot
  (text nodes + structure + colors, node-budgeted) and applies write-backs
  (font-loaded setCharacters, rename, createText) — full figma.* access, no net.
- ui.ts (iframe): the only place the model is called; streams via design-core,
  parses per action, posts edits/inserts back to code.ts over a typed
  postMessage protocol (messaging.ts).
- design-core.ts (PURE): the design-action catalog (system+user prompt builders
  per action), selection->context assembly + budget truncation, response parsing
  (JSON id->value edit maps with id allow-listing, variant-list splitting), and
  the @hanzo/ai run funnels over an injectable client.

Key pasted in the UI, validated via /v1/models, stored in figma.clientStorage;
manifest networkAccess allowlists only api.hanzo.ai. esbuild build emits the
three Figma files (code.js IIFE, ui.html with JS+CSS inlined, manifest.json).

56 vitest tests green (design-core + messaging guards); tsc --noEmit clean.
2026-07-03 21:07:06 -07:00
Hanzo Dev b24f0eb76e feat(business): HubSpot + Shopify — Wave 2 complete
- @hanzo/hubspot — CRM card UI extension + serverless fns: record → AI →
  Note/Task write-back (correct HUBSPOT_DEFINED associations); key server-side (75 tests).
- @hanzo/shopify — embedded admin app: product description/SEO generation +
  order summaries, productUpdate write-back (only changed field); dual HMAC
  verify (OAuth callback + webhooks), secret isolation proven (108 tests).

Both on the published @hanzo/ai@0.2.0 + @hanzo/iam. Wave 2 (Salesforce · DocuSign ·
Notion · Zendesk · HubSpot · Shopify) all on main.
2026-07-03 20:55:10 -07:00
Hanzo Dev 5c76eab8bc Merge remote-tracking branch 'origin/feat/hubspot' 2026-07-03 20:54:34 -07:00
Hanzo Dev 68df12bae2 Merge remote-tracking branch 'origin/feat/shopify' 2026-07-03 20:54:07 -07:00
Hanzo Dev 24bec0883f feat(hubspot): Hanzo AI for HubSpot — CRM card + AI over records (@hanzo/hubspot)
A HubSpot public app on the developer-projects platform: a CRM card UI
extension (crm.record.tab, on contacts/companies/deals) backed by two
serverless functions. Summarize a record, draft an email, suggest next
steps, or ask — grounded in the record + its associated records — and
write the result back as a Note or Task.

Built on the published @hanzo/ai@^0.2.0 headless client (createAiClient,
/v1 chat.completions + models.list) + @hanzo/iam. The Hanzo API key
(HANZO_API_KEY secret) and the per-portal OAuth token live only in the
serverless backend; the browser never sees them.

Pure, unit-tested core (config, hanzo call + context windowing + the four
actions, HubSpot CRM v3 request shaping/parsing, OAuth token exchange,
record→context assembly, Note/Task write-back payloads). 75 vitest tests
green, tsc --noEmit clean, dual ESM+CJS build so the CJS serverless
runtime can require it.
2026-07-03 20:53:24 -07:00
Hanzo Dev e79d3a853e feat(shopify): Hanzo AI for Shopify — embedded admin app on the shared foundation
Add packages/shopify (@hanzo/shopify): a Shopify embedded admin app that puts
Hanzo AI over the store as system of record.

- Product content: generate/rewrite descriptions + SEO title/meta from product
  attributes, then write back via the productUpdate mutation (only changed fields).
- Orders/support: summarize an order, draft a customer reply, extract issues.
- Ask: freeform question over a product or order, grounded in its data.

Server (src/server, holds the only secrets):
- oauth.ts   Shopify OAuth request shaping + OAuth-callback HMAC verify (hex over
             Shopify-canonical URLSearchParams form) + state (CSRF) check.
- shopify-api.ts  Admin GraphQL wrappers (getProduct/getOrder/updateProduct) —
                  pure request shaping + parsing + productUpdate userErrors.
- hanzo.ts   thin over the PUBLISHED @hanzo/ai createAiClient; product/order
             context assembly + prompt building. No transport reimplemented.
- actions.ts product + order AI action catalogs, one prompt each over ask.
- webhooks.ts  webhook HMAC verify (base64 over RAW body) + topic routing
               (orders/create, app/uninstalled, the 3 GDPR topics).
- server.ts  OAuth + /v1/* proxy the frontend calls + /webhooks. HMAC verified
             before any param/body is trusted; shop validated to *.myshopify.com.

Frontend (src/app): Polaris + App Bridge React panel — api.ts (proxy client,
no secrets), context.ts (launch context → GID), session-fetch.ts (App Bridge
session-token fetch), App/Assistant/main. App Bridge loaded from Shopify CDN;
public API key stamped into the app, secret never bundled.

Plus shopify.app.toml, an admin-link extension, build.js (esbuild: app bundle +
Node server), tsconfig, vitest config, README. 108 vitest tests (pure), tsc
--noEmit clean, build green. Model calls via @hanzo/ai to api.hanzo.ai/v1 only.

Register packages/shopify in pnpm-workspace.yaml.
2026-07-03 20:53:10 -07:00
Hanzo Dev 21686d4eda feat(zendesk): Hanzo AI ticket-sidebar app (@hanzo/zendesk)
ZAFv2 ticket_sidebar Support app on the published @hanzo/ai@0.2.0: summarize
ticket, draft reply (agent voice), suggest macro/next-steps, ask; write-back via
ticket.comment.appendText (public reply / internal note). Hanzo key is a secure
ZAF setting, proxied server-side so it never reaches the browser (49 tests). CI step added.
2026-07-03 20:49:22 -07:00
Hanzo Dev 1f13590df8 feat(zendesk): Hanzo AI ticket-sidebar Support app on @hanzo/ai
ZAFv2 ticket_sidebar app (@hanzo/zendesk). Reads the current ticket +
conversation via the ZAF client, offers Summarize / Draft reply / Suggest
next steps / Explain / Ask over the published @hanzo/ai headless client, and
writes results back into the reply editor or as an internal note.

The Hanzo API key is a SECURE installation setting: model traffic is proxied
through Zendesk's request API (secureFetch + {{setting.hanzo_api_key}}), so the
key never reaches the browser while still using createAiClient. Pure, tested
ticket-windowing (newest-first, honest scope note) and message shaping.

49 vitest tests, tsc --noEmit clean, esbuild -> dist/ ZAF bundle (zcli-ready).
2026-07-03 20:47:41 -07:00
Hanzo Dev 4b84ccfcd6 feat(business): Salesforce + DocuSign + Notion on the shared foundation
Wave 2 batch 1:
- @hanzo/salesforce — LWC assistant + Apex callouts on record pages, read record →
  AI → write-back (Task/Note/Chatter), Named+External Credential (key never in Apex).
- @hanzo/docusign — summarize/extract-terms/risk-flag agreements + Connect webhook
  (HMAC-verified) auto-summary; envelope docs → pdf.js → @hanzo/ai (103 tests).
- @hanzo/notion — summarize/draft/extract pages → databases, append AI blocks,
  server-held OAuth token + proxy (104 tests).

CI: add notion/docusign/salesforce test steps. All on @hanzo/ai (now published
0.2.0 as the canonical headless client) + @hanzo/iam.
2026-07-03 20:33:13 -07:00
Hanzo Dev dc1c9b4faa Merge remote-tracking branch 'origin/feat/docusign' 2026-07-03 20:32:17 -07:00
Hanzo Dev bbd424bb36 Merge remote-tracking branch 'origin/feat/notion' 2026-07-03 20:32:17 -07:00
Hanzo Dev f85de497ee feat(docusign): Hanzo AI for DocuSign — AI over agreements (@hanzo/docusign)
Extension-App panel + Connect webhook that summarize, extract key terms,
risk-flag clauses, and draft memos over DocuSign envelopes, on the shared
@hanzo/ai + @hanzo/iam foundation over api.hanzo.ai /v1.

- OAuth Authorization Code Grant (account-d/account.docusign.com), server-side
  client_secret, base_uri discovery via /oauth/userinfo (pure shaping).
- eSignature REST v2.1 wrappers (envelopes, documents, combined PDF).
- Connect webhook: X-DocuSign-Signature-N HMAC-SHA256 verify over raw body,
  constant-time, multi-key; envelope-completed auto-summarize pipeline.
- PDF text extraction (pdf.js, pure itemsToText), document windowing/truncation,
  one 'ask' path shared by the four actions, the panel, and the webhook.
- 103 vitest tests (config, oauth, api, webhook HMAC, pdf, hanzo, actions,
  envelope pipeline, panel). tsc clean, esbuild build green.
- Registers packages/docusign in pnpm-workspace.yaml (one line).
2026-07-03 19:40:52 -07:00
Hanzo Dev 1533315944 feat(notion): Hanzo AI for Notion — OAuth integration + web app
Notion has no in-app UI-extension surface, so this ships a public OAuth
integration + REST v1 client and a small web app that operates on the
user's pages/databases. Four actions over a page:

- Summarize: recursively read blocks -> text, summarize via @hanzo/ai,
  append as a callout.
- Draft/expand: generate content continuing the page.
- Extract -> database: pull {task, owner, due} action items and create
  one database row per item.
- Ask: freeform Q&A over page context.

Architecture mirrors @hanzo/clio: pure, unit-tested request/blocks/chat
modules with thin DOM + http glue. The Notion client_secret AND bot token
stay server-side (server.ts) — the browser holds only an opaque session id
and proxies every Notion call through an allow-listed /notion/* proxy.
Model calls route through the createAiClient contract to api.hanzo.ai/v1.

104 vitest tests (OAuth shaping, REST wrappers + pagination, blocks<->text
both directions, @hanzo/ai request shaping, context assembly/truncation,
action-item parsing, proxy allow-list). tsc --noEmit clean; esbuild builds.
2026-07-03 19:38:28 -07:00
Hanzo Dev 0379404940 feat(salesforce): Hanzo AI for Salesforce — LWC assistant + Apex gateway client
New @hanzo/salesforce SFDX package: a Lightning Web Component assistant on
Account/Opportunity/Case/Lead/Contact record pages, backed by Apex callouts to
the api.hanzo.ai/v1 gateway (OpenAI-compatible, same wire format as the browser
extension, Office add-in, gworkspace add-on, and Slack app).

- HanzoClient: pure request/response core (buildMessages/requestBody/
  extractContent/parseModels) + ask/listModels callouts via the Hanzo_API
  Named Credential — the key never touches Apex.
- HanzoContext: SOQL record + related-list context assembly per object,
  WITH SECURITY_ENFORCED, truncated to a token budget.
- HanzoController: @AuraEnabled complete/listModels/writeBack; write-back
  creates a Task, a ContentNote, or a Chatter FeedItem (as user).
- LWC hanzoAssistant: model picker + quick actions (Summarize, Draft email,
  Next steps, Risks) + prompt + write-back, on record pages.
- Named + External Credential for api.hanzo.ai, permission set with principal
  access, Connected App, Hanzo_Config__mdt default-model config.
- Tests: HanzoClientTest + HanzoControllerTest with HttpCalloutMock (asserts
  URL /v1/chat/completions, bearer, body model+messages, response parse, and
  each write-back) — >75% coverage; vitest over the truncate helper.
- Registered packages/salesforce in pnpm-workspace.yaml.
2026-07-03 19:37:36 -07:00
Hanzo Dev 3e90fd6e3a feat(teams): Hanzo AI for Microsoft Teams — Wave 1 comms complete
@hanzo/teams — bot (1:1/channel) + message extensions, Adaptive Cards via
@hanzo/cards, Action.Submit/Execute dispatch, packaged Teams app zip (69 tests).
CI: add teams test step. Wave 1 (Slack · Teams · Zoom/Meet) all on main.
2026-07-03 19:21:14 -07:00
Hanzo Dev e2bd0cacc5 Merge remote-tracking branch 'origin/feat/teams'
# Conflicts:
#	pnpm-workspace.yaml
2026-07-03 19:20:33 -07:00
Hanzo Dev 1c63e5592e feat(comms): Slack + Zoom/Meet apps on the shared foundation; CI build-order fix
Wave 1 comms hubs land:
- @hanzo/slack — /hanzo slash command, App Home, message shortcuts, thread-context
  quick actions, OAuth install; UI 100% @hanzo/cards Block Kit (68 tests).
- @hanzo/meetings — Zoom App + Google Meet add-on panels + Zoom webhook
  transcript→summary (signature-verified); web panels over @hanzo/ai (70 tests).

CI: build @hanzo/cards before dependent tests (consumers resolve its built dist/),
and add slack + meetings test steps.

Note: the headless @hanzo/ai client isn't published under 'latest' yet (npm
@hanzo/ai@0.1.1 is the React <HanzoAI/> UI); each adapter ships a client to the
exact createAiClient /v1 contract as an interim, to become a one-line re-export
once the headless client publishes.
2026-07-03 19:19:44 -07:00
Hanzo Dev 0591d31d95 feat(teams): Hanzo AI for Microsoft Teams (@hanzo/teams)
A Bot Framework (botbuilder) app bringing Hanzo AI into Microsoft Teams,
built on the shared foundation — @hanzo/ai (headless client), @hanzo/iam
(identity), @hanzo/cards (canonical PanelSpec -> Adaptive Card).

- Bot (TeamsActivityHandler): 1:1 + channel chat; @mention/DM a prompt ->
  completion -> reply as a Hanzo assistant Adaptive Card panel.
- Adaptive Card actions: model picker (Input.ChoiceSet), quick actions
  (Draft/Summarize/Explain/Extract action items), prompt input + submit,
  all routed by dispatch() on data.action (from @hanzo/cards).
- Message extension "Ask Hanzo" (action) on a message + "Compose with
  Hanzo" (query) to insert an AI draft; pulls message/thread context.
- Teams app manifest (schema v1.16) + color/outline icons + a dependency-
  free zip build step for the sideloadable app package.
- Pure logic modules (dispatch/panels/context/hanzo/identity/config) with
  vitest coverage; tsc clean; tsup build (library + runnable server).

api.hanzo.ai /v1 via @hanzo/ai only; secrets are env-only (never committed).
2026-07-03 19:19:13 -07:00
Hanzo Dev 0ec82c318b Merge remote-tracking branch 'origin/feat/meetings' 2026-07-03 19:18:08 -07:00
Hanzo Dev b31bcbef56 feat(meetings): Hanzo AI for Zoom + Google Meet
@hanzo/meetings — an in-meeting assistant for Zoom and Google Meet over one
shared panel, wired to api.hanzo.ai/v1 via @hanzo/ai and hanzo.id via @hanzo/iam.

- Shared panel (src/panel): model picker + quick-action chips (Summarize /
  Action items / Follow-up email) + streaming output + prompt, host-agnostic via
  a PanelHost seam. Same UX language as the Office task pane / PDF workspace.
- Zoom App (src/zoom): in-meeting side panel over @zoom/appssdk + a server for
  OAuth install and the recording.completed / meeting.ended webhook — verifies
  the Zoom HMAC signature, answers endpoint.url_validation, downloads the cloud
  recording VTT, and summarizes it via @hanzo/ai.
- Google Meet add-on (src/meet): side-panel web app over @googleworkspace/meet-addons.
- src/hanzo.ts: pure transcript windowing/truncation (honest context note),
  WebVTT parse, OpenAI-compatible /v1 request shaping + SSE streaming, summary-
  prompt assembly. src/config.ts: endpoints, pickBearer, server env (fail-fast).
- 70 vitest tests (windowing, VTT, request shaping over mock fetch, webhook
  signature verify + routing, OAuth shaping, config). tsc --noEmit clean; build
  produces dist/zoom, dist/meet, dist/server.js.
- Registers packages/meetings in pnpm-workspace.yaml (one line).
2026-07-03 19:15:50 -07:00
Hanzo Dev bded43dbce feat(slack): Hanzo AI Slack app (@hanzo/slack)
Bolt app bringing Hanzo AI into Slack, built on the shared foundation:
@hanzo/ai (headless client), @hanzo/iam (identity), @hanzo/cards (UI).

- /hanzo slash command → completion, replies with a @hanzo/cards panel
- App Home assistant panel (model picker, quick actions, prompt, sign-in)
  via toSlackBlocks — no hand-written Block Kit
- Interactivity for the stable card action ids (model select, quick
  actions, prompt submit, sign in)
- 'Ask Hanzo' message shortcut + app_mention: pull thread/message context
  via conversations.replies/history, fence it into the prompt
- OAuth install (/slack/install, /slack/oauth_redirect) with a per-workspace
  bot token + per-workspace Hanzo token, server-side only
- Quick actions: Summarize, Draft reply, Explain, Action items
- Endpoints via api.hanzo.ai /v1 only; secrets from env; /health + graceful
  shutdown for ingress/k8s
- One path from Slack to the model (complete); pure modules unit-tested
- README with Slack app manifest + registration steps

Tests: 68 passing. tsc --noEmit clean. Build emits dist/.
2026-07-03 19:15:01 -07:00
Hanzo Dev 733c87313d ci(cards): test @hanzo/cards; workspace includes packages/cards
The shared card-builder — one canonical PanelSpec → Slack Block Kit / Teams
Adaptive Cards / Google CardService with stable action ids. The missing
foundation piece for card-host adapters (Slack/Teams/Google); pairs with the
existing @hanzo/ai (headless client) + @hanzo/iam (identity) SDKs. Pure, zero
runtime deps, 66 tests.
2026-07-03 18:55:22 -07:00
Hanzo Dev bb1a88abd0 feat(cards): @hanzo/cards — canonical PanelSpec → Slack/Teams/CardService
One canonical PanelSpec (header, model picker, quick-action chips, output,
prompt input, footer, signed-out state) with stable action ids, emitted to
three host card formats:
- toSlackBlocks  → Slack Block Kit blocks[]
- toAdaptiveCard → Teams Adaptive Card v1.5
- toCardServiceJson → Google Workspace CardService JSON

Pure presentation layer: no network, no host SDK deps, no secrets. Emitters
never call the AI or auth (@hanzo/ai / @hanzo/iam do that). assistantPanel()
builder so adapters do not hand-assemble. 66 vitest tests, tsc clean, tsup
build (cjs+esm+dts). Registered in pnpm-workspace.yaml.
2026-07-03 18:54:13 -07:00
Hanzo Dev a1a355b1d1 ci(clio): test @hanzo/clio; workspace includes packages/clio
Clio practice-management integration — OAuth2 + Clio API v4, AI actions over
matters/documents with write-back to matter notes/activities, and Custom Actions
to embed AI buttons inside Clio. A hosted web app + token-exchange service
(clio.hanzo.ai; client_secret server-only, KMS), so CI-tested — no sideload
binary. Completes the daily-lawyer surface set (iManage/NetDocuments deferred).
2026-07-03 17:22:43 -07:00
Hanzo Dev 91a595b822 Merge remote-tracking branch 'origin/feat/clio-integration' 2026-07-03 17:22:10 -07:00
Hanzo Dev 110b91f54c fix(clio): correct OAuth refresh + Custom Action nonce to real Clio v4 contract
Post-review corrections verified against Clio's own developer docs and the
official clio/example-third-party-application, fixing three ways the first
cut would have failed against real Clio:

1. OAuth: removed access_type=offline (a Google-ism Clio ignores — it would
   have yielded NO refresh_token). Clio returns a refresh_token on the
   authorization_code grant by default; added the real redirect_on_decline
   option instead. Wired the refresh path into the SPA (ensureClioToken →
   /oauth/refresh) so a long matter session survives token expiry — the
   refresh apparatus is no longer dead code.

2. Custom Action nonce: the nonce is NOT a locally-verifiable HMAC. It is a
   single-use, 60s code that the app must REPLAY to the Clio API, which
   validates server-side (403 on bad/stale). Removed the wrong local HMAC
   verify (and node:crypto from the service); /custom-action now parses the
   deep-link and redirects into the SPA carrying matter_id + the nonce, and
   getMatter replays it (custom_action_nonce query param) on the first
   authenticated call. Corrected the deep-link params to Clio's actual set
   (custom_action_id/user_id/subject_url/custom_action_nonce; subject_url is
   URL-encoded) — subject_id was never sent.

3. Documents: gate downloads on content_type (isTextExtractable) — PDF/DOCX
   (the bulk of legal docs) would have been fed to the model as mojibake,
   wasting the context budget. Binary docs now contribute name + type only.
   The MIME check matches the subtype so DOCX (whose vendor type contains the
   substring "xml") is correctly binary.

tsc clean, 89 vitest tests pass, esbuild build succeeds; re-verified the
browser bundle has zero secret/crypto refs.
2026-07-03 17:21:01 -07:00
Hanzo Dev 85dc2db900 feat(clio): Hanzo AI integration for Clio practice management
New @hanzo/clio package: brings Hanzo AI into a lawyer's Clio matters.
Pick a matter and Hanzo can summarize it, draft correspondence, extract
key dates & obligations, or answer freeform questions over the matter's
documents — then write the result back to Clio as a Note or Activity.

Architecture (one and only one way, secret-safe):
- Clio OAuth2 authorization-code grant. client_secret lives ONLY in the
  token-exchange service (server.ts, read from env); the browser SPA never
  sees it. Verified: dist/app.js has zero secret / crypto / process.env refs.
- Clio REST API v4 client (clio-api.ts): pure request-shaping wrappers for
  listMatters/getMatter/listDocuments/downloadDocument/listContacts and the
  write-back createNote/createActivity — URL, headers, fields selection,
  pagination all unit-testable; one thin clioFetch/clioDownload at the edge.
- Hanzo chat (hanzo-chat.ts) mirrors @hanzo/office-addin's wire contract:
  api.hanzo.ai/v1/chat/completions + /v1/models, no /api/ prefix.
- Four actions + matter-context assembly/truncation (actions.ts, 48k total /
  12k per-doc caps, truncation marked).
- Custom Action landing (custom-action.ts + server.ts): parses Clio's signed
  deep-link, HMAC-verifies the nonce (constant-time) with the client_secret,
  redirects into the SPA on the right matter.
- Configurable regional Clio base (us/eu/au/ca).

esbuild build (SPA iife + Node ESM service), tsc --noEmit clean, 86 vitest
tests pass across config/oauth/api/chat/actions/custom-action/server.

Registered packages/clio in pnpm-workspace.yaml.
2026-07-03 17:13:30 -07:00
Hanzo Dev c213289abf ci(pdf): test @hanzo/pdf; workspace includes packages/pdf
The Hanzo AI PDF workspace (PDF.js viewer + AI side panel — summarize, extract
parties/dates/obligations, find risky clauses over filings/contracts). A hosted
static surface (pdf.hanzo.ai over hanzoai/static), so CI-tested like the Google
Workspace add-on — no sideload binary.
2026-07-03 17:02:27 -07:00
Hanzo Dev 72464665c5 Merge remote-tracking branch 'origin/feat/pdf-ai' 2026-07-03 17:01:53 -07:00
Hanzo Dev 7f57e4d2b8 feat(pdf): Hanzo AI PDF workspace (@hanzo/pdf)
Self-contained web PDF workspace — PDF.js viewer + Hanzo AI side panel —
for the surface lawyers spend the most time in (filings, discovery,
executed contracts). Cross-platform (any browser), no Adobe license.

- PDF.js (pdfjs-dist 4.10.38) renders pages to canvas and extracts per-page
  text; worker bundled and shipped beside app.js.
- Right panel mirrors @hanzo/office-addin: model picker, quick-action chips,
  streamed replies over api.hanzo.ai/v1/chat/completions (SSE), /v1/models.
- Law-office quick actions: summarize; extract parties/dates/obligations;
  find & explain risky clauses; explain the selection; draft a summary memo.
- Honest large-PDF context windowing (buildContext): caps attached text at a
  char budget, windows whole-document or from the current page, and prepends a
  context note naming the exact page range sent — never claims the full doc
  when truncated. Pure and unit-tested.
- Auth: pasted hk- key validated against /v1/models before save (localStorage);
  OAuth via hanzo.id documented as the additive future path.
- esbuild build.js, vitest (44 tests), tsc clean.

Endpoints: api.hanzo.ai /v1 only.
2026-07-03 17:00:51 -07:00
Hanzo Dev 2655a5f227 ci(release): make Safari packaging reliable — zip the whole safari output tree
The Safari step zipped a fixed 'safari/Hanzo AI' subpath and hit zip exit 12
('nothing to do') → continue-on-error swallowed it → no release asset (v1.9.30
shipped without Safari). Zip the whole dist/safari tree (the Xcode project is
always emitted; a dev builds+signs+sideloads it) + any built .app, guarded so a
zip exit code can't blank the asset. Future releases carry Safari reliably.
2026-07-03 16:56:36 -07:00
Hanzo Dev 1c7679c41e chore(release): sync all packages to 1.9.30 (Outlook downloadable)
Adds the Outlook mail add-in as a downloadable release binary (hanzo-ai-outlook)
alongside Office, browser, IDE (vscode/cursor/windsurf/antigravity/jetbrains),
Claude, and Safari. Google Workspace ships via clasp, not a sideload binary.
2026-07-03 16:49:08 -07:00
Hanzo Dev 3105d4a099 ci(office-suite): test + release Outlook & Google Workspace add-ins
Wire the two new productivity surfaces into CI/CD:
- ci.yml: test @hanzo/outlook-addin (39) + @hanzo/gworkspace-addon (29).
- publish.yml: new 'outlook' job builds @hanzo/outlook-addin → hanzo-ai-outlook-v<ver>.zip,
  added to release needs/files + an Outlook row in the download table.
  (Google Workspace deploys via clasp to Google, not a sideload binary — CI-tested
  only, no release zip.)
- pnpm-lock: workspace now includes packages/outlook + packages/gworkspace.
2026-07-03 16:32:55 -07:00
Hanzo Dev 5f14961fbb Merge remote-tracking branch 'origin/feat/gworkspace-addon' 2026-07-03 16:29:58 -07:00
Hanzo Dev 36de7695af feat(gworkspace): Google Workspace add-on for Docs, Sheets, Slides, Gmail
Mirror @hanzo/office-addin for the Google ecosystem. Apps Script +
CardService add-on calling api.hanzo.ai/v1 via UrlFetchApp (non-streaming,
one call per insert). Per-user hk- API key in PropertiesService.

- appsscript.json: per-host homepage triggers + Gmail contextual/compose
  triggers, currentonly + gmail.addons scopes, urlFetchWhitelist pinned to
  https://api.hanzo.ai/.
- hanzo.gs: API client (pure buildMessages/requestBody/extractContent/
  parseModels + UrlFetchApp ask/listModels).
- ui.gs: CardService sidebar (model picker, quick-action chips, settings/
  onboarding, universal actions) + all trigger/action handlers.
- docs/sheets/slides/gmail.gs: per-host read + insert glue.
- vitest over the pure logic (loads .gs in a Node sandbox, no duplication);
  validate-manifest.mjs preflights scopes + every runFunction resolves.
- Register packages/gworkspace in pnpm-workspace.yaml.
2026-07-03 16:25:19 -07:00
Hanzo Dev 0ae9186728 feat(outlook): Hanzo AI mail add-in (@hanzo/outlook-addin)
A Microsoft Outlook mail add-in embedding Hanzo AI into email — the
highest-value law-office surface after Word. Mail add-in manifest
(MailApp), Read + Compose command surfaces, ribbon button opens the
task pane.

Quick actions: Summarize thread, Draft reply, Extract deadlines &
action items, Explain plainly — all streamed (askStream/SSE).

- Reuses the Office add-in's pure core identically: config.ts (api.hanzo.ai
  /v1 gateway), chat.ts (ask/askStream/listModels), auth.ts (@hanzo/auth
  OAuth2+PKCE via roamingSettings + hk- API-key path). Shares the
  hanzo-office IAM client — no new auth surface.
- New outlook-host.ts: Office.context.mailbox.item glue (read body/subject/
  from; compose prepend/setSelectedData; displayReplyForm) + pure shaping
  helpers (stripHtml, collapseWhitespace, shapeMessage, formatFrom, mailMode).
- build.js mirrors office (esbuild -> dist/, base-URL stamp, dev HTTPS serve).
- 39 vitest tests pass; manifest validated by office-addin-manifest.
- Registered packages/outlook in pnpm-workspace.yaml.
2026-07-03 16:24:24 -07:00
Hanzo Dev e9531bf4e3 fix(jetbrains): disable buildSearchableOptions — headless libfreetype crash
buildPlugin transitively runs buildSearchableOptions, which launches a headless
IDE (JCEF) needing native font libs (libfreetype.so.6) absent on the CI runners
→ UnsatisfiedLinkError, non-deterministic (only green when the task was cache
UP-TO-DATE, which is why ci.yml passed but the release build failed). The search
index is optional and rebuilt by the IDE at runtime; disabling it makes
buildPlugin reliably emit the distributable plugin zip for the GitHub Release.
2026-07-03 15:47:49 -07:00
Hanzo Dev 46daa52d43 chore(release): sync all packages to 1.9.29 for full all-platform release
Bumps root + browser + vscode + office + jetbrains to 1.9.29 so every release
asset name matches the tag (the download-table URLs use the tag version). The
first release to carry a downloadable JetBrains plugin binary alongside browser,
VS Code/Cursor/Windsurf/Antigravity, Claude, Safari and Office.
2026-07-03 15:39:46 -07:00
hanzo-dev 780757ca55 feat(auth): one canonical @hanzo/auth core; migrate office; fix vscode HIP-0111
Five surfaces (browser, office, vscode, cli, mcp) each hand-rolled the SAME
IAM OAuth2+PKCE flow — and one drifted and broke: vscode built
hanzo.id/oauth/authorize and iam.hanzo.ai/oauth/token, both MISSING the
/v1/iam prefix (a 404 — the same class of bug as the browser login fix),
plus it POSTed JSON where IAM requires form-urlencoded and sent a
client_secret on a public PKCE client. That is the opposite of one way.

Decomplected into ONE core — packages/auth (@hanzo/auth):
- config.ts   canonical HIP-0111 endpoints (authorize/token/userinfo ALL
              carry /v1/iam) + the one-client-per-surface registry
              (hanzo-browser/office/vscode/cli). URL assembly lives once.
- pkce.ts     the one PKCE (S256), platform-CSPRNG + WebCrypto.
- oauth.ts    buildAuthorizeURL / exchangeCode / refreshTokens / userinfo —
              pure fetch, form-encoded per RFC 6749.
- flow.ts     login / getValidToken (transparent refresh) / logout /
              currentUser, written ONCE against TokenStore + Opener. A
              surface supplies only those two thin adapters — no surface
              re-implements the flow.
23 tests: the drift guard (every path has /v1/iam), client registry, PKCE
RFC-7636 vector, wire shapes, and the full flow with fake adapters.

Migrate office onto it: roamingSettings TokenStore + Dialog-API Opener;
delete office's duplicate pkce.ts + IAM config (now the core's). API-key
path kept. 22 tests; the core bundles into the task pane.

Fix vscode standalone-auth: all three endpoints → ${host}/v1/iam/oauth/*,
token calls form-urlencoded, OIDC scopes, drop the secret on the public
PKCE client. Compiles clean.

CI now gates @hanzo/auth. Browser/mcp/tools already use canonical paths;
migrating them to import the core is the tracked next step (each needs its
own bundler wiring) — the ONE way now exists and has two consumers.
2026-07-03 15:24:01 -07:00
Hanzo Dev 5bf8d934b4 ci(release): downloadable JetBrains + Office + Safari binaries for all platforms
The GitHub Release table advertised JetBrains and Safari downloads that 404'd —
neither job produced a release artifact — and the Office add-in was absent.
Make every advertised binary real and deterministic from the tag:

- jetbrains: always `gradlew buildPlugin` → hanzo-ai-jetbrains-v<ver>.zip artifact
  (Marketplace publish stays token-gated); the job no longer skips without the
  token, so the download link always resolves.
- office: new job builds @hanzo/office-addin → hanzo-ai-office-v<ver>.zip.
- safari: package the built .app(s) → hanzo-ai-safari-v<ver>.zip.
- release: add jetbrains+office to `needs`, add all three globs to `files`, and
  add the Office row to the download table. Asset names use the tag version so
  they match the table URLs.

All-platform release: browser (chrome/edge/firefox/safari), IDE (vscode/cursor/
windsurf/antigravity/jetbrains), Claude dxt, and Office (Word/Excel/PowerPoint).
2026-07-03 15:22:58 -07:00
Hanzo Dev 81db870a51 feat(office): streaming responses, live model picker, quick-action chips
Additive enhancements to the Office add-in, keeping the existing ask/requestBody
contract (and its tests) intact:

- chat.ts: askStream (SSE streaming completion → onDelta), streamDelta (pure SSE
  frame parse), listModels (/v1/models → ids); requestBody gains an opt-in stream
  flag (defaults false, so the one-shot ask path is unchanged).
- taskpane: a live model <select> populated from /v1/models (org-scoped by the
  token, refreshed on sign-in), and one-click quick-action chips — Draft,
  Summarize, Explain plainly, Tighten/redline, Continue — that run over the
  selection. Output now streams in live; Insert stays gated on real content.

Built for document-heavy review/drafting (e.g. a law office). Tests: 31 pass
(+6 new: stream flag, streamDelta, listModels). Build OK; my files tsc-clean.
2026-07-03 15:08:32 -07:00
hanzo-dev 4060ce491b feat(office): API-key auth + self-contained Windows test kit; release 1.9.28
- API-key auth: paste a Hanzo hk- key (console.hanzo.ai → API keys) instead
  of the IAM OAuth dialog, so the add-in is usable with zero infra/IAM setup.
  pickBearer(apiKey, oauthToken) precedence is pure + tested (29 tests).
- Windows test kit: `HANZO_OFFICE_BASE=https://localhost:3000` build ships
  serve.mjs (stdlib HTTPS static server) + SIDELOAD-WINDOWS.md, so the
  downloaded zip runs on any machine — trust a localhost cert, node serve.mjs,
  sideload manifest.xml, paste key, go.
- CI Build now emits BOTH hanzo-ai-office-v${VER}.zip (production, office.hanzo.ai)
  and hanzo-ai-office-localhost-v${VER}.zip (the test kit).

Bump browser+root to 1.9.28 so tagging v1.9.28 publishes a GitHub Release
carrying ALL binaries — chrome/edge/firefox/safari, vscode/cursor/windsurf,
claude(dxt), and now office (prod + localhost) — with permanent download links.
2026-07-03 15:05:46 -07:00
hanzo-dev 9032801deb feat(office): Hanzo AI add-in for Word, Excel & PowerPoint
The surface a non-developer actually uses — a Microsoft Office task-pane
add-in that puts Hanzo AI inside the document. Select text or a range,
ask, insert. Distinct from the browser extension and the IDE extensions;
same backend as everything else: model calls through api.hanzo.ai/v1
(OpenAI-compatible), sign-in via Hanzo IAM (hanzo.id, auth-code + PKCE,
HIP-0111). No new API surface.

Decomplected so the logic is pure and unit-tested (26 tests), Office.js
glue kept thin:
- chat.ts — request/response shaping (buildMessages fences the selection
  as data; extractContent tolerates the gateway's response shapes)
- pkce.ts — PKCE + authorize URL, verified against the RFC 7636 S256 test
  vector
- host.ts — per-host read/insert; Excel range↔text helpers tested
- config.ts — canonical endpoints pinned (api.hanzo.ai, /v1/iam/oauth/*)
- taskpane/auth/commands — the Office.js + Dialog-API glue

manifest.xml passes `office-addin-manifest validate` ("The manifest is
valid" — Word/Excel/PowerPoint on web, Windows, Mac). build.js stamps the
hosting base URL (office.hanzo.ai, override for localhost dev) into a
sideloadable/AppSource-submittable dist/manifest.xml.

CI runs the tests and builds+zips a sideloadable artifact. Publishing to
AppSource needs the dist hosted at office.hanzo.ai + a hanzo-office IAM
client + a Partner Center submission (documented in the README).
2026-07-03 14:43:29 -07:00
hanzo-dev 8051256382 test(vscode): gate the zsh shell-tool test on /bin/zsh existing — its whole subject is delegation to zsh, absent on CI runners (the one remaining hard-gate failure: 151/152) 2026-07-03 02:16:05 -07:00
hanzo-dev 3e380bcec3 ci: provision electron runtime libs for the extension-host tests — VS Code downloaded but could not load libnspr4.so on the minimal arc node 2026-07-03 02:12:29 -07:00
hanzo-dev 39fc502513 ci: provision xvfb before the vscode extension-host tests — the arc pool is heterogeneous and not every node ships it (Test job hit exit 127 while the E2E job's node has it) 2026-07-03 02:09:01 -07:00
hanzo-dev ce2f186bd2 vscode: resurrect the whole test gate — 0 lint errors, 152 mocha + 3 vitest passing, hard CI gates
The package's `npm test` has been broken-by-construction for years:
tsconfig excluded src/test entirely (out/test/runTest.js never existed),
tsconfig.test.json inherited the exclude that negated its include, the
runner pointed at a ./suite/index path that never existed, two suites used
BDD describe/it under the tdd-configured mocha, a vitest file sat in the
mocha glob, `ws` was never declared (it resolved only cross-package), and
154 eslint errors blocked pretest.

Lint 154 → 0 with real fixes: the ban-types autofix damage reverted
properly (the local `Symbol` interface shadowing the ES built-in — the
actual footgun — is now `CodeSymbol` everywhere), case bodies braced via
AST codemod, empty catches annotated, `Function` types replaced with real
signatures, hasOwnProperty via Object.prototype, and
@typescript-eslint/no-var-requires off — CJS lazy requires are the
intentional activation-perf idiom (the rule is retired to stylistic in
typescript-eslint v8).

Harness: tests compile via tsconfig.test.json in pretest; runner path
fixed; BDD→TDD; glob v10 promise API; esModuleInterop default imports.
Two harnesses, one directory each: vitest owns src/test/vitest/ (scoped
vitest.config, excluded from the mocha compile), mocha owns the rest;
posttest runs the vitest side so `npm test` is the whole gate.

Suites modernized against current tool contracts (34 passing / 64 failing
→ 152 passing / 0 failing / 12 pending): web-fetch rewritten against the
real http(s) path via loopback server, mode against list/activate/show/
current, mcp-tools against config-driven getAllTools filtering, extension
pins the shipped manifest contract, shell/process/git-search/filesystem/
config/bash/mcp-runner exercise real commands, files, repos and spawns.
Dead-subject suites removed; network-dependent integration tests gated on
HANZO_E2E_LIVE with visible skip reasons (12 pending). Tests-only — the
one suspected product bug was an unfaithful Memento mock, and a real
cross-suite settings leak was fixed by making suites hermetic.

CI: cli-tools and vscode test steps lose continue-on-error and become
hard gates (vscode under xvfb-run, same as the E2E job).
2026-07-03 02:05:42 -07:00
hanzo-dev 7c742c07f4 deps: re-pin stacked-advisory packages to their highest patched floor
The first pass pinned each override to its advisory-snapshot floor, but
several packages carry stacked advisories — a pin at one advisory's floor
sits inside the next one's vulnerable range (protobufjs 7.5.5 vs 7.6.3,
tar 7.5.16, tmp 0.2.7, flatted 3.4.2, vite 6.4.3, file-type 21.3.2,
qs 6.15.2, js-yaml 4.2.0). One within-major floor per package now, at the
highest first-patched. Suites: browser 243, ai 31, aci 29, cli-tools 87.
2026-07-03 00:56:30 -07:00
hanzo-dev 4eb2cf57a8 release: browser-extension 1.9.27 — rebuilt on patched dependency tree
1.9.26 bundles carried the pre-override axios/ws resolutions; this rebuild
ships the patched tree from the dependabot-closure pass.
2026-07-03 00:40:45 -07:00
hanzo-dev 3ab5ae053c deps: close all 175 dependabot alerts — range-exact overrides, vitest 3, otel 2
- pnpm overrides pin every vulnerable transitive to its advisory's exact
  first-patched version, scoped to the advisory's own vulnerable range so
  no dependency is dragged across a major it didn't need (criticals:
  form-data 4.0.6, handlebars 4.7.9, protobufjs 7.5.5).
- vitest < 3.2.6 is a critical (CVE fix floor) with no 0.x/1.x backport —
  forced major to ^3.2.6 in every package. Test scripts unify on
  `vitest run` (aci/ai ran bare watch mode). tools' vscode mock alias
  becomes absolute (vitest 3 dropped relative alias resolution).
- packages/ai otel family 1.x → 2.x (core <2.8.0 advisory covers all 1.x):
  resourceFromAttributes, ATTR_* semconv, constructor spanProcessors. The
  previously-uncollectable telemetry suite now runs: ai 31 tests (was 16).
- direct bumps: vite ^6.4.3 (site), esbuild ^0.25.8 (mcp), uuid ^11.1.1
  (tools). packages/ai/package-lock.json deleted — stray npm lockfile in a
  pnpm workspace, carried 24 of the alerts.
- ci: test step filtered @hanzo/tools but the package is @hanzo/cli-tools —
  the suite never ran in CI. Fixed; 87 tests now gate (behind the existing
  continue-on-error). pnpm 9 reads onlyBuiltDependencies from package.json,
  not pnpm-workspace.yaml — mirrored so native build scripts stay allowlisted.

Verified: browser 243/243 + e2e 30/30 + build; aci 29/29; ai 31/31;
cli-tools 87/87; site builds on vite 6; mcp builds on esbuild 0.25.
2026-07-03 00:40:07 -07:00
hanzo-dev fe3c0eabdc release: browser-extension 1.9.26 — working login (hanzo-browser IAM client)
1.9.25 shipped the unregistered app-hanzo client_id, so the PKCE token
exchange failed invalid_client — login has been dead since v1.8.0. The
canonical registered client is hanzo-browser (universe IAM init_data;
redirectUris include https://hanzo.ai/callback). Contracts pinning the
registered client land in shared-config + cross-browser-parity tests.
2026-07-03 00:00:14 -07:00
hanzo-dev e23fbe51f7 dxt: kill dead app-hanzo password-grant, canonical OIDC PKCE only (from PR #6)
Adopted from fix/dxt-login-and-ci-guards: the DXT auth tool offered a
password-grant login defaulting to the unregistered app-hanzo client —
removed; whoami/token/account read /v1/iam/oauth/userinfo, refresh
exchanges at /v1/iam/oauth/token, issuance is authorization-code+PKCE
only. dxt build script now actually builds (mcp cli → server.js) instead
of echoing success. PR #6's test-suite restorations are not taken — main
already restored those suites.
2026-07-02 23:59:10 -07:00
Hanzo AIandhanzo-dev 42bf555a4e fix(extension): login client_id app-hanzo→hanzo-browser (dead client since v1.8.0) — v1.9.25 2026-07-02 23:58:30 -07:00
hanzo-dev 8f8f8b5ae2 llm.hanzo.ai is dead — api.hanzo.ai is the one gateway; harden live HF tests
Migrate the last llm.hanzo.ai references (vscode MCP llm tool provider URL
+ gateway fallback, auth-chat e2e doc comments) to the canonical
https://api.hanzo.ai/v1 (OpenAI-compatible, IAM JWT at the gateway — see
hanzoai/ai). The browser extension chat paths already pointed at
API_BASE_URL = api.hanzo.ai.

model-hub's HuggingFace tests are real live-API integration tests, which
means they fail on HF's weather: repeated runs rate-limit and the 5s
default timeout reads as 6 test failures with nothing of ours broken.
Probe HF once at module load and describe.skipIf with the status logged
when HF itself refuses service; give live calls a 15s budget. A failure
while HF answers 200 is still a real failure. 243/243 across repeated runs.
2026-07-02 23:53:04 -07:00
hanzo-dev c783b89c35 e2e: repair popup specs stranded by the 1.9.3 UI and the tab-hosted-popup harness
Three specs still tested the pre-1.9.3 popup (#open-page-overlay FAB,
'Open Chat Panel' label) — the Extension E2E jobs have been red on them
since the FAB was killed. Repointed at the current contract
(#overlay-enabled + #open-panel, 'Open Chat').

The interaction specs (overlay show, picker tool, console tool) all
funnel through the popup's active-tab lookup, which cannot work when the
harness hosts the popup as a tab: the popup IS the active tab, so the
privileged-tab guard always takes the fallback. Each spec now sends the
exact message its button sends (page.overlay.show / tool.picker.start /
tool.console.start), explicitly targeted at the example.com tab, so the
background relay and content-script surfaces are still exercised end to
end. CI's file set (popup, popup-tools, sidebar): 30/30 locally.
2026-07-02 23:04:29 -07:00
hanzo-dev b3f718dfce release: browser-extension 1.9.25 — green pipeline end to end
Fix the two failures the 1.9.24 tag surfaced:

- test/claude-integration.test.ts bound a fixed port 3002 in beforeEach
  and raced the previous test's teardown — EADDRINUSE as an unhandled
  'error', a 10s hook timeout, then ECONNREFUSED (flaked 3 of 4 local
  runs). BrowserExtensionServer now exposes ready() (resolves on listen,
  rejects on bind failure instead of crashing the process) and a port
  getter; the test binds port 0 (OS-assigned), awaits ready(), and
  terminates the client so wss.close() reliably fires. Six consecutive
  green runs.

- CI Build and Publish packaged with the `zip` binary the arc runner
  image doesn't have (Publish red since 1.9.23); Publish also wrote the
  zips one directory above where its artifact globs and the Chrome Web
  Store upload (which additionally pointed at an unversioned filename)
  looked, and CI hardcoded the version from the root package.json.
  Package with `npx web-ext build` (already in the job for linting),
  version from the browser package.json everywhere, store upload path
  fixed, and the Linux job no longer ships a junk safari zip — the real
  bundle comes from the macOS job.

Root and browser package.json move in lockstep to 1.9.25 (root was left
at 1.9.23 by the 1.9.24 bump). npm + VS Code Marketplace already carry
1.9.24; the stores' first complete release of this line is 1.9.25.
2026-07-02 21:52:58 -07:00
hanzo-dev c4424826f9 release: browser-extension 1.9.24 — one evaluate rule across Chrome+Firefox
shared/evaluable.ts is the single cross-browser rule for caller-supplied
JS: pickEvaluable accepts every code-param alias (expression/code/script/
function/js); wrapEvaluable passes bare expressions through and wraps
statement bodies in an async IIFE with the trailing value auto-returned.
Both dispatch paths consume it — browser-dispatch.ts (chrome.debugger
Runtime.evaluate, which also gains the plain 'evaluate' method alias
Firefox already accepted) and background-firefox.ts, whose previous
`return (${code})` wrap was a SyntaxError on any multi-statement body
and silently returned undefined for `expr;`. The Firefox path now always
settles promises in-page and routes rejections over the __hanzo_error
channel so callers see the real page-side error.

Tests: behavioral suite for the wrapper (evaluates wrapped output, not
string-matching); revive the three suites dead since the node bridge was
deleted — parity contracts repointed at browser-dispatch.ts, the IAM
contracts updated to the HIP-0111 canonical /v1/iam/oauth/token shape,
hub-wiring trimmed to the live background.ts surface, and the node-bridge
routing suite removed with its subject. 243 passing (was 186 + 3 dead
files).
2026-07-02 21:38:18 -07:00
zeekay 8d5384c1de Merge remote-tracking branch 'origin/rip/api-callers-to-v1' 2026-07-02 13:18:34 -07:00
zeekayandClaude Opus 4.8 3633c5a6ff rip: migrate IAM /api/* callers to canonical /v1/iam/*
extension dxt server: /api/userinfo→/v1/iam/oauth/userinfo, /api/login→
/v1/iam/login. Clean break, no aliases (org rule: one way, /v1/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:12:56 -07:00
355f6496fb Debrand: replace Casdoor name with Hanzo IAM in comments/docs/aliases (#5)
Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-30 14:59:31 -07:00
z 28c75ec6a8 docs(brand): add hero banner 2026-06-28 20:10:52 -07:00
z 22729e5218 chore(brand): dynamic hero banner 2026-06-28 20:10:51 -07:00
51f59c44ff fix(iam): migrate to canonical /v1/iam/oauth/* (HIP-0111) (#4)
Co-authored-by: Zach Kelling <z@zeekay.io>
2026-06-24 19:16:15 -07:00
Hanzo AI 1c2b9f7915 kms: clarify upstream API path note 2026-06-24 10:51:37 -07:00
zeekay 1298aea79a release: browser-extension 1.9.23 2026-06-20 13:05:07 -07:00
1560cd1228 ci: run on self-hosted ARC pool (hanzo-build-linux-amd64/deploy), not GitHub-hosted (#3)
Co-authored-by: zeekay <z@hanzo.ai>
2026-06-19 20:34:40 -07:00
Antje WorringandClaude Opus 4.8 c6e3ac5533 docs: tidy LLM.md indexes; CLAUDE.md -> LLM.md symlink convention
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:24:21 -07:00
Antje WorringandClaude Opus 4.8 bb0cd246e8 ext 1.9.22: native-zap singleton — one native port, no host-spawn storm
connectNativeZap had no singleton guard: state.port was overwritten without closing the
prior port, and every onDisconnect scheduled another connect. Called from startup + the
3s reconnect, ports (+ their native hosts) accumulated into a host-spawn storm once the
router stopped rejecting duplicates. Now: tear down any prior port on entry; a replaced
port (state.port !== port) never reconnects. Exactly one native host per browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:42:36 -07:00
Antje WorringandClaude Opus 4.8 52076f2c25 ext: package.json is the single source of truth for version
build.js stamps the package.json version into every manifest (chrome/firefox/safari)
so the three can never drift. Syncs src manifests to 1.9.21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:04:52 -07:00
Antje WorringandClaude Opus 4.8 03e081e83a ext 1.9.21: native ZAP is the one transport (Chrome+Firefox)
connectNativeZap (shared/native-zap.ts) registers the browser as a zapd provider
over native messaging and dispatches inbound ROUTE commands. Renames cdp-bridge.ts
-> browser-dispatch.ts; deletes the cdp-bridge-server.ts WS bridge. No ws://localhost,
no port roulette, no CDP fallback in the default boot path. Patch bump (no 2.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:03:00 -07:00
Antje WorringandClaude Opus 4.8 3bb7d57a9f ext 1.9.20: re-enable legacy CDP/MCP transport as working fallback
ZAP discovery is unreliable for local hanzo-mcp: the server binds ephemeral
ports (not the probed [9999..9995]) and mDNS needs the unshipped
ai.hanzo.zap_mdns native helper. Re-enable ENABLE_LEGACY_TRANSPORTS so the
extension connects to the running CDP bridge (ws://localhost:9223/cdp +
HTTP :9224) for driving a logged-in Chrome profile. Builds on the 1.9.16-1.9.19
dispatch/CSP fixes. Bumps chrome+firefox manifests + package.json to 1.9.20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:07:15 -07:00
zeekay 2ad3d464b4 extension(browser): v1.9.19 — decomplected click + cross-window tab switch
hanzo.click: removed framework-specific hacks (Drupal AJAX lookup, jQuery
fallback). Those don't generalize — every new framework would need its
own hack. Replaced with a clean realistic-event-sequence dispatch:

  mouseover → mouseenter → mousemove → mousedown → focus → mouseup → click

with proper clientX/Y/button/buttons/composed. Events still have
isTrusted=false (a WebExtension can't change that), so frameworks that
explicitly check isTrusted (Drupal AJAX behaviors, some React libs)
will still reject — those need the v1.10.0 BiDi backend on the Python
bridge to produce real trusted browser input via Firefox's
--remote-debugging-port WebDriver BiDi endpoint.

Target.activateTarget: now ALSO calls windows.update(focused:true) so
cross-Firefox-window tab switching actually brings the OS window to
the foreground (not just the tab within its current window).

hanzo.switchTab: new Layer-3 ergonomic alias. Same implementation as
Target.activateTarget but with a human-readable name. Recommended for
everyday tab switching in scripts and the MCP browser_tool wrapper.

Architectural note: hanzo.click is the synthetic path (best-effort for
sites that don't check isTrusted). When the v1.10.0 Python bridge BiDi
backend lands, calls to hanzo.click will auto-route to Input.bidi
.dispatchMouseEvent for trusted browser-input events. That is the
proper decomplected design — backend per trustedness level, ergonomic
alias on top. No per-framework hacks in the extension.
2026-06-10 13:10:16 -07:00
zeekay 5e270c061d extension(browser): v1.9.18 — propagate page-side throws, fix textarea fill
Two bugs caught while driving the SEC TCR form:

1) runFunc silently swallowed page-side errors.
   scripting.executeScript returns [{result, error, frameId}] per frame.
   We were mapping to r?.result which dropped the `error` field — when a
   page-side function threw, callers saw null instead of an error.

   Fix: keep the full InjectionResult shape and throw on first.error.

2) hanzo.fill called HTMLInputElement's value setter on a TEXTAREA.
   That throws TypeError "Illegal invocation" silently (now visible
   thanks to fix #1). The chained `|| HTMLTextAreaElement.prototype...`
   never executed because the first ?.set was truthy.

   Fix: per-tag dispatch — pick the matching prototype's native setter
   (INPUT/TEXTAREA/SELECT each get their own). Wrapped in try/catch with
   plain `el.value = value` as last resort.

Verified on www.sec.gov/forms/tcr-external-form:
  - hanzo.fill on textarea[name^="in_your_own_words"] now sets the value
  - hanzo.clear on same textarea now works
2026-06-10 11:47:16 -07:00
zeekay bb6ed472be extension(browser): v1.9.17 — fix HTTP-action dispatch fallthrough
handleExtensionRequest() was returning "Unknown extension action" for
every Page.* / DOM.* / Input.* / hanzo.* method added in 1.9.16. The
handler had only routed monitor.*/audit.* and fell off the end for
everything else.

Fix: fall back to executeMethod (the canonical CDP-shaped dispatcher)
before returning Unknown. Strictly additive — all previously-working
actions continue through their existing branches.

Verified locally on www.sec.gov/forms/tcr-external-form: hanzo.listForm
returns 183 elements with full label association; hanzo.scroll +
hanzo.clickByText + hanzo.fillByLabel are now routable.

Next: a v1.10.x multi-backend Python bridge that adds WebDriver BiDi
(Firefox 129+), CDP (Chrome 124+), and safaridriver (Safari 17+) as
secondary backends for network interception, log streams, and other
capabilities the WebExtension scripting API cannot provide. Same wire
surface, prefix-based routing (BiDi.* / CDP.* / WD.*).
2026-06-10 11:29:59 -07:00
zeekay 610367e6fd extension(browser): v1.9.16 — CSP-safe DOM ops + CDP-shaped surface
Decomplected three orthogonal layers:
  L3: hanzo.* ergonomic aliases
  L2: CDP-shape canonical primitives (Page.* DOM.* Input.* ...)
  L1: runFunc(tabId, fn, args) → scripting.executeScript({world: MAIN, func: ref})

Every DOM op now passes a real function reference to scripting.executeScript
instead of constructing Function(codeStr)(). The Function() path is blocked
by strict page CSP on sec.gov / github.com / any default-src 'self' SPA.
Direct function refs are exempt from page CSP per the WebExtension spec, so
the new path works everywhere.

Added (all CSP-safe):
  • hanzo.clickByText  fillByLabel  findByText  listForm  submitForm
  • hanzo.press        scroll        waitForText  waitForMutation
  • hanzo.uploadFile   dialogAccept  observe/observeRead/observeStop
  • Input.dispatchMouseEvent  Input.scrollWheel
  • DOM.querySelector  DOM.scrollIntoView  DOM.focus
  • Page.printToPDF

Rewrote with runFunc (CSP-safe):
  • hanzo.click  fill  check  uncheck  clear  getText  getHTML
  • hanzo.querySelectorAll

Capabilities handshake bumped to advertise the new method names.
Driver-side mapping in hanzo-tools-browser/browser_tool.py updated with
~100 snake_case action → wire-method entries (Python MCP surface).

API reference: dist/browser-extension/firefox/API.md
2026-06-10 11:21:29 -07:00
Hanzo AI d268fa9298 cleanup: remove AI-slop summary / status / plan / report files
Reverts violations of the durable rule that current-state docs belong
in LLM.md and history belongs in git log. Removed files were session
handoffs, agent-style "complete success" / "1000%" reports, dated
audit dumps, and stub NOTES.
2026-06-07 13:34:07 -07:00
Hanzo AI 44fccae452 chore: drop stale hanzo-auth.ts.bak
Fossilized copy left over from an earlier rewrite of packages/tools/src/auth.
The live hanzo-auth.ts is the source of truth.
2026-06-03 11:26:39 -07:00
Antje WorringandClaude Opus 4.7 d002cc136c ZAP wire fixes + WebGPU on-demand HF model loader
Fixes the browser ↔ hanzo-mcp ZAP path end to end:

- shared/zap.ts: vendor ZAP_PORTS constant and restore localhost
  port-probe fallback in discoverZapServers when the mDNS native
  helper isn't available. Sequential probe (stops on first hit) so
  the Chrome console stays quiet on the happy path.
- src/manifest.json: add deterministic ``key`` (extension ID
  ``biingenefmanpecedoafkfajbnlgdmbl``) + ``nativeMessaging``
  permission so the ``ai.hanzo.zap_mdns`` helper can be invoked.
- background.ts: configurable HF model source for the WebGPU
  runtime via chrome.storage (``hanzo_model_url`` or
  ``hanzo_model_hf={repo,file,revision}``); falls back through HF →
  bundled artifact → remote providers.
- webgpu-ai.ts: cache-first fetch backed by the service-worker
  Cache API + progress logs, ``WebGPUAI.huggingFaceURL`` helper, and
  ``WebGPUAI.evictCache`` for rotating model artefacts. Drop-in for
  the existing single-blob loader; lays groundwork for sparse-load
  MoE work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:24:31 -07:00
Hanzo AI 4aebce7014 extension(vscode): drop HANZO_IAM_ env-var prefix, use canonical IAM_
Aligns the standalone MCP server's auth path with the canonical IAM_
env contract enforced by hanzo-iam 1.30.0 (python-sdk 3cb6a34) — there
is exactly one prefix, IAM_, with no upstream-brand alias chain.

- HANZO_IAM_ENDPOINT       → IAM_ENDPOINT
- HANZO_IAM_LOGIN_URL      → IAM_LOGIN_URL
- HANZO_IAM_CLIENT_ID      → IAM_CLIENT_ID
- HANZO_IAM_CLIENT_SECRET  → IAM_CLIENT_SECRET

Updates docs/BUILD.md and docs/MCP_INSTALLATION.md to match.
2026-05-14 23:40:22 -07:00
Antje Worring 2287b6a707 extension(browser): gate legacy MCP+CDP WebSockets behind feature flag
Module-load WS connections to ws://localhost:3001/browser-extension
(legacy MCP) and ws://localhost:9223/cdp (legacy CDP bridge) spam
ERR_CONNECTION_REFUSED on every reload when no local hanzo-mcp / CDP
server is running.

ZAP mDNS discovery (startZapDiscoveryLoop / discoverZapServers, added
in 1.9.13–1.9.15) is now the canonical transport, so the legacy paths
are dead-on-load by default.

Adds ENABLE_LEGACY_TRANSPORTS = false (top of background.ts) and gates:
- connectToMCP() body (early-return when off)
- The module-load call site for connectToMCP()
- The module-load call site for cdpBridge.startWebSocketServer(cdpPort)

Flip the flag to true when developing against a local WS server.
ZAP path is untouched.
2026-05-14 15:16:53 -07:00
902 changed files with 91213 additions and 19047 deletions
+9
View File
@@ -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
View File
@@ -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() &&
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
runs-on: hanzo-deploy-linux-amd64
steps:
- uses: actions/checkout@v4
+468 -81
View File
@@ -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/*
+35
View File
@@ -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/*"
+1
View File
@@ -65,3 +65,4 @@ build/
.next/
*.log
tmp/
.npmrc
+49
View File
@@ -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
+102
View File
@@ -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
+55 -42
View File
@@ -1,52 +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 (since browser-extension 2.0.0)
- `shared/native-zap.ts` — the ONE transport: `connectNativeZap()` opens the native
port (**singleton** — one port; the router does evict-and-replace), registers as
provider `browser:<engine>/<host>/default`, dispatches inbound ROUTE commands.
Cross-browser (`browser ∥ chrome`).
- `background.ts` (Chrome) — dispatch via `chrome.debugger` (CDP→tab; shows the
"debugging this browser" banner; WebKit-incompatible). **To be removed** — see WXT plan.
- `background-firefox.ts` — native `browser.*` dispatch (no banner). The correct model.
- `browser-dispatch.ts``chrome.debugger` actuation (Chrome-only).
- `shared/evaluable.ts` — the ONE evaluate rule: `pickEvaluable` (code-param
aliases) + `wrapEvaluable` (bare expression passes through; statement bodies
→ async IIFE with the trailing value auto-returned). Both dispatch paths
consume it, so Chrome and Firefox accept identical caller JS.
Two-process: each Python `hanzo-mcp` hosts a ZAP server directly. The
extension discovers it on the lowest free port from
`[9999, 9998, 9997, 9996, 9995]` and dispatches commands over a binary
WebSocket frame:
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`).
```
[0x5A 0x41 0x50 0x01][type:1][length:4 BE][JSON payload]
```
## 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).
- **shared/zap.ts**canonical wire constants (MSG_REQUEST=0x10,
MSG_RESPONSE=0x11, MSG_PING=0xFE, MSG_PONG=0xFF). Both Chrome and
Firefox extensions use this. Includes `setZapRequestHandler(mgr, fn)`
so the Python server can dispatch RPCs back to the extension.
- **background.ts** (Chrome) — wires `cdpBridge.dispatchMethod` as the
ZAP inbound handler.
- **background-firefox.ts** — wires `HanzoFirefoxExtension.dispatchMethod`
as the ZAP inbound handler.
- **cdp-bridge-server.ts** — DEPRECATED. Legacy node bridge on
`:9223/9224`. Fallback only; not in the critical path.
## CI/CD (nativeHanzo 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.
Wire constants must stay locked across implementations:
- `python-sdk/pkg/hanzo-tools-browser/hanzo_tools/browser/zap_server.py`
- `extension/packages/browser/src/shared/zap.ts`
- `extension/packages/mcp/src/zap-server.ts`
## Cross-platform — WXT migration (canonical plan, not yet executed)
If you change them, change all three and update `tests/shared-zap.test.ts`
+ `tests/test_zap_server.py::TestWireFormat::test_constants_match_extension`.
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).
## Versioning rule
Bump patch (X.Y.Z+1) for protocol-compatible changes. Bump major (e.g.
1.x → 2.0) only when the wire format changes or the architecture pivots
(2.0.0 = ZAP becomes canonical, dropping the node bridge from default
boot). `package.json` and both `manifest*.json` must agree.
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 14 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
View File
@@ -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
+2
View File
@@ -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 🚀
[![VS Code Extension CI/CD](https://github.com/hanzoai/dev/workflows/VS%20Code%20Extension%20CI%2FCD/badge.svg)](https://github.com/hanzoai/dev/actions/workflows/vscode-extension.yml)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -144,7 +144,7 @@ VS Code extensions are signed automatically when published to the marketplace.
- `VSCODE_ENV`: Set to 'development' or 'production'
- `HANZO_WORKSPACE`: Default workspace for MCP operations
- `HANZO_ANONYMOUS`: Set to 'true' for anonymous mode
- `HANZO_IAM_ENDPOINT`: Custom IAM endpoint (default: https://iam.hanzo.ai)
- `IAM_ENDPOINT`: Custom IAM endpoint (default: https://iam.hanzo.ai)
## Testing Builds
+4 -4
View File
@@ -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)**
+1 -1
View File
@@ -184,7 +184,7 @@ The extension will generate `.windsurfrules` file.
- `HANZO_ANONYMOUS` - Set to 'true' for anonymous mode
- `MCP_TRANSPORT` - Transport method (stdio or tcp)
- `MCP_PORT` - Port for TCP transport (default: 3000)
- `HANZO_IAM_ENDPOINT` - Custom IAM endpoint (default: https://iam.hanzo.ai)
- `IAM_ENDPOINT` - Custom IAM endpoint (default: https://iam.hanzo.ai)
### Tool Configuration
-195
View File
@@ -1,195 +0,0 @@
# Comprehensive Tool Testing Report
## Overview
All core tools have been implemented, tested, and benchmarked. The extension now supports 56 tools total, with 27 enabled by default.
## Tool Implementation Status
### ✅ Fully Implemented and Tested Tools
#### File System Tools (6/6)
-**read** - Read file contents with line numbers
-**write** - Write content to files
-**edit** - Edit files by replacing patterns
-**multi_edit** - Multiple edits in one operation
-**directory_tree** - Display directory structure
-**find_files** - Find files matching patterns
#### Search Tools (4/4)
-**grep** - Pattern search using ripgrep
-**search** - Unified search across files/symbols/git
-**symbols** - Search code symbols
-**unified_search** - Parallel search across all types
#### Shell Tools (3/3)
-**run_command** - Execute shell commands
-**open** - Open files/URLs
-**process** - Background process management with logging
#### Development Tools (5/5)
-**todo_read** - Read todo list
-**todo_write** - Write todo items
-**todo_unified** - Unified todo management
-**think** - Structured thinking space
-**critic** - Code review and analysis
#### Configuration Tools (3/3)
-**config** - Git-style configuration
-**rules** - Project conventions (.cursorrules, .clauderc)
-**palette** - Tool personality switching
#### Database & AI Tools (6/6)
-**graph_db** - Graph database with AST integration
-**vector_index** - Index documents for vector search
-**vector_search** - Semantic search with embeddings
-**vector_similar** - Find similar documents
-**document_store** - Chat document management
-**zen** - Hanzo Zen1 AI model (local/cloud)
#### Utility Tools (2/2)
-**batch** - Batch operations
-**web_fetch** - Fetch and analyze web content
## Test Results
### Functionality Tests
All key tools passed functionality tests:
- ✅ think - Thought recording works
- ✅ critic - Code analysis works
- ✅ unified_search - Parallel search works
- ✅ graph_db - Node/edge operations work
- ✅ vector_index - Document indexing works
- ✅ document_store - Document management works
- ✅ web_fetch - Web content fetching works
- ✅ palette - Tool switching works
- ✅ config - Configuration management works
- ✅ process - Background process management works
### Performance Benchmarks
#### Graph Database
- **Add nodes**: 6,768 nodes/ms (excellent)
- **Query performance**: < 0.1ms for most operations
- **Path finding**: < 0.01ms average
- **Connected components**: 0.42ms for full analysis
#### Vector Store
- **Index documents**: 211 documents/ms
- **Search performance**: 0.49ms average (🟢 Fast)
- **Filtered search**: 0.24ms average
- **Metadata search**: 0.11ms average
#### AST Index
- **File indexing**: ~5,610 files/second
- **Symbol search**: < 0.02ms
- **Reference finding**: < 0.01ms
- **Call hierarchy**: 0.02ms average
#### Document Store
- **Add documents**: 13ms per batch
- **Search documents**: < 0.01ms
- **Session save**: 1.64ms average
## Backend Abstraction
### Local vs Cloud Support
**Local Backend**
- In-memory graph database
- Local vector store with mock embeddings
- File-based document persistence
- Support for Ollama and LM Studio
- Hanzo local model support
**Cloud Backend**
- API-based operations
- Real embeddings from cloud
- Persistent storage
- Authentication via API key
- Unified interface with local
### AI Model Support
**Local AI Providers**
- Ollama (auto-detected at localhost:11434)
- LM Studio (auto-detected at localhost:1234)
- Hanzo Local Models (zen1, zen1-mini, zen1-code)
**Cloud AI Providers**
- Hanzo Cloud API
- Fallback to OpenAI/Anthropic
- Unified LLM interface
## Critic Tool Capabilities
The critic tool provides comprehensive code analysis:
1. **Security Analysis**
- SQL injection detection
- XSS vulnerability checks
- Authentication/authorization issues
- Sensitive data exposure
2. **Performance Analysis**
- Algorithm complexity
- Database query optimization
- Memory leak detection
- Unnecessary computations
3. **Code Quality**
- Naming conventions
- Code organization
- Documentation coverage
- Error handling
4. **Correctness**
- Logic errors
- Edge case handling
- Type safety
- Test coverage
## Missing/Partial Implementations
### Tools Not Yet Implemented
- ❌ AST analyzer (compilation issues with TypeScript parser)
- ❌ Tree-sitter analyzer (module resolution issues)
- ❌ Jupyter notebook tools (notebook_read, notebook_edit)
- ❌ SQL database tools (sql_query, sql_search)
- ❌ LLM consensus tool
- ❌ Agent dispatch tool
- ❌ Some system tools (memory, date, copy, move, delete)
### Limitations
- Vector store uses mock embeddings (real embeddings need API integration)
- Graph database is in-memory only for local mode
- Document store requires file system access
## Recommendations
1. **Enable More Tools by Default**
- Consider enabling graph_db, vector tools, and zen by default
- These are now fully tested and performant
2. **Real Embeddings**
- Integrate with OpenAI/Cohere for real embeddings
- Or use local sentence-transformers
3. **Persistent Storage**
- Add SQLite backend option for local persistence
- Implement proper backup/restore
4. **Complete AST Integration**
- Fix TypeScript compilation issues
- Add tree-sitter support for more languages
## Summary
**27 tools** fully implemented and tested
**Excellent performance** across all subsystems
**Local and cloud** backend abstraction working
**AI model integration** for Ollama, LM Studio, and Hanzo
**Comprehensive testing** with benchmarks
The extension is production-ready with all core functionality working as expected.
+85 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/extension",
"version": "1.9.14",
"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"
]
}
}
+11 -4
View File
@@ -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"
]
}
-12
View File
@@ -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.
-236
View File
@@ -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
-369
View File
@@ -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
});
```
-263
View File
@@ -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 };
-5062
View File
File diff suppressed because it is too large Load Diff
-83
View File
@@ -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"
}
}
-429
View File
@@ -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
View File
@@ -1 +0,0 @@
export async function embed(params: any): Promise<any> { return { embeddings: [] }; }
-1
View File
@@ -1 +0,0 @@
export async function generateObject(params: any): Promise<any> { return {}; }
-1
View File
@@ -1 +0,0 @@
export async function* generateStream(params: any): AsyncIterableIterator<any> { yield { type: 'done' }; }
-1
View File
@@ -1 +0,0 @@
export async function generateText(params: any): Promise<any> { return { text: '' }; }
-418
View File
@@ -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);
}
-38
View File
@@ -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);
}
-257
View File
@@ -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);
}
-235
View File
@@ -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;
}
})
};
-45
View File
@@ -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';
}
}
-45
View File
@@ -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';
-461
View File
@@ -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;
}
-57
View File
@@ -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
}
}
-7
View File
@@ -1,7 +0,0 @@
/**
* MCP exports
*/
export * from './types';
export * from './client';
export * from './agent-server';
-36
View File
@@ -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[];
}
-20
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 }; } });
-20
View File
@@ -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
View File
@@ -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 }; } });
-18
View File
@@ -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();
});
});
});
-363
View File
@@ -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);
}
-352
View File
@@ -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';
-58
View File
@@ -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;
}
-8
View File
@@ -1,8 +0,0 @@
import { nanoid } from 'nanoid';
/**
* Creates a unique completion ID
*/
export function createCompletionId(): string {
return `cmpl-${nanoid()}`;
}
-16
View File
@@ -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 };
}
}
-25
View File
@@ -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);
}
-14
View File
@@ -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'],
})
-26
View File
@@ -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')
}
}
});
+26
View File
@@ -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"
]
}
+53
View File
@@ -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(/\/+$/, '');
}
+103
View File
@@ -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;
}
}
+21
View File
@@ -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 };
}
+133
View File
@@ -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 '';
}
}
+36
View File
@@ -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 };
}
+32
View File
@@ -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>;
}
+39
View File
@@ -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',
);
});
});
+106
View File
@@ -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');
});
});
+88
View File
@@ -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',
);
});
});
+14
View File
@@ -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"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+9 -9
View File
@@ -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!!!',
+15 -9
View File
@@ -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();
});
});
+23 -7
View File
@@ -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');
+95
View File
@@ -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)"
+15 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/browser-extension",
"version": "1.9.15",
"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",
@@ -25,16 +27,25 @@
"@types/firefox-webext-browser": "^120.0.0",
"@types/jsdom": "^21.1.7",
"@types/ws": "^8.18.1",
"esbuild": "^0.25.6",
"esbuild": "^0.25.8",
"jsdom": "^26.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.8.3",
"vitest": "^0.34.6",
"vitest": "^3.2.6",
"ws": "^8.18.3"
},
"engines": {
"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>
);
}
+80
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 };
}
+83
View File
@@ -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 [];
}
}
+725 -112
View File
@@ -20,16 +20,15 @@ import 'webextension-polyfill';
import type { ControlSession, RagSnippet, RagQueryParams } from './shared/types.js';
import {
createZapManager,
discoverZapServers,
startZapDiscoveryLoop,
connectZap,
zapRequest,
zapCallTool,
hasZapTool,
handleZapMessage,
setZapRequestHandler,
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,
@@ -111,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 {
@@ -173,13 +176,24 @@ class HanzoFirefoxExtension {
'select', 'check', 'uncheck', 'press_key', 'content', 'snapshot',
'getText', 'getHTML', 'getAttribute', 'getElementInfo', 'getPageInfo',
'querySelectorAll', 'waitForSelector', 'waitForNavigation',
// CSP-safe text/label primitives + composite waits (1.9.16+)
'clickByText', 'fillByLabel', 'findByText', 'listForm',
'press', 'scroll', 'waitForText', 'waitForMutation',
'submitForm', 'uploadFile', 'dialogAccept',
'observe', 'observeRead', 'observeStop',
// Coordinate-level Input.* (1.9.16+)
'Input.dispatchMouseEvent', 'Input.scrollWheel',
// DOM.* + Page.* aliases (1.9.16+)
'DOM.querySelector', 'DOM.scrollIntoView', 'DOM.focus',
'Page.printToPDF',
// Existing legacy
'fetch', 'cookies', 'localStorage', 'injectScript', 'injectCSS',
'createTab', 'closeTab', 'activateTab', 'history',
'observeMutations', 'computedStyles', 'boundingRects',
'setHTML', 'setText', 'setAttribute', 'removeAttribute',
'setStyle', 'addClass', 'removeClass', 'insertElement', 'removeElement',
'monitor', 'audit'
]
'monitor', 'audit',
],
});
}
@@ -352,6 +366,76 @@ class HanzoFirefoxExtension {
return result;
}
/**
* CSP-safe alternative to runInPage. Passes a real function reference
* (and serializable args) to scripting.executeScript. Because no
* Function() or eval() is constructed at runtime, page CSP that
* blocks 'unsafe-eval' does NOT block this path. Works on .gov, on
* sites that ship strict default-src 'self', and on github.com.
*
* Use this for any DOM read/write the bridge needs to do. The
* `fn` argument is a normal JS function — it is serialized by
* Firefox/Chrome's scripting API and executed in the MAIN world.
*
* Added in 1.9.16 alongside the CDP-shaped decomplected surface.
*/
private async runFunc<R = any>(
tabId: number,
fn: (...args: any[]) => R,
args: any[] = [],
timeoutMs: number = 10000,
): Promise<R> {
let tab: chrome.tabs.Tab | undefined;
try { tab = await browser.tabs.get(tabId); } catch { /* best-effort */ }
if (tab && isPrivilegedTab(tab)) {
throw new Error(
`Cannot run JS in privileged tab ${tabId} (${tab.url}).`,
);
}
const scripting = (browser as any).scripting;
if (!scripting?.executeScript) {
throw new Error('scripting.executeScript not available (need MV3 scripting permission)');
}
const exec = (async () => {
const runIn = async (world: 'MAIN' | 'ISOLATED'): Promise<any[]> => {
const results = await scripting.executeScript({
target: { tabId },
world,
args,
func: fn,
});
// Keep the full InjectionResult shape ({result, error, frameId}) so
// we can propagate page-side throws (1.9.18). Previously we mapped
// to r?.result which silently dropped errors → callers saw null.
return Array.isArray(results) ? results : [results];
};
let out: any[];
try {
out = await runIn('MAIN');
} catch (e) {
out = await runIn('ISOLATED');
}
if (!Array.isArray(out) || out.length === 0) {
throw new Error(
`runFunc returned no frames for tab ${tabId} — page may be privileged or unloaded.`,
);
}
const first = out[0] || {};
if (first.error) {
throw new Error(`runFunc page-side error: ${first.error}`);
}
const result = first.result;
if (result && typeof result === 'object' && (result as any).__hanzo_error) {
throw new Error((result as any).__hanzo_error);
}
return result === undefined ? (null as any) : result;
})();
return Promise.race([
exec,
new Promise<R>((_, reject) => setTimeout(() => reject(new Error('runFunc timeout')), timeoutMs)),
]);
}
/**
* Public dispatcher for ZAP-server-initiated requests. Mirrors
* `CDPBridge.dispatchMethod` on the Chrome side so background-ZAP wiring
@@ -529,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;
@@ -666,17 +742,36 @@ class HanzoFirefoxExtension {
// =====================================================================
// Click / Interaction
// =====================================================================
case 'hanzo.click': {
case 'hanzo.click': {
if (!tabId || !params.selector) throw new Error('No active tab or selector');
const sel = this.escapeSelector(params.selector as string);
this.notifyOverlay(tabId, 'ai.control.highlight', { selector: params.selector as string });
this.notifyOverlay(tabId, 'ai.control.status', { text: `Clicking ${params.selector}` });
const clicked = await this.runInPage(tabId, `
var el = document.querySelector('${sel}');
if (el) { el.click(); return true; }
return false;
`);
return { success: !!clicked };
const result = await this.runFunc<any>(tabId, (selector: string) => {
const el = document.querySelector(selector) as HTMLElement | null;
if (!el) return { clicked: false, reason: 'not-found' };
try { el.scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior }); } catch (e) {}
const r = el.getBoundingClientRect();
const x = r.left + r.width / 2;
const y = r.top + r.height / 2;
const init: MouseEventInit = {
bubbles: true, cancelable: true, composed: true, view: window,
button: 0, buttons: 1, clientX: x, clientY: y, screenX: x, screenY: y, detail: 1,
};
try {
el.dispatchEvent(new MouseEvent('mouseover', init));
el.dispatchEvent(new MouseEvent('mouseenter', init));
el.dispatchEvent(new MouseEvent('mousemove', init));
el.dispatchEvent(new MouseEvent('mousedown', init));
try { el.focus({ preventScroll: true }); } catch (e) {}
el.dispatchEvent(new MouseEvent('mouseup', { ...init, buttons: 0 }));
el.dispatchEvent(new MouseEvent('click', { ...init, buttons: 0 }));
return { clicked: true, tag: el.tagName, x, y, isTrusted: false };
} catch (e) {
try { (el as HTMLElement).click(); return { clicked: true, fallback: 'el.click()', isTrusted: false }; }
catch (e2) { return { clicked: false, error: String(e2) }; }
}
}, [params.selector as string]);
return { success: !!(result && (result as any).clicked), detail: result };
}
case 'hanzo.dblclick': {
@@ -712,20 +807,44 @@ class HanzoFirefoxExtension {
// =====================================================================
case 'hanzo.fill': {
if (!tabId || !params.selector) throw new Error('No active tab or selector');
const sel = this.escapeSelector(params.selector as string);
const val = this.escapeValue((params.value as string) || '');
this.notifyOverlay(tabId, 'ai.control.highlight', { selector: params.selector as string });
this.notifyOverlay(tabId, 'ai.control.status', { text: `Filling ${params.selector}` });
await this.runInPage(tabId, `
var el = document.querySelector('${sel}');
if (el) {
el.focus();
el.value = '${val}';
el.dispatchEvent(new Event('input', {bubbles: true}));
el.dispatchEvent(new Event('change', {bubbles: true}));
const ok = await this.runFunc<boolean>(tabId, (selector: string, value: string) => {
const el = document.querySelector(selector) as HTMLInputElement | HTMLTextAreaElement | HTMLElement | null;
if (!el) return false;
try { (el as HTMLElement).scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior }); } catch (e) {}
try { (el as HTMLElement).focus(); } catch (e) {}
const tag = (el as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
// React-compatible: per-tag native setter. Calling
// HTMLInputElement's setter on a TEXTAREA throws "Illegal
// invocation" — was the silent failure on SEC TCR (1.9.18 fix).
let setter: any = null;
try {
if (tag === 'TEXTAREA') {
setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
} else if (tag === 'INPUT') {
setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
} else if (tag === 'SELECT') {
setter = Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype, 'value')?.set;
}
} catch (e) { setter = null; }
try {
if (setter) setter.call(el, value);
else (el as HTMLInputElement).value = value;
} catch (e) { (el as HTMLInputElement).value = value; }
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
`);
return { success: true };
if ((el as HTMLElement).isContentEditable) {
(el as HTMLElement).textContent = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}
return false;
}, [params.selector as string, (params.value as string) || '']);
return { success: !!ok };
}
case 'hanzo.type': {
@@ -749,16 +868,17 @@ class HanzoFirefoxExtension {
case 'hanzo.clear': {
if (!tabId || !params.selector) throw new Error('No active tab or selector');
const sel = this.escapeSelector(params.selector as string);
await this.runInPage(tabId, `
var el = document.querySelector('${sel}');
if (el) {
el.value = '';
el.dispatchEvent(new Event('input', {bubbles: true}));
el.dispatchEvent(new Event('change', {bubbles: true}));
}
`);
return { success: true };
const ok = await this.runFunc<boolean>(tabId, (selector: string) => {
const el = document.querySelector(selector) as HTMLInputElement | HTMLTextAreaElement | null;
if (!el) return false;
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|| Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
if (setter) setter.call(el, ''); else (el as HTMLInputElement).value = '';
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}, [params.selector as string]);
return { success: !!ok };
}
case 'hanzo.select': {
@@ -777,22 +897,26 @@ class HanzoFirefoxExtension {
case 'hanzo.check': {
if (!tabId || !params.selector) throw new Error('No active tab or selector');
const sel = this.escapeSelector(params.selector as string);
await this.runInPage(tabId, `
var el = document.querySelector('${sel}');
if (el && !el.checked) { el.click(); }
`);
return { success: true };
const ok = await this.runFunc<boolean>(tabId, (selector: string) => {
const el = document.querySelector(selector) as HTMLInputElement | null;
if (!el) return false;
try { el.scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior }); } catch (e) {}
if (!el.checked) el.click();
return true;
}, [params.selector as string]);
return { success: !!ok };
}
case 'hanzo.uncheck': {
if (!tabId || !params.selector) throw new Error('No active tab or selector');
const sel = this.escapeSelector(params.selector as string);
await this.runInPage(tabId, `
var el = document.querySelector('${sel}');
if (el && el.checked) { el.click(); }
`);
return { success: true };
const ok = await this.runFunc<boolean>(tabId, (selector: string) => {
const el = document.querySelector(selector) as HTMLInputElement | null;
if (!el) return false;
try { el.scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior }); } catch (e) {}
if (el.checked) el.click();
return true;
}, [params.selector as string]);
return { success: !!ok };
}
case 'Input.dispatchKeyEvent': {
@@ -815,22 +939,21 @@ class HanzoFirefoxExtension {
// =====================================================================
case 'hanzo.getText': {
if (!tabId || !params.selector) throw new Error('No active tab or selector');
const sel = this.escapeSelector(params.selector as string);
const text = await this.runInPage(tabId, `
var el = document.querySelector('${sel}');
return el ? el.textContent : null;
`);
const text = await this.runFunc<string | null>(tabId, (selector: string) => {
const el = document.querySelector(selector);
return el ? (el.textContent || '') : null;
}, [params.selector as string]);
return { result: { value: text } };
}
case 'hanzo.getHTML': {
if (!tabId || !params.selector) throw new Error('No active tab or selector');
const sel = this.escapeSelector(params.selector as string);
const outer = params.outer !== false;
const html = await this.runInPage(tabId, `
var el = document.querySelector('${sel}');
return el ? ${outer ? 'el.outerHTML' : 'el.innerHTML'} : null;
`);
const html = await this.runFunc<string | null>(tabId, (selector: string, useOuter: boolean) => {
const el = document.querySelector(selector);
if (!el) return null;
return useOuter ? el.outerHTML : el.innerHTML;
}, [params.selector as string, outer]);
return { result: { value: html } };
}
@@ -883,25 +1006,464 @@ class HanzoFirefoxExtension {
case 'hanzo.querySelectorAll': {
if (!tabId || !params.selector) throw new Error('No active tab or selector');
const sel = this.escapeSelector(params.selector as string);
const elements = await this.runInPage(tabId, `
var els = document.querySelectorAll('${sel}');
var result = [];
for (var i = 0; i < Math.min(els.length, 100); i++) {
var el = els[i];
var rect = el.getBoundingClientRect();
result.push({
index: i, tagName: el.tagName, id: el.id, className: el.className,
textContent: (el.textContent || '').trim().slice(0, 100),
value: el.value, href: el.href,
visible: !!(rect.width && rect.height)
const elements = await this.runFunc<any[]>(tabId, (selector: string, limit: number) => {
const els = document.querySelectorAll(selector);
const out: any[] = [];
const n = Math.min(els.length, limit);
for (let i = 0; i < n; i++) {
const el = els[i] as HTMLElement;
const rect = el.getBoundingClientRect();
out.push({
index: i,
tagName: el.tagName,
id: el.id || '',
className: typeof el.className === 'string' ? el.className : '',
name: el.getAttribute('name') || '',
type: el.getAttribute('type') || '',
role: el.getAttribute('role') || '',
ariaLabel: el.getAttribute('aria-label') || '',
value: (el as HTMLInputElement).value || '',
href: (el as HTMLAnchorElement).href || '',
textContent: (el.textContent || '').trim().slice(0, 200),
visible: !!(rect.width && rect.height),
});
}
return result;
`);
return out;
}, [params.selector as string, (params.limit as number) || 100]);
return { elements: elements || [] };
}
// ─────────────────────────────────────────────────────────────────
// CDP-shaped decomplected surface (1.9.16+). See dist/API.md for the
// canonical reference. Three orthogonal layers:
// 1) Wire transport (JSON over WS/HTTP)
// 2) CDP-shaped canonical primitives (Page.* DOM.* Input.* ...)
// 3) hanzo.* ergonomic aliases that compose canonical primitives
// Every DOM-touching method goes through runFunc → executeScript with
// a direct function reference. NEVER Function() / eval(). That is what
// makes them CSP-safe on .gov, github.com, and any strict-CSP SPA.
// ─────────────────────────────────────────────────────────────────
case 'Input.dispatchMouseEvent': {
if (!tabId) throw new Error('No active tab');
const result = await this.runFunc<any>(tabId, (p: any) => {
const { type, x, y, button = 0, clickCount = 1, modifiers = 0 } = p;
const target = document.elementFromPoint(x, y) as HTMLElement | null;
if (!target) return { delivered: false, reason: 'no element at point' };
const mods = {
altKey: !!(modifiers & 1), ctrlKey: !!(modifiers & 2),
metaKey: !!(modifiers & 4), shiftKey: !!(modifiers & 8),
};
const init: MouseEventInit = {
bubbles: true, cancelable: true, view: window,
button, buttons: type === 'mousePressed' ? 1 : 0,
clientX: x, clientY: y, screenX: x, screenY: y, detail: clickCount,
...mods,
};
const map: Record<string, string> = { mouseMoved: 'mousemove', mousePressed: 'mousedown', mouseReleased: 'mouseup' };
target.dispatchEvent(new MouseEvent(map[type] || type.toLowerCase(), init));
if (type === 'mousePressed' && clickCount >= 1) {
target.dispatchEvent(new MouseEvent('click', init));
}
return { delivered: true, target: target.tagName, x, y };
}, [params]);
return result;
}
case 'Input.scrollWheel': {
if (!tabId) throw new Error('No active tab');
const result = await this.runFunc<any>(tabId, (p: any) => {
const { x = 0, y = 0, deltaX = 0, deltaY = 0 } = p;
const target = (x || y)
? (document.elementFromPoint(x, y) as HTMLElement | null)
: ((document.scrollingElement || document.body) as HTMLElement | null);
if (!target) return { delivered: false };
target.dispatchEvent(new WheelEvent('wheel', {
bubbles: true, cancelable: true, deltaX, deltaY, clientX: x, clientY: y,
}));
if (target === document.scrollingElement || target === document.body) {
window.scrollBy({ left: deltaX, top: deltaY, behavior: 'instant' as ScrollBehavior });
}
return { delivered: true };
}, [params]);
return result;
}
case 'Page.printToPDF': {
if (!tabId) throw new Error('No active tab');
await this.runFunc<boolean>(tabId, () => { window.print(); return true; }, []);
return {
opened: true,
note: 'Firefox extension cannot programmatically save PDF. ' +
'For headless PDF export use Page.captureScreenshot with fullPage=true ' +
'and convert PNG→PDF on the client side.',
};
}
case 'DOM.querySelector': {
if (!tabId || !params.selector) throw new Error('No active tab or selector');
const element = await this.runFunc<any>(tabId, (selector: string) => {
const el = document.querySelector(selector) as HTMLElement | null;
if (!el) return null;
const rect = el.getBoundingClientRect();
return {
tagName: el.tagName, id: el.id || '',
className: typeof el.className === 'string' ? el.className : '',
textContent: (el.textContent || '').trim().slice(0, 200),
value: (el as HTMLInputElement).value || '',
visible: !!(rect.width && rect.height),
rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
};
}, [params.selector as string]);
return { element };
}
case 'DOM.scrollIntoView': {
if (!tabId || !params.selector) throw new Error('No active tab or selector');
const ok = await this.runFunc<boolean>(tabId, (selector: string) => {
const el = document.querySelector(selector) as HTMLElement | null;
if (!el) return false;
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
return true;
}, [params.selector as string]);
return { success: !!ok };
}
case 'DOM.focus': {
if (!tabId || !params.selector) throw new Error('No active tab or selector');
const ok = await this.runFunc<boolean>(tabId, (selector: string) => {
const el = document.querySelector(selector) as HTMLElement | null;
if (!el) return false;
el.focus();
return true;
}, [params.selector as string]);
return { success: !!ok };
}
case 'hanzo.clickByText': {
if (!tabId || !params.text) throw new Error('No active tab or text');
const result = await this.runFunc<any>(tabId, (text: string, scope: string) => {
const norm = (s: string) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const want = norm(text);
const tags = scope === 'all'
? 'button, a, input[type=button], input[type=submit], input[type=radio], input[type=checkbox], label, [role=button], [role=radio], [role=checkbox], [role=tab], [role=menuitem]'
: 'button, a, label, [role=button], [role=radio], [role=checkbox], [role=tab], [role=menuitem]';
const els = Array.from(document.querySelectorAll(tags)) as HTMLElement[];
const matches = els.filter((el) => {
const t = norm(el.innerText || (el as HTMLInputElement).value || el.getAttribute('aria-label') || '');
return t === want || t.includes(want);
});
const exact = matches.find((el) => norm(el.innerText || (el as HTMLInputElement).value || el.getAttribute('aria-label') || '') === want) || matches[0];
if (!exact) return { clicked: false, candidates: matches.length };
try { exact.scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior }); } catch (e) {}
exact.click();
return { clicked: true, tag: exact.tagName, text: (exact.innerText || (exact as HTMLInputElement).value || '').slice(0, 100) };
}, [params.text as string, (params.scope as string) || 'default']);
return result || { clicked: false };
}
case 'hanzo.fillByLabel': {
if (!tabId || !params.label) throw new Error('No active tab or label');
const result = await this.runFunc<any>(tabId, (labelText: string, value: string) => {
const norm = (s: string) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const want = norm(labelText);
const labels = Array.from(document.querySelectorAll('label')) as HTMLLabelElement[];
const lbl = labels.find((l) => norm(l.innerText) === want) || labels.find((l) => norm(l.innerText).includes(want));
let input: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null = null;
if (lbl) {
const forId = lbl.getAttribute('for');
if (forId) input = document.getElementById(forId) as any;
if (!input) input = lbl.querySelector('input, textarea, select');
}
if (!input) {
const all = Array.from(document.querySelectorAll('input, textarea, select')) as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)[];
input = all.find((el) =>
norm(el.getAttribute('aria-label') || '') === want
|| norm(el.getAttribute('placeholder') || '') === want
|| norm(el.getAttribute('name') || '') === want
|| norm(el.getAttribute('id') || '') === want
) || null;
}
if (!input) return { filled: false };
try { input.scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior }); } catch (e) {}
input.focus();
const tag = input.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|| Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
if (setter) setter.call(input, value); else (input as HTMLInputElement).value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
return { filled: true, name: input.name || input.id };
}
if (tag === 'SELECT') {
(input as HTMLSelectElement).value = value;
input.dispatchEvent(new Event('change', { bubbles: true }));
return { filled: true, name: input.name || input.id };
}
return { filled: false };
}, [params.label as string, (params.value as string) || '']);
return result || { filled: false };
}
case 'hanzo.findByText': {
if (!tabId || !params.text) throw new Error('Missing text');
const result = await this.runFunc<any[]>(tabId, (text: string, scope: string, limit: number) => {
const norm = (s: string) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const want = norm(text);
const tagSel = scope === 'all' ? '*' : 'button, a, label, input, select, textarea, [role=button], [role=radio], [role=checkbox], [role=tab], [role=menuitem], h1, h2, h3, h4, h5, h6, p, span, li, td';
const els = Array.from(document.querySelectorAll(tagSel)) as HTMLElement[];
const matches: any[] = [];
for (let i = 0; i < els.length && matches.length < limit; i++) {
const el = els[i];
const t = norm(el.innerText || (el as HTMLInputElement).value || el.getAttribute('aria-label') || '');
if (!t) continue;
if (t === want || t.includes(want)) {
const rect = el.getBoundingClientRect();
matches.push({
index: i, tag: el.tagName,
id: el.id || '',
className: typeof el.className === 'string' ? el.className : '',
text: t.slice(0, 200), exact: t === want,
rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
visible: !!(rect.width && rect.height),
});
}
}
matches.sort((a, b) => (b.exact ? 1 : 0) - (a.exact ? 1 : 0));
return matches;
}, [params.text as string, (params.scope as string) || 'default', (params.limit as number) || 50]);
return { matches: result || [] };
}
case 'hanzo.listForm': {
if (!tabId) throw new Error('No active tab');
const inputs = await this.runFunc<any[]>(tabId, () => {
const all = Array.from(document.querySelectorAll('input, textarea, select, button, label, [role=radio], [role=checkbox], [role=button]')) as HTMLElement[];
return all.slice(0, 200).map((el, i) => {
const rect = el.getBoundingClientRect();
const lblFor = el.id ? document.querySelector(`label[for="${el.id}"]`) as HTMLElement | null : null;
return {
i,
tag: el.tagName,
type: el.getAttribute('type') || '',
name: el.getAttribute('name') || '',
id: el.id || '',
role: el.getAttribute('role') || '',
ariaLabel: el.getAttribute('aria-label') || '',
placeholder: el.getAttribute('placeholder') || '',
value: (el as HTMLInputElement).value || '',
checked: !!(el as HTMLInputElement).checked,
labelText: (lblFor?.innerText || '').trim().slice(0, 200),
innerText: (el.innerText || '').trim().slice(0, 200),
visible: !!(rect.width && rect.height),
};
});
}, []);
return { inputs: inputs || [] };
}
case 'hanzo.press': {
if (!tabId || !params.key) throw new Error('Missing key');
const result = await this.runFunc<any>(tabId, (key: string, modifiers: any) => {
const el = (document.activeElement || document.body) as HTMLElement;
const mods = {
altKey: !!modifiers?.alt, ctrlKey: !!modifiers?.ctrl,
metaKey: !!modifiers?.meta, shiftKey: !!modifiers?.shift,
};
const keyCodeMap: Record<string, number> = {
Enter: 13, Tab: 9, Escape: 27, Backspace: 8, Delete: 46,
ArrowUp: 38, ArrowDown: 40, ArrowLeft: 37, ArrowRight: 39,
Home: 36, End: 35, PageUp: 33, PageDown: 34, ' ': 32,
};
const keyCode = keyCodeMap[key] ?? (key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0);
const init: any = { key, code: key, keyCode, which: keyCode, bubbles: true, cancelable: true, ...mods };
el.dispatchEvent(new KeyboardEvent('keydown', init));
if (key.length === 1) el.dispatchEvent(new KeyboardEvent('keypress', init));
el.dispatchEvent(new KeyboardEvent('keyup', init));
if (key === 'Enter' && el.tagName === 'INPUT') {
const form = (el as HTMLInputElement).form;
if (form) { try { (form as any).requestSubmit?.() || form.submit?.(); } catch (e) {} }
}
return { delivered: true, target: el.tagName, keyCode };
}, [params.key as string, params.modifiers || {}]);
return result;
}
case 'hanzo.scroll': {
if (!tabId) throw new Error('No active tab');
const result = await this.runFunc<any>(tabId, (target: any, amount: any) => {
if (target === 'top') { window.scrollTo({ top: 0, behavior: 'smooth' }); return { scrolled: 'top' }; }
if (target === 'bottom') { window.scrollTo({ top: document.body?.scrollHeight || 0, behavior: 'smooth' }); return { scrolled: 'bottom' }; }
if (typeof target === 'string' && target) {
const el = document.querySelector(target) as HTMLElement | null;
if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); return { scrolled: 'into-view', selector: target }; }
return { scrolled: null, error: 'selector not found', selector: target };
}
if (amount && (amount.x || amount.y)) {
window.scrollBy({ left: amount.x || 0, top: amount.y || 0, behavior: 'smooth' });
return { scrolled: 'by', ...amount };
}
return { scrolled: null, error: 'no target or amount' };
}, [params.target || params.selector || null, params.amount || null]);
return result;
}
case 'hanzo.waitForText': {
if (!tabId || !params.text) throw new Error('Missing text');
const timeoutMs = (params.timeout as number) || 15000;
const interval = (params.interval as number) || 250;
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const found = await this.runFunc<boolean>(tabId, (text: string) => {
const norm = (s: string) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
return norm(document.body?.innerText || '').includes(norm(text));
}, [params.text as string]);
if (found) return { found: true, elapsed: Date.now() - start };
await new Promise((r) => setTimeout(r, interval));
}
return { found: false, timedOut: true, elapsed: Date.now() - start };
}
case 'hanzo.waitForMutation': {
if (!tabId || !params.selector) throw new Error('Missing selector');
const timeoutMs = (params.timeout as number) || 15000;
const result = await this.runFunc<any>(tabId, (selector: string, timeout: number) => {
return new Promise((resolve) => {
const existing = document.querySelector(selector);
if (existing) { resolve({ found: true, immediate: true }); return; }
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) { observer.disconnect(); resolve({ found: true, immediate: false }); }
});
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
setTimeout(() => { observer.disconnect(); resolve({ found: false, timedOut: true }); }, timeout);
});
}, [params.selector as string, timeoutMs]);
return result;
}
case 'hanzo.submitForm': {
if (!tabId) throw new Error('No active tab');
const result = await this.runFunc<any>(tabId, (hint: string | null) => {
const norm = (s: string) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const intents = hint ? [hint] : ['submit', 'continue', 'next', 'save', 'send', 'agree', 'accept', 'ok'];
const wantList = intents.map(norm);
const candidates = Array.from(document.querySelectorAll(
'button, input[type=submit], input[type=button], a[role=button], [role=button], a.btn'
)) as HTMLElement[];
for (const want of wantList) {
for (const c of candidates) {
const t = norm(c.innerText || (c as HTMLInputElement).value || c.getAttribute('aria-label') || '');
if (t === want) {
try { c.scrollIntoView({ block: 'center' }); } catch (e) {}
c.click();
return { submitted: true, intent: want, tag: c.tagName, text: t.slice(0, 100), exact: true };
}
}
for (const c of candidates) {
const t = norm(c.innerText || (c as HTMLInputElement).value || c.getAttribute('aria-label') || '');
if (t.includes(want)) {
try { c.scrollIntoView({ block: 'center' }); } catch (e) {}
c.click();
return { submitted: true, intent: want, tag: c.tagName, text: t.slice(0, 100), exact: false };
}
}
}
return { submitted: false, candidates: candidates.length, intents };
}, [(params.intent as string) || null]);
return result;
}
case 'hanzo.uploadFile': {
if (!tabId || !params.selector || !params.content || !params.name) {
throw new Error('Need selector, name, content (base64)');
}
const result = await this.runFunc<any>(tabId, (selector: string, name: string, mimeType: string, contentB64: string) => {
const el = document.querySelector(selector) as HTMLInputElement | null;
if (!el) return { uploaded: false, error: 'selector not found' };
if (el.tagName !== 'INPUT' || el.type !== 'file') return { uploaded: false, error: 'not a file input' };
const binStr = atob(contentB64);
const bytes = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) bytes[i] = binStr.charCodeAt(i);
const file = new File([bytes], name, { type: mimeType });
const dt = new DataTransfer();
dt.items.add(file);
el.files = dt.files;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return { uploaded: true, name, size: bytes.byteLength };
}, [params.selector as string, params.name as string, (params.mimeType as string) || 'application/octet-stream', params.content as string]);
return result;
}
case 'hanzo.dialogAccept': {
if (!tabId) throw new Error('No active tab');
await this.runFunc<boolean>(tabId, (acceptText: string) => {
const W = window as any;
W.__hanzo_dialogText = acceptText || '';
W.alert = () => true;
W.confirm = () => true;
W.prompt = () => W.__hanzo_dialogText || '';
return true;
}, [(params.text as string) || '']);
return { armed: true };
}
case 'hanzo.observe': {
if (!tabId || !params.name) throw new Error('Missing name');
await this.runFunc<boolean>(tabId, (name: string, selector: string | null, opts: any) => {
const W = window as any;
W.__hanzo_obs = W.__hanzo_obs || {};
if (W.__hanzo_obs[name]?.observer) W.__hanzo_obs[name].observer.disconnect();
W.__hanzo_obs[name] = { events: [], observer: null };
const target = selector ? document.querySelector(selector) : document.documentElement;
if (!target) return false;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
W.__hanzo_obs[name].events.push({
type: m.type,
target: (m.target as Element)?.tagName || '',
added: m.addedNodes?.length || 0,
removed: m.removedNodes?.length || 0,
attr: m.attributeName || null,
ts: Date.now(),
});
if (W.__hanzo_obs[name].events.length > 500) W.__hanzo_obs[name].events.shift();
}
});
obs.observe(target, opts || { childList: true, subtree: true, attributes: true });
W.__hanzo_obs[name].observer = obs;
return true;
}, [params.name as string, (params.selector as string) || null, params.options || null]);
return { started: true, name: params.name };
}
case 'hanzo.observeRead': {
if (!tabId || !params.name) throw new Error('Missing name');
const events = await this.runFunc<any[]>(tabId, (name: string, clear: boolean) => {
const W = window as any;
const entry = W.__hanzo_obs?.[name];
if (!entry) return [];
const out = entry.events.slice();
if (clear !== false) entry.events.length = 0;
return out;
}, [params.name as string, params.clear !== false]);
return { events: events || [] };
}
case 'hanzo.observeStop': {
if (!tabId || !params.name) throw new Error('Missing name');
await this.runFunc<boolean>(tabId, (name: string) => {
const W = window as any;
const entry = W.__hanzo_obs?.[name];
if (entry?.observer) entry.observer.disconnect();
if (W.__hanzo_obs) delete W.__hanzo_obs[name];
return true;
}, [params.name as string]);
return { stopped: true, name: params.name };
}
case 'hanzo.getComputedStyles': {
if (!tabId || !params.selector) throw new Error('No active tab or selector');
const sel = this.escapeSelector(params.selector as string);
@@ -1320,7 +1882,19 @@ class HanzoFirefoxExtension {
return { error: `${action} not yet implemented in Firefox extension` };
}
return { error: `Unknown extension action: ${action}` };
// ─────────────────────────────────────────────────────────────────
// 1.9.17: fall back to the canonical executeMethod dispatcher.
// Without this, HTTP-API callers using {action: "hanzo.scroll", ...}
// silently failed because handleExtensionRequest only handled
// monitor.*/audit.* — every Page.* / DOM.* / Input.* / hanzo.* method
// fell off the end. The canonical dispatcher (executeMethod) holds
// the full CDP-shaped surface added in 1.9.16+.
// ─────────────────────────────────────────────────────────────────
try {
return await this.executeMethod(action, params || {});
} catch (e: any) {
return { error: (e && e.message) || String(e) };
}
}
private notifyOverlay(tabId: number, action: string, data: Record<string, unknown>): void {
@@ -2431,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;
@@ -2445,18 +3030,18 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
const hanzoExtension = new HanzoFirefoxExtension();
// Install the ZAP inbound-request handler so Python hanzo-mcps can dispatch
// browser actions BACK to Firefox over the same socket. This is what
// closes the loop: the extension is no longer ZAP-client-only — it accepts
// inbound RPC and routes via the canonical executeMethod dispatcher.
setZapRequestHandler(zapMgr, async (method, params) => {
return hanzoExtension.dispatchMethod(method, (params || {}) as Record<string, unknown>);
});
// Start the always-on ZAP discovery loop. Ticks every 5s; each tick is a
// no-op when we're already connected to every advertised server. Replaces
// the earlier setTimeout chain that died if any path missed a re-arm.
startZapDiscoveryLoop(zapMgr, BROWSER_NAME, VERSION, 5000);
// Connect to the local zapd router via the ai.hanzo.zap native host and
// register Firefox as a browser provider the SAME transport Chrome uses.
// Inbound ROUTE commands dispatch to Firefox's native browser.* dispatcher.
const BROWSER_CAPS = ['browser.tabs', 'browser.navigate', 'browser.dom', 'browser.screenshot', 'browser.input'];
const nativeZap: NativeZapState = {
connected: false,
port: null,
providerId: `browser:${BROWSER_NAME}/default`,
};
const browserDispatch = (method: string, params: any) =>
hanzoExtension.dispatchMethod(method, (params || {}) as Record<string, unknown>);
connectNativeZap(nativeZap, 'hanzo', BROWSER_CAPS, browserDispatch, console.log);
// =============================================================================
// Keep-alive — two-layer:
@@ -2487,9 +3072,9 @@ try {
browser.alarms.create(KEEP_ALIVE_ALARM, { periodInMinutes: 0.25 });
browser.alarms.onAlarm.addListener((alarm) => {
if (alarm.name !== KEEP_ALIVE_ALARM) return;
if (!zapMgr.state.connected) {
console.log('[Hanzo/ZAP] keep-alive alarm: no connection, rediscovering');
void discoverZapServers(zapMgr, BROWSER_NAME, VERSION);
if (!nativeZap.connected) {
console.log('[Hanzo/ZAP] keep-alive: reconnecting native host');
connectNativeZap(nativeZap, 'hanzo', BROWSER_CAPS, browserDispatch, console.log);
}
});
} catch (e) {
@@ -2503,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');
+82 -82
View File
@@ -11,13 +11,11 @@ import type { ZapState, ControlSession, RagSnippet, RagQueryParams } from './sha
import {
createZapManager,
encodeZapMessage,
discoverZapServers,
startZapDiscoveryLoop,
handleZapMessage,
setZapRequestHandler,
MSG_REQUEST,
type ZapManager,
} from './shared/zap.js';
import { connectNativeZap, type NativeZapState } from './shared/native-zap.js';
import {
getRagConfig as getSharedRagConfig,
normalizeRagSnippets,
@@ -38,8 +36,9 @@ import {
const IAM_V1 = `${IAM_API_URL}${IAM_API_PATH}`;
import { BrowserControl } from './browser-control';
import { WebGPUAI } from './webgpu-ai';
import { getCDPBridge, CDPBridge } from './cdp-bridge';
import { getCDPBridge, CDPBridge } from './browser-dispatch';
import * as auth from './auth';
import { fetchAllUsage } from './shared/usage.js';
import { listModels, chatCompletion, ChatMessage } from './chat-client';
import { PageMonitor } from './page-monitor';
import {
@@ -284,23 +283,63 @@ 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) {
debugLog('[Hanzo] WebGPU available');
try {
await webgpuAI.loadModel({
name: 'hanzo-browser-control',
url: chrome.runtime.getURL('models/browser-control-4bit.bin'),
quantization: '4bit',
maxTokens: 512
});
// Model source resolution order (first hit wins):
// 1. chrome.storage.local.hanzo_model_url — explicit override
// 2. chrome.storage.local.hanzo_model_hf — { repo, file, revision? }
// 3. The hanzo-browser-control HF default — cached after first load
// 4. The bundled-with-extension fallback at models/browser-control-4bit.bin
const stored = await chrome.storage.local.get([
'hanzo_model_url', 'hanzo_model_hf', 'hanzo_model_quant', 'hanzo_model_max_tokens', 'hanzo_model_vocab_url',
]);
let modelUrl: string;
if (stored.hanzo_model_url) {
modelUrl = stored.hanzo_model_url;
} else if (stored.hanzo_model_hf && stored.hanzo_model_hf.repo && stored.hanzo_model_hf.file) {
const { repo, file, revision } = stored.hanzo_model_hf;
modelUrl = WebGPUAI.huggingFaceURL(repo, file, revision || 'main');
} else {
// Default to the HF-published browser-control artifact. If it 404s, the
// catch below falls through to the bundled file, then to remote providers.
modelUrl = WebGPUAI.huggingFaceURL('zenlm/hanzo-browser-control', 'browser-control-4bit.bin');
}
try {
await webgpuAI.loadModel({
name: 'hanzo-browser-control',
url: modelUrl,
quantization: stored.hanzo_model_quant || '4bit',
maxTokens: stored.hanzo_model_max_tokens || 512,
vocabUrl: stored.hanzo_model_vocab_url,
});
debugLog(`[Hanzo] WebGPU model loaded from ${modelUrl}`);
} catch (hfErr) {
debugLog(`[Hanzo] WebGPU model fetch failed for ${modelUrl}: ${hfErr}; trying bundled fallback…`);
await webgpuAI.loadModel({
name: 'hanzo-browser-control',
url: chrome.runtime.getURL('models/browser-control-4bit.bin'),
quantization: '4bit',
maxTokens: 512,
});
}
} catch (error) {
// Model file not bundled yet — will use Ollama/LM Studio/cloud instead
debugLog('[Hanzo] Local model not bundled, using remote providers');
// Neither HF nor bundled artifact available — fall back to remote providers.
debugLog(`[Hanzo] No local WebGPU model available (${error}); using Ollama/LM Studio/cloud`);
}
}
@@ -1479,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
@@ -1758,46 +1797,19 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
break;
}
}
}
// =============================================================================
// Legacy MCP WebSocket (fallback when no ZAP gateway running)
// =============================================================================
let ws: WebSocket | null = null;
async function connectToMCP() {
const { mcpPort } = await getPortConfig();
ws = new WebSocket(`ws://localhost:${mcpPort}/browser-extension`);
ws.onopen = () => {
debugLog('[Hanzo] Connected to legacy MCP server');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleMCPMessage(data);
};
ws.onerror = () => {
// Silent — ZAP is primary, this is fallback
};
ws.onclose = () => {
setTimeout(connectToMCP, 5000);
};
}
function handleMCPMessage(data: any) {
switch (data.type) {
case 'browserControl':
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]?.id) {
browserControl.launchAIWorker(tabs[0].id, data.model);
// --- 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;
}
}
}
@@ -1819,17 +1831,19 @@ chrome.tabs.onRemoved.addListener((tabId) => {
}
});
// 1. Primary: Discover ZAP servers (high-performance binary protocol)
// Install a request handler so Python hanzo-mcps can dispatch browser
// actions BACK to this extension over the same socket. This is what
// makes the 2-process architecture work — the extension is no longer
// a one-way client; it accepts inbound RPC and routes via the existing
// cdp-bridge dispatcher.
setZapRequestHandler(zapMgr, async (method, params) => {
return cdpBridge.dispatchMethod(method, params);
});
// mDNS-only discovery per HIP-0069 — always-on watchdog, no port hint.
startZapDiscoveryLoop(zapMgr, BROWSER_NAME, VERSION, 5000, debugLog);
// 1. Primary: connect to the local zapd router via the ai.hanzo.zap native
// host and register as a browser provider. Inbound ROUTE commands are
// dispatched to the browser. Exactly one transport — no WebSocket, no
// localhost ports, no mDNS probing; the browser can never get a refused
// connection (connectNative attaches the host or fails once, cleanly).
const BROWSER_CAPS = ['browser.tabs', 'browser.navigate', 'browser.dom', 'browser.screenshot', 'browser.input'];
const nativeZap: NativeZapState = {
connected: false,
port: null,
providerId: `browser:${BROWSER_NAME}/default`,
};
const browserDispatch = (method: string, params: any) => cdpBridge.dispatchMethod(method, params);
connectNativeZap(nativeZap, 'hanzo', BROWSER_CAPS, browserDispatch, debugLog);
// Keep-alive — Layer 1: Port. Content scripts in every loaded http(s) tab
// hold a `zap-keepalive` port. As long as ANY tab has one, Chrome keeps
@@ -1839,36 +1853,22 @@ chrome.runtime.onConnect.addListener((port) => {
port.onMessage.addListener(() => { /* receipt is the keep-alive */ });
});
// Keep-alive — Layer 2: Alarms. Belt-and-suspenders for the case where
// no http(s) tab is open AND the worker idles out. Alarm fires every 30s,
// wakes the worker, re-discovers if the ZAP socket is gone.
// Keep-alive — Layer 2: Alarms. Wake the worker periodically; if the native
// host link dropped (or the worker was idled out), reconnect.
const KEEP_ALIVE_ALARM = 'hanzo-zap-keepalive';
try {
// Wakes the worker every 15s; one-shot discovery probe if disconnected.
chrome.alarms.create(KEEP_ALIVE_ALARM, { periodInMinutes: 0.25 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name !== KEEP_ALIVE_ALARM) return;
if (!zapMgr.state.connected) {
debugLog('[Hanzo/ZAP] keep-alive alarm: no connection, rediscovering via mDNS');
void discoverZapServers(zapMgr, BROWSER_NAME, VERSION, undefined, debugLog);
if (!nativeZap.connected) {
debugLog('[Hanzo/ZAP] keep-alive: reconnecting native host');
connectNativeZap(nativeZap, 'hanzo', BROWSER_CAPS, browserDispatch, debugLog);
}
});
} catch (e) {
console.warn('[Hanzo] alarms unavailable:', e);
}
// 2. Fallback: Connect to legacy MCP WebSocket
connectToMCP();
// 3. CDP bridge for browser control (configurable port)
getPortConfig().then(({ cdpPort }) => {
try {
cdpBridge.startWebSocketServer(cdpPort);
debugLog(`[Hanzo] CDP bridge connecting to ws://localhost:${cdpPort}/cdp`);
} catch (e) {
console.error('[Hanzo] Failed to start CDP bridge:', e);
}
});
// Export for testing
export { browserControl, webgpuAI, zapMgr, zapState };
@@ -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,
});
@@ -314,78 +315,10 @@ export class CDPBridge {
return true;
}
// WebSocket server for external connections (hanzo-mcp)
startWebSocketServer(port: number = 9223): void {
// Note: Chrome extensions can't create native WebSocket servers
// Instead, we connect to an external bridge server
this.connectToBridge(`ws://localhost:${port}/cdp`);
}
private connectToBridge(url: string): void {
try {
this.wsServer = new WebSocket(url);
this.wsServer.onopen = () => {
debugLog('[CDP] Connected to bridge server');
// Register as CDP provider with browser identification
const browser = typeof navigator !== 'undefined'
? (navigator.userAgent.includes('Firefox') ? 'firefox'
: navigator.userAgent.includes('Edg/') ? 'edge'
: navigator.userAgent.includes('Chrome') ? 'chrome'
: navigator.userAgent.includes('Safari') ? 'safari'
: 'unknown')
: 'unknown';
this.wsServer?.send(JSON.stringify({
type: 'register',
role: 'cdp-provider',
browser,
capabilities: ['navigate', 'screenshot', 'click', 'type', 'evaluate', 'takeover']
}));
};
this.wsServer.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
// Legacy bridge client removed — the extension reaches the local zapd router
// via the ai.hanzo.zap native host. dispatchMethod() below remains the
// inbound browser-command dispatcher.
// Handle extension-request from bridge server (monitor/audit calls)
if (message.type === 'extension-request' && message.action) {
chrome.runtime.sendMessage(
{ action: message.action, ...(message.params || {}) },
(result) => {
this.wsServer?.send(JSON.stringify({
type: 'extension-response',
id: message.id,
result: result || { error: chrome.runtime.lastError?.message },
}));
}
);
return;
}
const response = await this.handleBridgeMessage(message);
this.wsServer?.send(JSON.stringify(response));
} catch (error: any) {
this.wsServer?.send(JSON.stringify({
id: 0,
error: { code: -32603, message: error.message }
}));
}
};
this.wsServer.onerror = (error) => {
console.error('[CDP] Bridge connection error:', error);
};
this.wsServer.onclose = () => {
debugLog('[CDP] Bridge disconnected, reconnecting in 5s...');
setTimeout(() => this.connectToBridge(url), 5000);
};
} catch (error) {
console.error('[CDP] Failed to connect to bridge:', error);
setTimeout(() => this.connectToBridge(url), 5000);
}
}
/**
* Public entry point for ZAP-server-initiated requests.
*
@@ -457,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,
});
+97 -84
View File
@@ -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,17 +147,7 @@ async function build() {
await esbuild.build({
entryPoints: ['src/cli.ts'],
bundle: true,
outfile: 'dist/browser-extension/cli.js',
platform: 'node',
target: 'node16',
packages: 'external'
});
// Build CDP Bridge Server (WebSocket + HTTP API for hanzo-mcp integration)
await esbuild.build({
entryPoints: ['src/cdp-bridge-server.ts'],
bundle: true,
outfile: 'dist/browser-extension/cdp-bridge-server.js',
outfile: out('cli.js'),
platform: 'node',
target: 'node16',
packages: 'external'
@@ -121,36 +155,24 @@ 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
fs.copyFileSync(
'src/manifest.json',
'dist/browser-extension/manifest.json'
);
// Chrome version
fs.copyFileSync(
'src/manifest.json',
'dist/browser-extension/chrome/manifest.json'
);
// Firefox version
fs.copyFileSync(
'src/manifest-firefox.json',
'dist/browser-extension/firefox/manifest.json'
);
// Copy manifests for different browsers. package.json is the SINGLE source of
// truth for the version — stamp it into every manifest so the three can never
// drift apart (the "must agree" rule, enforced by the build instead of by hand).
const pkgVersion = JSON.parse(fs.readFileSync('package.json', 'utf8')).version;
const writeManifest = (srcPath, destPath) => {
const m = JSON.parse(fs.readFileSync(srcPath, 'utf8'));
m.version = pkgVersion;
fs.writeFileSync(destPath, JSON.stringify(m, null, 2));
};
writeManifest('src/manifest.json', 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,9 +181,9 @@ async function build() {
<key>CFBundleIdentifier</key>
<string>ai.hanzo.browser-extension</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<string>${pkgVersion}</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<string>${pkgVersion}</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
@@ -174,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']) {
@@ -217,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.
@@ -272,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) => {
-961
View File
@@ -1,961 +0,0 @@
#!/usr/bin/env node
/**
* CDP Bridge Server DEPRECATED in @hanzo/browser-extension 1.9.1.
*
* The canonical 2-process architecture removes this node bridge from the
* critical path. Each Python `hanzo-mcp` now hosts a ZAP server directly
* (one of ports 9999..9995); browser extensions discover and connect to
* those MCPs without any intermediary. See
* `python-sdk/pkg/hanzo-tools-browser/hanzo_tools/browser/zap_server.py`.
*
* This file remains only as a fallback for non-ZAP MCP clients (legacy
* stdio-only consumers that still want to talk to the extension via the
* old WS+HTTP API on 9223/9224). It is no longer auto-launched. Do not
* extend it for new functionality extend the Python ZAP server and the
* extension's `shared/zap.ts` instead.
*
* Provides hanzo.browser(action, params) interface matching hanzo-mcp pattern.
* Browser extension connects to this server for remote control.
*/
import { WebSocketServer, WebSocket } from 'ws';
import * as readline from 'readline';
import * as fs from 'fs';
import * as path from 'path';
import { unwrapEvaluateResult } from './shared/tab-id.js';
interface CDPClient {
ws: WebSocket;
id: string; // Unique ID: "chrome", "firefox", "chrome-2", etc.
capabilities: string[];
browser: string; // Browser type: "chrome", "firefox", "safari", "edge"
tabId?: number;
}
interface BrowserCommand {
action: string;
// Navigation
url?: string;
// Selectors
selector?: string;
ref?: string;
element?: string;
// Input
text?: string;
value?: string;
key?: string;
// Screenshot
fullPage?: boolean;
format?: 'png' | 'jpeg';
filename?: string;
// Evaluate
code?: string;
function?: string;
expression?: string;
// Tabs
tabId?: number;
tabIndex?: number;
// Wait
timeout?: number;
state?: string;
// Browser targeting (multi-browser support)
browser?: string; // Target browser: "chrome", "firefox", "safari", or unique ID
// Generic
[key: string]: any;
}
class CDPBridgeServer {
private wss: WebSocketServer;
private clients: Map<WebSocket, CDPClient> = new Map();
private pendingCommands: Map<number, { resolve: Function; reject: Function }> = new Map();
private pendingExtensionRequests: Map<string, { resolve: Function; timeout: ReturnType<typeof setTimeout> }> = new Map();
private commandId = 0;
private httpPort: number;
constructor(port: number = 9223) {
this.httpPort = port;
// Restore persisted defaultBrowser so user's "always Firefox" preference
// survives bridge restarts. The config file is written by the
// `config` message handler.
try {
const cfgPath = path.join(
process.env.HOME || process.env.USERPROFILE || '.',
'.hanzo', 'extension', 'config.json',
);
if (fs.existsSync(cfgPath)) {
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
if (cfg && typeof cfg.defaultBrowser === 'string') {
this.defaultBrowser = cfg.defaultBrowser;
}
}
} catch { /* ignore — startup config is best-effort */ }
this.wss = new WebSocketServer({ port, path: '/cdp' });
this.wss.on('connection', (ws) => {
console.log('[hanzo.browser] Extension connected');
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
this.handleMessage(ws, message);
} catch (error: any) {
console.error('[hanzo.browser] Error:', error.message);
}
});
ws.on('close', () => {
console.log('[hanzo.browser] Extension disconnected');
this.clients.delete(ws);
});
ws.on('error', (error) => {
console.error('[hanzo.browser] WebSocket error:', error);
});
});
console.log(`[hanzo.browser] Server listening on ws://localhost:${port}/cdp`);
}
/** Assign a unique ID for a browser type (e.g. "chrome", "chrome-2", "firefox") */
private assignClientId(browser: string): string {
const existing = Array.from(this.clients.values())
.filter((c) => c.browser === browser);
if (existing.length === 0) return browser;
// Find next available suffix
for (let i = 2; ; i++) {
const candidate = `${browser}-${i}`;
if (!existing.some((c) => c.id === candidate)) return candidate;
}
}
/**
* Default browser preference order when caller doesn't specify a target.
* Firefox first because (a) it's the user's primary browser, (b) it's
* where authenticated sessions typically live (Cloudflare, Porkbun,
* hanzo.id), (c) Chrome is often a secondary/clean profile that the
* agent should never silently land on.
*
* Override at runtime by writing the `defaultBrowser` config via the
* `config` action, e.g. `{ action: 'config', key: 'defaultBrowser',
* value: 'chrome' }`. The override is also persisted to
* `~/.hanzo/extension/config.json` so it survives bridge restarts.
*/
private static DEFAULT_BROWSER_PREFERENCE = ['firefox', 'safari', 'edge', 'chrome'] as const;
private defaultBrowser: string | null = null;
/** Find a client by browser name/ID. Without a target, picks the
* configured default browser (defaults to Firefox) or the first
* preferred-order match among connected clients. */
private resolveClient(target?: string): CDPClient | undefined {
if (target) {
// Exact match on ID first (e.g. "chrome-2")
for (const client of this.clients.values()) {
if (client.id === target) return client;
}
// Then match by browser type (returns first of that type)
for (const client of this.clients.values()) {
if (client.browser === target) return client;
}
return undefined;
}
// No target — prefer the user-configured default browser if connected.
if (this.defaultBrowser) {
for (const client of this.clients.values()) {
if (client.id === this.defaultBrowser || client.browser === this.defaultBrowser) {
return client;
}
}
}
// Fall back to the standard preference order. Firefox first because
// the user's authenticated sessions live there.
for (const browser of CDPBridgeServer.DEFAULT_BROWSER_PREFERENCE) {
for (const client of this.clients.values()) {
if (client.browser === browser) return client;
}
}
// Last resort: any connected client.
return this.clients.values().next().value;
}
private handleMessage(ws: WebSocket, message: any) {
if (message.type === 'register') {
const browser = message.browser || 'unknown';
const id = this.assignClientId(browser);
this.clients.set(ws, {
ws,
id,
capabilities: message.capabilities || [],
browser,
});
console.log(`[hanzo.browser] Registered ${id} (${browser}):`, message.capabilities?.join(', '));
// Notify the extension of its assigned ID
ws.send(JSON.stringify({ type: 'registered', id, browser }));
return;
}
if (message.type === 'config') {
// Special-case: defaultBrowser is read by resolveClient() so apply it
// immediately in addition to persisting it. Without this every config
// change would require a bridge restart.
if (message.key === 'defaultBrowser') {
this.defaultBrowser = (message.value as string) || null;
}
this.saveConfig(message.key, message.value);
return;
}
// Handle responses from extension for sendToExtension() calls
if (message.type === 'extension-response' && message.id) {
const pending = this.pendingExtensionRequests.get(message.id);
if (pending) {
clearTimeout(pending.timeout);
this.pendingExtensionRequests.delete(message.id);
pending.resolve(message.result || message);
}
return;
}
if (message.id !== undefined) {
const pending = this.pendingCommands.get(message.id);
if (pending) {
if (message.error) {
pending.reject(new Error(message.error.message));
} else {
pending.resolve(message.result);
}
this.pendingCommands.delete(message.id);
}
return;
}
if (message.type === 'event') {
console.log(`[hanzo.browser] Event: ${message.method}`);
}
}
/** Save a config value to ~/.hanzo/extension/config.json */
private saveConfig(key: string, value: unknown): void {
const configDir = path.join(
process.env.HOME || process.env.USERPROFILE || '.',
'.hanzo',
'extension',
);
const configPath = path.join(configDir, 'config.json');
try {
fs.mkdirSync(configDir, { recursive: true });
let config: Record<string, unknown> = {};
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
config[key] = value;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
console.log(`[hanzo.browser] Config saved: ${key} = ${JSON.stringify(value)}`);
} catch (error: any) {
console.error(`[hanzo.browser] Failed to save config: ${error.message}`);
}
}
/** Get connected browser names */
getConnectedBrowsers(): string[] {
return Array.from(this.clients.values())
.map((c) => c.id || c.browser || 'unknown')
.filter((b) => b !== 'unknown');
}
/** Get detailed info about all connected browsers */
getConnectedBrowserDetails(): Array<{ id: string; browser: string; capabilities: string[] }> {
return Array.from(this.clients.values()).map((c) => ({
id: c.id,
browser: c.browser,
capabilities: c.capabilities,
}));
}
private async sendRaw(method: string, params?: any, targetBrowser?: string): Promise<any> {
const client = this.resolveClient(targetBrowser);
if (!client) {
const connected = this.getConnectedBrowsers();
throw new Error(
targetBrowser
? `Browser "${targetBrowser}" not connected. Connected: [${connected.join(', ')}]`
: 'No browser extension connected'
);
}
const id = ++this.commandId;
return new Promise((resolve, reject) => {
this.pendingCommands.set(id, { resolve, reject });
client.ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => {
if (this.pendingCommands.has(id)) {
this.pendingCommands.delete(id);
reject(new Error(`Command timeout (browser: ${client.id})`));
}
}, 30000);
});
}
/**
* Unified hanzo.browser interface - matches mcp__hanzo__browser pattern
*/
async browser(params: BrowserCommand): Promise<any> {
const { action, browser: targetBrowser, ...rest } = params;
// Special action: list connected browsers
if (action === 'list_browsers' || action === 'browsers') {
return {
browsers: this.getConnectedBrowserDetails(),
count: this.clients.size,
defaultBrowser: this.defaultBrowser,
preferenceOrder: CDPBridgeServer.DEFAULT_BROWSER_PREFERENCE,
};
}
// Special action: set the default browser the bridge auto-routes to
// when no per-call `browser` target is specified. Persisted to
// ~/.hanzo/extension/config.json so it survives restarts.
if (action === 'set_default_browser' || action === 'use_browser') {
const target = (params.browser || params.value || params.target) as string | undefined;
if (!target) {
return { ok: false, error: 'browser/value/target required (e.g. "firefox")' };
}
this.defaultBrowser = target;
this.saveConfig('defaultBrowser', target);
return {
ok: true,
defaultBrowser: this.defaultBrowser,
connected: this.getConnectedBrowserDetails(),
};
}
// Shorthand: b = targetBrowser for all sendRaw/sendToExtension calls below
const b = targetBrowser;
// Universal tab targeting: a `tabId` or `tabIndex` on the incoming command
// applies to every downstream sendRaw call so the user can pin a specific
// tab without repeating it. Without this, every action falls back to
// `chrome.tabs.query({active:true,currentWindow:true})[0]` — which is
// fragile when the user has many windows / a chrome://newtab in focus.
const tabTarget = (extra?: Record<string, unknown>) => {
const out: Record<string, unknown> = { ...(extra || {}) };
if (rest.tabId !== undefined) out.tabId = rest.tabId;
if (rest.tabIndex !== undefined) out.tabIndex = rest.tabIndex;
if (rest.targetId !== undefined && out.tabId === undefined) out.tabId = rest.targetId;
return out;
};
switch (action) {
// Navigation
case 'navigate':
return this.sendRaw('Page.navigate', tabTarget({ url: rest.url }), b);
case 'navigate_back':
case 'go_back':
return this.sendRaw('Page.goBack', tabTarget(), b);
case 'navigate_forward':
case 'go_forward':
return this.sendRaw('Page.goForward', tabTarget(), b);
case 'reload':
return this.sendRaw('Page.reload', tabTarget(), b);
case 'url':
return this.sendRaw('hanzo.url', tabTarget(), b);
case 'title':
return this.sendRaw('hanzo.title', tabTarget(), b);
case 'tab_info':
return this.sendRaw('hanzo.tabInfo', tabTarget(), b);
case 'content':
return this.sendRaw('Runtime.evaluate', tabTarget({
expression: 'document.documentElement.outerHTML',
returnByValue: true
}), b);
// Screenshots
case 'screenshot': {
const screenshot = await this.sendRaw('hanzo.screenshot', tabTarget({
format: rest.format || 'png',
fullPage: rest.fullPage,
}), b);
if (rest.filename && screenshot?.data) {
const buffer = Buffer.from(screenshot.data, 'base64');
fs.writeFileSync(rest.filename, buffer);
return { saved: rest.filename, bytes: buffer.length, data: screenshot.data };
}
return screenshot;
}
case 'snapshot':
// Accessibility snapshot
return this.sendRaw('Accessibility.getFullAXTree', tabTarget(), b);
// Input - Click
case 'click':
return this.sendRaw('hanzo.click', tabTarget({
selector: rest.selector || rest.ref,
}), b);
case 'dblclick':
case 'double_click':
return this.sendRaw('hanzo.dblclick', tabTarget({
selector: rest.selector || rest.ref,
}), b);
case 'hover':
return this.sendRaw('hanzo.hover', tabTarget({
selector: rest.selector || rest.ref,
}), b);
// Input - Type
case 'type':
return this.sendRaw('hanzo.type', tabTarget({
selector: rest.selector || rest.ref,
text: rest.text,
}), b);
case 'fill':
return this.sendRaw('hanzo.fill', tabTarget({
selector: rest.selector || rest.ref,
value: rest.value || rest.text,
}), b);
case 'clear':
return this.sendRaw('hanzo.clear', tabTarget({
selector: rest.selector || rest.ref,
}), b);
case 'press_key':
case 'press':
return this.sendRaw('Input.dispatchKeyEvent', tabTarget({
type: 'keyDown',
key: rest.key,
}), b);
// Forms
case 'select_option':
return this.sendRaw('hanzo.select', tabTarget({
selector: rest.selector || rest.ref,
value: rest.value,
}), b);
case 'check':
return this.sendRaw('hanzo.check', tabTarget({
selector: rest.selector || rest.ref,
}), b);
case 'uncheck':
return this.sendRaw('hanzo.uncheck', tabTarget({
selector: rest.selector || rest.ref,
}), b);
// Aliases for the navigation cases above. We attach `get_url`,
// `get_title`, `get_tab_info`, `get_history`, `wait_for_navigation`,
// `create_tab` here. Note that `go_back`, `go_forward`, `tab_info`,
// and `history` are already aliased in the upper block — TS picks
// the first matching case-label so duplicates here would be dead
// code. Consolidating once and for all to avoid that confusion.
case 'get_url':
return this.sendRaw('hanzo.url', tabTarget(), b);
case 'get_title':
return this.sendRaw('hanzo.title', tabTarget(), b);
case 'get_tab_info':
return this.sendRaw('hanzo.tabInfo', tabTarget(), b);
case 'get_history':
return this.sendRaw('hanzo.getHistory', tabTarget({ maxResults: rest.maxResults }), b);
case 'wait_for_navigation':
return this.sendRaw('hanzo.waitForNavigation', tabTarget({ timeout: rest.timeout }), b);
case 'create_tab':
return this.sendRaw('Target.createTarget', { url: rest.url || 'about:blank' }, b);
// Fetch through browser context (uses page cookies/auth)
case 'fetch': {
const fetchResult = await this.sendRaw('hanzo.fetch', {
url: rest.url,
options: {
method: rest.method || rest.options?.method,
headers: rest.headers || rest.options?.headers,
body: rest.body || rest.options?.body,
mode: rest.mode || rest.options?.mode,
credentials: rest.credentials || rest.options?.credentials,
},
}, b);
return fetchResult;
}
// Selectors / Waiting
case 'wait_for_selector':
return this.sendRaw('hanzo.waitForSelector', { selector: rest.selector, timeout: rest.timeout }, b);
case 'query_selector_all':
return this.sendRaw('hanzo.querySelectorAll', { selector: rest.selector }, b);
case 'get_element_info':
return this.sendRaw('hanzo.getElementInfo', { selector: rest.selector }, b);
case 'get_page_info':
return this.sendRaw('hanzo.getPageInfo', {}, b);
// DOM Read/Write/Observe
case 'get_html':
return this.sendRaw('hanzo.getHTML', { selector: rest.selector, outer: rest.outer }, b);
case 'set_html':
return this.sendRaw('hanzo.setHTML', { selector: rest.selector, html: rest.html, outer: rest.outer }, b);
case 'get_text':
return this.sendRaw('hanzo.getText', { selector: rest.selector }, b);
case 'set_text':
return this.sendRaw('hanzo.setText', { selector: rest.selector, text: rest.text }, b);
case 'get_attribute':
return this.sendRaw('hanzo.getAttribute', { selector: rest.selector, attr: rest.attr }, b);
case 'set_attribute':
return this.sendRaw('hanzo.setAttribute', { selector: rest.selector, attr: rest.attr, value: rest.value }, b);
case 'remove_attribute':
return this.sendRaw('hanzo.removeAttribute', { selector: rest.selector, attr: rest.attr }, b);
case 'set_style':
return this.sendRaw('hanzo.setStyle', { selector: rest.selector, styles: rest.styles }, b);
case 'add_class':
return this.sendRaw('hanzo.addClass', { selector: rest.selector, classNames: rest.classNames }, b);
case 'remove_class':
return this.sendRaw('hanzo.removeClass', { selector: rest.selector, classNames: rest.classNames }, b);
case 'insert_element':
return this.sendRaw('hanzo.insertElement', { selector: rest.selector, html: rest.html, position: rest.position }, b);
case 'remove_element':
return this.sendRaw('hanzo.removeElement', { selector: rest.selector }, b);
case 'observe_mutations':
return this.sendRaw('hanzo.observeMutations', { selector: rest.selector, options: rest.options, duration: rest.duration }, b);
case 'computed_styles':
return this.sendRaw('hanzo.getComputedStyles', { selector: rest.selector, properties: rest.properties }, b);
case 'bounding_rects':
return this.sendRaw('hanzo.getBoundingRects', { selector: rest.selector }, b);
case 'inject_script':
return this.sendRaw('hanzo.injectScript', { url: rest.url }, b);
case 'inject_css':
return this.sendRaw('hanzo.injectCSS', { css: rest.css }, b);
case 'local_storage':
if (rest.value !== undefined) {
return this.sendRaw('hanzo.setLocalStorage', tabTarget({ key: rest.key, value: rest.value }), b);
}
return this.sendRaw('hanzo.getLocalStorage', tabTarget({ key: rest.key }), b);
case 'cookies':
return this.sendRaw('hanzo.getCookies', tabTarget(), b);
// Evaluate.
//
// awaitPromise: true tells CDP to wait for any async IIFE to resolve
// before returning. Without it, expressions like
// (async () => fetch('/x').then(r => r.json()))()
// serialise as `{}` (a Promise placeholder) and the caller can't
// distinguish "the page evaluated to {}" from "the bridge swallowed
// the result". We then route through `unwrapEvaluateResult` (the
// shared helper from shared/tab-id.ts) which handles every shape
// Chrome / Firefox / Safari extensions can hand back.
case 'evaluate': {
const evalResult = await this.sendRaw('Runtime.evaluate', tabTarget({
expression: rest.code || rest.function || rest.expression,
returnByValue: true,
awaitPromise: true,
}), b);
return { result: unwrapEvaluateResult(evalResult), raw: evalResult };
}
// Wait
case 'wait':
await new Promise(resolve => setTimeout(resolve, (rest.timeout || 1) * 1000));
return { waited: rest.timeout || 1 };
case 'wait_for_load':
return this.sendRaw('Page.waitForLoadState', tabTarget({
state: rest.state || 'load',
}), b);
// Tabs
case 'tabs':
case 'list_tabs':
return this.sendRaw('Target.getTargets', undefined, b);
case 'new_tab':
return this.sendRaw('Target.createTarget', { url: rest.url || 'about:blank' }, b);
case 'close_tab':
return this.sendRaw('Target.closeTarget', { targetId: rest.tabId }, b);
case 'select_tab':
return this.sendRaw('Target.activateTarget', { targetId: rest.tabId }, b);
// Console/Network — routed to PageMonitor via extension background
case 'console_messages':
case 'console':
case 'console_logs':
return this.sendToExtension('monitor.consoleLogs', { tabId: rest.tabId }, b);
case 'console_errors':
return this.sendToExtension('monitor.consoleErrors', { tabId: rest.tabId }, b);
case 'network_requests':
case 'network_logs':
return this.sendToExtension('monitor.networkLogs', { tabId: rest.tabId }, b);
case 'network_errors':
return this.sendToExtension('monitor.networkErrors', { tabId: rest.tabId }, b);
case 'network_success':
return this.sendToExtension('monitor.networkSuccess', { tabId: rest.tabId }, b);
case 'wipe_logs':
return this.sendToExtension('monitor.wipeLogs', { tabId: rest.tabId }, b);
case 'start_monitoring':
return this.sendToExtension('monitor.start', { tabId: rest.tabId }, b);
case 'stop_monitoring':
return this.sendToExtension('monitor.stop', { tabId: rest.tabId }, b);
case 'monitor_status':
return this.sendToExtension('monitor.status', {}, b);
// Audits — routed to AuditRunner via extension background
case 'accessibility_audit':
return this.sendToExtension('audit.accessibility', { tabId: rest.tabId }, b);
case 'performance_audit':
return this.sendToExtension('audit.performance', { tabId: rest.tabId }, b);
case 'seo_audit':
return this.sendToExtension('audit.seo', { tabId: rest.tabId }, b);
case 'best_practices_audit':
return this.sendToExtension('audit.bestPractices', { tabId: rest.tabId }, b);
case 'full_audit':
return this.sendToExtension('audit.full', { tabId: rest.tabId }, b);
case 'nextjs_audit':
return this.sendToExtension('audit.nextjs', { tabId: rest.tabId }, b);
case 'debugger_mode':
case 'debug':
return this.sendToExtension('debugger.mode', { tabId: rest.tabId }, b);
// Ollama / Local LLM
case 'ollama_models':
return this.sendToExtension('ollama.models', {}, b);
case 'ollama_chat':
return this.sendToExtension('ollama.chat', { model: rest.model, messages: rest.messages, options: rest.options }, b);
case 'local_chat':
return this.sendToExtension('local.chat', { provider: rest.provider, endpoint: rest.endpoint, model: rest.model, messages: rest.messages, options: rest.options, apiKey: rest.apiKey }, b);
case 'local_models':
return this.sendToExtension('local.models', { provider: rest.provider, endpoint: rest.endpoint, apiKey: rest.apiKey }, b);
case 'local_discover':
return this.sendToExtension('local.discover', {}, b);
// Model Hub — browse, search, download
case 'hub_search':
return this.sendToExtension('hub.search', { query: rest.query, filter: rest.filter, sort: rest.sort, limit: rest.limit, author: rest.author }, b);
case 'hub_search_gguf':
return this.sendToExtension('hub.searchGGUF', { query: rest.query, limit: rest.limit }, b);
case 'hub_model':
return this.sendToExtension('hub.model', { modelId: rest.modelId }, b);
case 'hub_recommended':
return this.sendToExtension('hub.recommended', {}, b);
case 'hub_search_ollama':
return this.sendToExtension('hub.searchOllama', { query: rest.query }, b);
case 'hub_download_ollama':
return this.sendToExtension('hub.downloadOllama', { modelName: rest.modelName, ollamaUrl: rest.ollamaUrl }, b);
case 'hub_download_hf':
return this.sendToExtension('hub.downloadHF', { downloadUrl: rest.downloadUrl }, b);
case 'hub_all_models':
return this.sendToExtension('hub.allModels', {}, b);
case 'hub_search_mlx':
return this.sendToExtension('hub.searchMLX', { query: rest.query, limit: rest.limit }, b);
case 'hub_model_card':
return this.sendToExtension('hub.modelCard', { modelId: rest.modelId }, b);
case 'hub_model_stats':
return this.sendToExtension('hub.modelStats', { modelId: rest.modelId }, b);
// Status
case 'status':
return {
connected: this.clients.size > 0,
clients: this.clients.size,
browsers: this.getConnectedBrowserDetails(),
port: this.httpPort
};
default:
// Pass through as raw CDP command
return this.sendRaw(action, rest, b);
}
}
// Send a message to the extension background and wait for response
private sendToExtension(action: string, params: any, targetBrowser?: string): Promise<any> {
return new Promise((resolve) => {
const client = this.resolveClient(targetBrowser);
if (!client) {
const connected = this.getConnectedBrowsers();
resolve({
error: targetBrowser
? `Browser "${targetBrowser}" not connected. Connected: [${connected.join(', ')}]`
: 'No browser extension connected'
});
return;
}
const id = `ext-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const timeout = setTimeout(() => {
this.pendingExtensionRequests.delete(id);
resolve({ error: `Extension request timed out (browser: ${client.id})` });
}, 30000);
this.pendingExtensionRequests.set(id, { resolve, timeout });
client.ws.send(JSON.stringify({
type: 'extension-request',
id,
action,
params,
}));
});
}
isConnected(): boolean {
return this.clients.size > 0;
}
close() {
this.wss.close();
}
}
// JSON-RPC server for MCP integration
async function startJSONRPCServer(bridgeServer: CDPBridgeServer) {
const http = require('http');
const httpServer = http.createServer(async (req: any, res: any) => {
if (req.method === 'POST') {
let body = '';
req.on('data', (chunk: string) => body += chunk);
req.on('end', async () => {
try {
const request = JSON.parse(body);
const result = await bridgeServer.browser(request.params || request);
res.writeHead(200, { 'Content-Type': 'application/json' });
// Return result directly (no extra wrapping — Python expects flat dict)
res.end(JSON.stringify(result && typeof result === 'object' ? result : { result }));
} catch (error: any) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
});
} else {
// GET - return status
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
service: 'hanzo.browser',
connected: bridgeServer.isConnected(),
browsers: bridgeServer.getConnectedBrowserDetails(),
actions: [
'list_browsers', 'browsers',
'navigate', 'navigate_back', 'reload', 'url', 'title', 'content',
'screenshot', 'snapshot',
'click', 'dblclick', 'hover', 'type', 'fill', 'clear', 'press_key',
'select_option', 'check', 'uncheck',
'evaluate',
'go_back', 'go_forward', 'get_url', 'get_title', 'get_tab_info',
'wait_for_navigation', 'get_history', 'create_tab', 'close_tab',
'fetch',
'get_html', 'set_html', 'get_text', 'set_text',
'get_attribute', 'set_attribute', 'remove_attribute',
'set_style', 'add_class', 'remove_class',
'insert_element', 'remove_element',
'observe_mutations', 'computed_styles', 'bounding_rects',
'inject_script', 'inject_css',
'local_storage', 'cookies',
'wait', 'wait_for_load',
'tabs', 'new_tab', 'close_tab', 'select_tab',
'console_logs', 'console_errors', 'network_logs', 'network_errors', 'network_success',
'start_monitoring', 'stop_monitoring', 'monitor_status', 'wipe_logs',
'accessibility_audit', 'performance_audit', 'seo_audit', 'best_practices_audit', 'full_audit',
'nextjs_audit', 'debugger_mode',
'ollama_models', 'ollama_chat', 'local_chat', 'local_models', 'local_discover',
'hub_search', 'hub_search_gguf', 'hub_search_mlx', 'hub_model', 'hub_model_card',
'hub_model_stats', 'hub_recommended',
'hub_search_ollama', 'hub_download_ollama', 'hub_download_hf', 'hub_all_models',
'takeover.start', 'takeover.end', 'takeover.cursor',
'status'
]
}));
}
});
httpServer.listen(9224, () => {
console.log('[hanzo.browser] HTTP API at http://localhost:9224');
});
}
// Interactive CLI
async function main() {
const port = parseInt(process.env.CDP_PORT || '9223');
const server = new CDPBridgeServer(port);
// Start HTTP API
startJSONRPCServer(server);
console.log('\n[hanzo.browser] Commands:');
console.log(' navigate <url> - Go to URL');
console.log(' screenshot [file] - Capture screen');
console.log(' click <selector> - Click element');
console.log(' type <sel> <text> - Type into element');
console.log(' eval <js> - Run JavaScript');
console.log(' tabs - List all tabs');
console.log(' status - Connection status');
console.log(' quit - Exit\n');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const prompt = () => {
rl.question('hanzo.browser> ', async (line) => {
const parts = line.trim().split(' ');
const action = parts[0];
const args = parts.slice(1);
if (!action) {
prompt();
return;
}
try {
let result;
switch (action) {
case 'quit':
case 'exit':
server.close();
rl.close();
process.exit(0);
case 'navigate':
result = await server.browser({ action: 'navigate', url: args[0] });
console.log('Navigated to:', args[0]);
break;
case 'screenshot':
result = await server.browser({
action: 'screenshot',
filename: args[0] || `screenshot-${Date.now()}.png`
});
console.log('Screenshot:', result);
break;
case 'click':
result = await server.browser({ action: 'click', selector: args[0] });
console.log('Clicked:', args[0]);
break;
case 'type':
result = await server.browser({
action: 'type',
selector: args[0],
text: args.slice(1).join(' ')
});
console.log('Typed into:', args[0]);
break;
case 'eval':
result = await server.browser({
action: 'evaluate',
expression: args.join(' ')
});
console.log('Result:', result?.result?.value);
break;
case 'tabs':
result = await server.browser({ action: 'tabs' });
console.log('Tabs:', result?.targetInfos?.map((t: any) => t.title).join(', '));
break;
case 'status':
result = await server.browser({ action: 'status' });
console.log('Status:', result);
break;
case 'url':
result = await server.browser({ action: 'url' });
console.log('URL:', result?.result?.value);
break;
case 'title':
result = await server.browser({ action: 'title' });
console.log('Title:', result?.result?.value);
break;
default:
// Try as raw action
result = await server.browser({ action, ...Object.fromEntries(
args.map((a, i) => [i === 0 ? 'selector' : `arg${i}`, a])
)});
console.log('Result:', result);
}
} catch (error: any) {
console.error('Error:', error.message);
}
prompt();
});
};
prompt();
}
export { CDPBridgeServer, BrowserCommand };
if (require.main === module) {
main();
}
+10 -2
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "Hanzo AI",
"version": "1.9.15",
"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",
+20 -3
View File
@@ -1,8 +1,9 @@
{
"manifest_version": 3,
"name": "Hanzo AI",
"version": "1.9.15",
"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",
"identity",
@@ -12,11 +13,14 @@
"scripting",
"cookies",
"debugger",
"alarms"
"alarms",
"nativeMessaging"
],
"host_permissions": [
"http://localhost/*",
"https://localhost/*",
"https://claude.ai/*",
"https://chatgpt.com/*",
"<all_urls>"
],
"content_security_policy": {
@@ -50,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": [
+277
View File
@@ -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; }
}
+13
View File
@@ -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>
+48
View File
@@ -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();
}
+78
View File
@@ -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); }
+45
View File
@@ -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 &amp; 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>
+29
View File
@@ -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();
}
});
+56
View File
@@ -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 &amp; 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 &amp; 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>
+113
View File
@@ -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) => (({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }) 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();
});
+9 -27
View File
@@ -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();
}
+20 -4
View File
@@ -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';
+185
View File
@@ -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 */
}
}
+48
View File
@@ -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} })()`;
}

Some files were not shown because too many files have changed in this diff Show More