@turmind/halo 1.5.5 → 1.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/989-e0faeff1c4a0b0b9.js +1 -0
  3. package/admin-out/_next/static/chunks/app/page-44f707ea8dad57da.js +1 -0
  4. package/admin-out/_next/static/css/b8c87b919758c52b.css +3 -0
  5. package/admin-out/index.html +1 -1
  6. package/admin-out/index.txt +5 -5
  7. package/bundled-docs/design/agent.md +7 -5
  8. package/bundled-docs/design/canvas-extensions.md +19 -4
  9. package/bundled-docs/design/session.md +2 -2
  10. package/bundled-docs/design/storage.md +13 -11
  11. package/bundled-docs/dev/api.md +4 -1
  12. package/bundled-docs/dev/env.md +13 -10
  13. package/bundled-docs/dev/tools.md +2 -2
  14. package/bundled-docs/guide/delegation-and-access.md +1 -1
  15. package/bundled-docs/guide/skills.md +3 -3
  16. package/bundled-docs/guide/workspace.md +2 -2
  17. package/bundled-docs/requirements/editor.md +3 -2
  18. package/bundled-docs/requirements/settings.md +15 -7
  19. package/dist/index.js +9326 -9307
  20. package/package.json +1 -1
  21. package/templates/agents/default/agent.yaml +1 -0
  22. package/templates/skills/extension/SKILL.md +25 -14
  23. package/templates/skills/extension/config.yaml +9 -0
  24. package/templates/skills/extension/templates/ext.sh +166 -34
  25. package/templates/skills/web-search/SKILL.md +20 -6
  26. package/templates/skills/web-search/config.yaml +13 -0
  27. package/templates/skills/web-search/search.py +27 -6
  28. package/admin-out/_next/static/chunks/989-bc743317498d3fbc.js +0 -1
  29. package/admin-out/_next/static/chunks/app/page-0a390059f1914664.js +0 -1
  30. package/admin-out/_next/static/css/b53542687ebd4c65.css +0 -3
  31. /package/admin-out/_next/static/{cuVcldfdg1PXevaQMQSzb → visjvN510yFK-Ff5zwx-0}/_buildManifest.js +0 -0
  32. /package/admin-out/_next/static/{cuVcldfdg1PXevaQMQSzb → visjvN510yFK-Ff5zwx-0}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turmind/halo",
3
- "version": "1.5.5",
3
+ "version": "1.5.6",
4
4
  "description": "Halo — multi-agent collaborative workspace. CLI + TUI + HTTP/WS server in one package.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,4 +47,5 @@ skills:
47
47
  - web-search
48
48
  - cron
49
49
  - acp
50
+ - extension
50
51
  - halo
@@ -1,18 +1,19 @@
1
1
  ---
2
2
  name: extension
3
- description: Install / list / remove admin preview extensions (`~/.halo/global/extensions/<id>/`) — static viewers the admin editor loads for file types it has no built-in preview for (e.g. `.glb`). `/extension install <id|zip|url>` pulls the latest `<id>-v*` release from turmind/halo-hub by default. Activate when the user wants to preview a file type the editor can't open, or asks to install / update / remove an extension.
3
+ description: Install / list / remove admin preview extensions (`~/.halo/global/extensions/<id>/`) — static viewers the admin editor loads for file types it has no built-in preview for (e.g. `.glb`). `/extension install <id|zip|url>` pulls the latest `<id>-v*` release from the configured hub (default turmind/halo-hub). Activate when the user wants to preview a file type the editor can't open, or asks to install / update / remove an extension.
4
4
  command: /extension
5
5
  requiresAccess: full
6
6
  verbs:
7
- - { name: install, desc: "Install or upgrade an extension from halo-hub (by id), a local zip path, or a zip URL" }
7
+ - { name: install, desc: "Install or upgrade an extension from the hub (by id), a local zip path, or a zip URL" }
8
8
  - { name: list, desc: List installed extensions with version and status }
9
9
  - { name: remove, desc: Remove an installed extension by id }
10
10
  ---
11
11
 
12
12
  # extension
13
13
 
14
- The requested action is **`$1`**; the rest of the line is the payload. With
15
- natural language ("install the glb viewer"), infer both.
14
+ The requested action is **`$1`**; everything after it is the payload
15
+ (`$ARGUMENTS` minus the first token). With natural language ("install the glb
16
+ viewer"), infer both.
16
17
 
17
18
  Preview extensions are static bundles the admin editor loads in a sandboxed
18
19
  iframe to preview file types it has no built-in viewer for (e.g. `.glb`).
@@ -30,23 +31,33 @@ Pick EXT = the first that exists, then:
30
31
 
31
32
  ## install
32
33
 
33
- `bash EXT install <source>` where `<source>` is one of:
34
- - an extension id (e.g. `glb`) → downloads the newest `glb-v*` release asset
35
- (`glb-<version>.zip`) from https://github.com/turmind/halo-hub/releases;
34
+ When the payload names a source, run the install right away — no confirmation,
35
+ no explanation first. Ask only when no source was given.
36
+
37
+ `HALO_HUB_REPO='{{params.hub_repo}}' bash EXT install <source>` — keep
38
+ `{{params.hub_repo}}` exactly as-is (the system substitutes it at runtime), where
39
+ `<source>` is one of:
40
+ - an extension id (e.g. `glb`) → the newest `glb-v*` release zip from the hub
41
+ (a GitHub / Gitea / Forgejo / GitLab repo), or the newest `glb-v<x.y.z>` tag
42
+ when the hub is any other git URL / local repo;
36
43
  - a local `.zip` path;
37
44
  - an `https://…zip` URL.
38
45
 
46
+ The hub is set in Settings → Skills → extension → `hub_repo`; empty means
47
+ https://github.com/turmind/halo-hub.
48
+
39
49
  The script unpacks to a temp dir, pre-checks `halo-extension.json` (id matches,
40
50
  entry exists), then atomically replaces `~/.halo/global/extensions/<id>/`.
41
51
  Reinstalling the same id upgrades (or downgrades) it. Relay the script's last
42
52
  line to the user (`installed glb 1.0.0` / the error). Never edit files inside an
43
53
  installed extension — reinstall instead.
44
54
 
45
- If the download fails with "no release for <id>" the GitHub API may be
46
- rate-limited (60 unauthenticated requests/hour) or the id may not exist: point
47
- the user at https://github.com/turmind/halo-hub/releases to download the zip by
48
- hand, then `install <path>`. Setting `GITHUB_TOKEN` in the environment lifts
49
- the limit.
55
+ On failure relay the script's hint. "release API failed" → rate-limited
56
+ (GitHub: 60 anonymous requests/hour), private, or a wrong `hub_repo`; setting
57
+ `GITHUB_TOKEN` / `GITLAB_TOKEN` / `GITEA_TOKEN` in the server environment helps.
58
+ "no release for <id>" → the id isn't on that hub. "needs a build step" → that
59
+ extension can't be installed from git tags; get its release zip. Either way the
60
+ user can download the zip by hand and run `install <path>`.
50
61
 
51
62
  ## list
52
63
 
@@ -63,8 +74,8 @@ the user first if they didn't name the id explicitly.
63
74
 
64
75
  - Extensions are global (all workspaces on this server). Say so if the user
65
76
  asks "install it for this project".
66
- - To find what's available, point the user at https://github.com/turmind/halo-hub
67
- (the `extensions/` directory); do not scrape it.
77
+ - Mention the configured hub (its `extensions/` directory) only when the user
78
+ asks what extensions exist or an install fails; do not scrape it.
68
79
  - The script only pre-checks the required fields; the server's scanner is the
69
80
  authority. A package it rejects shows up in `list` (and the admin) as
70
81
  `ERROR` with the reason.
@@ -0,0 +1,9 @@
1
+ displayName_zh: 预览扩展
2
+ description: Install / list / remove admin preview extensions.
3
+ description_zh: 安装 / 列出 / 删除管理后台的预览扩展。
4
+
5
+ params:
6
+ - key: hub_repo
7
+ description: "Hub that `install <id>` pulls from: a GitHub / Gitea / Forgejo / GitLab repo URL (newest `<id>-v*` release zip), `owner/repo` for GitHub, or any git URL / local repo path (newest `<id>-v<x.y.z>` tag). Empty = https://github.com/turmind/halo-hub."
8
+ description_zh: "`install <id>` 的来源仓库:GitHub / Gitea / Forgejo / GitLab 仓库地址(取最新的 `<id>-v*` release zip),GitHub 可简写为 `owner/repo`;也可以是任意 git 地址或本地仓库路径(取最新的 `<id>-v<x.y.z>` 标签)。留空 = https://github.com/turmind/halo-hub。"
9
+ default: https://github.com/turmind/halo-hub
@@ -9,17 +9,18 @@
9
9
  # ext.sh list
10
10
  # ext.sh remove <id>
11
11
  #
12
- # Needs curl + unzip + node. No `gh`: releases are read through the public API
13
- # (GITHUB_TOKEN, if set, is sent to lift the 60 req/h anonymous limit).
12
+ # `install <id>` reads the hub in $HALO_HUB_REPO (the skill's hub_repo param;
13
+ # empty → https://github.com/turmind/halo-hub). GitHub / Gitea-Forgejo / GitLab
14
+ # hubs go through their public release API (GITHUB_TOKEN / GITEA_TOKEN /
15
+ # GITLAB_TOKEN, if set, are sent — lifts GitHub's 60 req/h anonymous limit);
16
+ # any other git URL or local repo installs from `<id>-v<semver>` tags.
17
+ # Needs curl + unzip + node (+ git for tag-based hubs). No `gh`.
14
18
  set -euo pipefail
15
19
 
16
20
  ROOT="$HOME/.halo/global/extensions"
17
- REPO="turmind/halo-hub"
21
+ DEFAULT_HUB="https://github.com/turmind/halo-hub"
18
22
  ID_RE='^[a-z0-9][a-z0-9_-]{0,63}$' # keep in sync with server extensions/registry.ts ID_RE
19
23
 
20
- cmd=${1:?usage: ext.sh install <id|zip|url> | list | remove <id>}
21
- shift || true
22
-
23
24
  # Print `<id> <version> <name>` for the extension dir $1, or fail with the
24
25
  # reason. $2 (optional) = the id the directory / request claims.
25
26
  precheck() {
@@ -38,44 +39,175 @@ precheck() {
38
39
  ' "$1" "${2:-}"
39
40
  }
40
41
 
41
- # Echo the path of a local zip for source $1 (path, https URL, or hub id).
42
+ # ── Hub source ──────────────────────────────────────────────────────────────
43
+ # Self-contained hub_* functions (no globals besides DEFAULT_HUB) so a later
44
+ # `/skill install` / `/workspace import` can lift them out unchanged.
45
+
46
+ # Normalize a hub setting: empty / unsubstituted `{{…}}` → default,
47
+ # `owner/repo` → GitHub, an existing dir → absolute path, drop trailing `/`.
48
+ # A `.git` suffix stays (git mode clones the URL as given); the API helpers
49
+ # below drop it.
50
+ hub_url() {
51
+ local h=${1:-}
52
+ if [[ -z $h || $h == '{{'* ]]; then h=$DEFAULT_HUB; fi
53
+ if [[ $h =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9_.-]+$ ]]; then h="https://github.com/$h"
54
+ elif [[ -d $h ]]; then h=$(cd "$h" && pwd)
55
+ fi
56
+ echo "${h%/}"
57
+ }
58
+
59
+ # curl with the platform's optional token header.
60
+ hub_curl() {
61
+ local p=$1; shift
62
+ case $p in
63
+ github) curl -fsSL ${GITHUB_TOKEN:+-H "Authorization: Bearer $GITHUB_TOKEN"} "$@" ;;
64
+ gitlab) curl -fsSL ${GITLAB_TOKEN:+-H "PRIVATE-TOKEN: $GITLAB_TOKEN"} "$@" ;;
65
+ gitea) curl -fsSL ${GITEA_TOKEN:+-H "Authorization: token $GITEA_TOKEN"} "$@" ;;
66
+ esac
67
+ }
68
+
69
+ # Repo endpoint of <platform>'s API for hub URL $2.
70
+ hub_repo_api() {
71
+ [[ $2 =~ ^(https?://[^/]+)/(.+)$ ]] || return 1
72
+ local base=${BASH_REMATCH[1]} path=${BASH_REMATCH[2]%.git}
73
+ case $1 in
74
+ github) echo "https://api.github.com/repos/$path" ;;
75
+ gitea) echo "$base/api/v1/repos/$path" ;;
76
+ gitlab) echo "$base/api/v4/projects/${path//\//%2F}" ;;
77
+ esac
78
+ }
79
+
80
+ hub_releases_api() {
81
+ local q='per_page=100'
82
+ if [[ $1 == gitea ]]; then q='limit=50'; fi
83
+ echo "$(hub_repo_api "$1" "$2")/releases?$q"
84
+ }
85
+
86
+ # 0 when the host answers <platform>'s repo endpoint for $2 with JSON.
87
+ hub_probe() {
88
+ local api ct
89
+ api=$(hub_repo_api "$1" "$2") || return 1
90
+ ct=$(hub_curl "$1" --max-time 10 -o /dev/null -w '%{content_type}' "$api" 2>/dev/null) || return 1
91
+ [[ $ct == application/json* ]]
92
+ }
93
+
94
+ # github | gitlab | gitea | git (= tag-based fallback) for a normalized hub URL.
95
+ hub_platform() {
96
+ if [[ ! $1 =~ ^https?://([^/]+)/[^/]+/.+$ ]]; then echo git; return; fi
97
+ case ${BASH_REMATCH[1]} in
98
+ github.com) echo github ;;
99
+ *gitlab*) echo gitlab ;;
100
+ codeberg.org|*gitea*|*forgejo*) echo gitea ;;
101
+ *) if hub_probe gitea "$1"; then echo gitea
102
+ elif hub_probe gitlab "$1"; then echo gitlab
103
+ else echo git; fi ;;
104
+ esac
105
+ }
106
+
107
+ hub_releases_page() {
108
+ if [[ $1 == gitlab ]]; then echo "${2%.git}/-/releases"; else echo "${2%.git}/releases"; fi
109
+ }
110
+
111
+ # stdin = <platform>'s release list (newest-first); print the first `.zip`
112
+ # asset URL of the newest non-draft / non-prerelease / non-upcoming release
113
+ # tagged `<id>-v*`, exit 2 if there is none.
114
+ hub_pick_release() {
115
+ node -e '
116
+ let s = ""; process.stdin.on("data", (d) => s += d).on("end", () => {
117
+ const [platform, id] = process.argv.slice(1)
118
+ // "<id>-v<digit>", so id glb never matches a glb-viewer-v… tag.
119
+ const r = JSON.parse(s).find((r) => new RegExp("^" + id + "-v\\d").test(r.tag_name) && !r.draft && !r.prerelease && !r.upcoming_release)
120
+ const assets = !r ? [] : platform === "gitlab"
121
+ ? ((r.assets && r.assets.links) || []).map((l) => ({ name: l.name, url: l.direct_asset_url || l.url }))
122
+ : (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url }))
123
+ const a = assets.find((a) => String(a.name).endsWith(".zip"))
124
+ if (!a) process.exit(2)
125
+ console.log(a.url)
126
+ })' "$1" "$2"
127
+ }
128
+
129
+ # stdin = `git ls-remote --tags --refs`; print the highest `<id>-vX.Y.Z` tag
130
+ # (prerelease `-…` suffixes skipped), nothing if there is none.
131
+ hub_pick_tag() {
132
+ local v
133
+ v=$(sed -n "s|.*refs/tags/$1-v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$|\1|p" | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)
134
+ if [[ -n $v ]]; then echo "$1-v$v"; fi
135
+ }
136
+
137
+ # Tag-based hub: shallow-clone the newest `<id>-v<semver>` tag and echo
138
+ # `extensions/<id>/` minus the build inputs scripts/pack.mjs leaves out of a
139
+ # release zip. A package with a build step is refused — its output isn't in git.
140
+ hub_git_package() {
141
+ local hub=$1 id=$2 tmp=$3 url=$1 refs tag dir
142
+ if [[ $url == /* ]]; then url="file://$url"; fi # a plain path makes git ignore --depth
143
+ refs=$(git ls-remote --tags --refs "$url") || { echo "cannot list tags of $hub (git ls-remote failed)" >&2; exit 1; }
144
+ tag=$(printf '%s\n' "$refs" | hub_pick_tag "$id")
145
+ [[ -n $tag ]] || { echo "no release for $id in $hub (no $id-v<x.y.z> tag)" >&2; exit 1; }
146
+ git -c advice.detachedHead=false clone -q --depth 1 --branch "$tag" "$url" "$tmp/repo" || { echo "git clone of $tag from $hub failed" >&2; exit 1; }
147
+ dir="$tmp/repo/extensions/$id"
148
+ [[ -d $dir ]] || { echo "$hub tag $tag has no extensions/$id/" >&2; exit 1; }
149
+ if [[ -e $dir/fetch-deps.sh || -e $dir/build.sh ]]; then
150
+ echo "$id needs a build step — install it from a release zip (install <path/to.zip> or <https://…zip>)" >&2; exit 1
151
+ fi
152
+ rm -rf "$dir/node_modules" "$dir/src" "$dir/package.json" "$dir/package-lock.json"
153
+ echo "$dir"
154
+ }
155
+
156
+ # Fetch hub package <id> from hub URL $1 into $3: echo a zip path (release
157
+ # API) or the package dir (git tags). API errors never fall back to git — a
158
+ # git checkout of a build-step package would install broken.
159
+ hub_fetch() {
160
+ local hub=$1 id=$2 tmp=$3 platform api url token page
161
+ platform=$(hub_platform "$hub")
162
+ if [[ $platform == git ]]; then hub_git_package "$hub" "$id" "$tmp"; return; fi
163
+ api=$(hub_releases_api "$platform" "$hub")
164
+ page=$(hub_releases_page "$platform" "$hub")
165
+ case $platform in github) token=GITHUB_TOKEN ;; gitlab) token=GITLAB_TOKEN ;; *) token=GITEA_TOKEN ;; esac
166
+ hub_curl "$platform" "$api" -o "$tmp/releases.json" \
167
+ || { echo "$platform release API failed for $hub — rate-limited, private or wrong repo? set $token, or download the zip from $page and run: install <path>" >&2; exit 1; }
168
+ url=$(hub_pick_release "$platform" "$id" <"$tmp/releases.json") \
169
+ || { echo "no release for $id in $hub — check the id, or download the zip from $page and run: install <path>" >&2; exit 1; }
170
+ # No token here: asset links redirect to other hosts and curl -L would
171
+ # forward a custom header like PRIVATE-TOKEN there.
172
+ curl -fsSL "$url" -o "$tmp/pkg.zip" || { echo "download failed: $url" >&2; exit 1; }
173
+ echo "$tmp/pkg.zip"
174
+ }
175
+
176
+ # Echo a local zip or an unpacked package dir for source $1 (path, https URL, or hub id).
42
177
  resolve_source() {
43
- local src=$1 url
178
+ local src=$1
44
179
  if [[ -f $src ]]; then echo "$src"; return; fi
45
180
  if [[ $src =~ ^https?:// ]]; then curl -fsSL "$src" -o "$TMP/pkg.zip"; echo "$TMP/pkg.zip"; return; fi
46
181
  [[ $src =~ $ID_RE ]] || { echo "invalid id or missing file: $src" >&2; exit 1; }
47
- # Newest non-draft, non-prerelease release tagged "<id>-v*" (API is newest-first).
48
- url=$(curl -fsSL ${GITHUB_TOKEN:+-H "Authorization: Bearer $GITHUB_TOKEN"} \
49
- "https://api.github.com/repos/$REPO/releases?per_page=100" \
50
- | node -e '
51
- let s = ""; process.stdin.on("data", (d) => s += d).on("end", () => {
52
- const id = process.argv[1]
53
- const r = JSON.parse(s).find((r) => r.tag_name.startsWith(id + "-v") && !r.draft && !r.prerelease)
54
- const a = r && r.assets.find((a) => a.name.endsWith(".zip"))
55
- if (!a) process.exit(2)
56
- console.log(a.browser_download_url)
57
- })' "$src") \
58
- || { echo "no release for $src in $REPO — download the zip from https://github.com/$REPO/releases and run: install <path>" >&2; exit 1; }
59
- curl -fsSL "$url" -o "$TMP/pkg.zip"
60
- echo "$TMP/pkg.zip"
182
+ hub_fetch "$(hub_url "${HALO_HUB_REPO:-}")" "$src" "$TMP"
61
183
  }
62
184
 
185
+ # Sourced (tests, later reuse of the hub_* functions): definitions only.
186
+ if [[ ${BASH_SOURCE[0]} != "$0" ]]; then return 0; fi
187
+
188
+ cmd=${1:?usage: ext.sh install <id|zip|url> | list | remove <id>}
189
+ shift || true
190
+
63
191
  case $cmd in
64
192
  install)
65
193
  src=${1:?usage: ext.sh install <id|zip|url>}
66
194
  TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
67
- zip=$(resolve_source "$src")
68
- # Same refusals as the server's zip installer: no `..`, no absolute paths,
69
- # no symlinks (zipinfo mode column starts with `l`).
70
- if unzip -Z1 "$zip" | grep -Eq '(^|/)\.\.(/|$)|^/'; then echo "zip contains ../ or absolute entries" >&2; exit 1; fi
71
- if unzip -Z "$zip" | grep -q '^l'; then echo "zip contains symlinks" >&2; exit 1; fi
72
- mkdir -p "$TMP/x"
73
- unzip -q "$zip" -d "$TMP/x"
74
- # `zip -r glb.zip glb/` wraps everything in one directory — descend into it.
75
- dir="$TMP/x"
76
- if [[ ! -f $dir/halo-extension.json ]]; then
77
- only=$(find "$dir" -mindepth 1 -maxdepth 1 | head -2)
78
- if [[ $(printf '%s\n' "$only" | wc -l) -eq 1 && -f $only/halo-extension.json ]]; then dir=$only; fi
195
+ pkg=$(resolve_source "$src")
196
+ if [[ -d $pkg ]]; then
197
+ dir=$pkg
198
+ else
199
+ # Same refusals as the server's zip installer: no `..`, no absolute paths,
200
+ # no symlinks (zipinfo mode column starts with `l`).
201
+ if unzip -Z1 "$pkg" | grep -Eq '(^|/)\.\.(/|$)|^/'; then echo "zip contains ../ or absolute entries" >&2; exit 1; fi
202
+ if unzip -Z "$pkg" | grep -q '^l'; then echo "zip contains symlinks" >&2; exit 1; fi
203
+ mkdir -p "$TMP/x"
204
+ unzip -q "$pkg" -d "$TMP/x"
205
+ # `zip -r glb.zip glb/` wraps everything in one directory — descend into it.
206
+ dir="$TMP/x"
207
+ if [[ ! -f $dir/halo-extension.json ]]; then
208
+ only=$(find "$dir" -mindepth 1 -maxdepth 1 | head -2)
209
+ if [[ $(printf '%s\n' "$only" | wc -l) -eq 1 && -f $only/halo-extension.json ]]; then dir=$only; fi
210
+ fi
79
211
  fi
80
212
  expect=""
81
213
  if [[ $src =~ $ID_RE && ! -f $src ]]; then expect=$src; fi
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: web-search
3
- description: Real-time web search with two gears. Fast (default, Amazon Nova grounding, ~3s, cheap) — current events, news, prices, any routine lookup. Deep (--deep, OpenAI GPT-5.6 web_search via Bedrock Mantle, ~20-40s, token-expensive) — multi-round retrieval with first-hand sources and per-claim citations, for verifying facts behind important decisions or double-checking weak/contradictory fast results. Default to fast; escalate to deep only when the question deserves depth. For AWS documentation, service announcements or regional availability, use the aws-knowledge skill instead.
3
+ description: Real-time web search on AWS Bedrock (needs AWS credentials with Bedrock access) with two gears. Fast (default, Amazon Nova grounding, ~3s, cheap) — current events, news, prices, any routine lookup. Deep (--deep, OpenAI GPT-5.6 web_search via Bedrock Mantle, ~20-40s, token-expensive) — multi-round retrieval with first-hand sources and per-claim citations, for verifying facts behind important decisions or double-checking weak/contradictory fast results. Default to fast; escalate to deep only when the question deserves depth. For AWS documentation, service announcements or regional availability, use the aws-knowledge skill instead.
4
4
  user-invocable: false
5
5
  ---
6
6
  # Web Search
@@ -22,12 +22,18 @@ searches against official sites), leans toward authoritative first-hand sources
22
22
  Use `<workspace>/.halo/skills/web-search/search.py` if that exists, else
23
23
  `~/.halo/global/skills/web-search/search.py`.
24
24
 
25
+ Always prefix the call with the two region variables, keeping
26
+ `{{params.fast_region}}` / `{{params.deep_region}}` exactly as written (the
27
+ system substitutes them at runtime; unset = built-in defaults):
28
+
25
29
  ```bash
26
30
  # fast (default) — everyday lookups
27
- python3 ~/.halo/global/skills/web-search/search.py "What are the latest news about the China A-share market today?"
31
+ WEB_SEARCH_FAST_REGION='{{params.fast_region}}' WEB_SEARCH_DEEP_REGION='{{params.deep_region}}' \
32
+ python3 ~/.halo/global/skills/web-search/search.py "What are the latest news about the China A-share market today?"
28
33
 
29
34
  # deep — verify facts that must be right
30
- python3 ~/.halo/global/skills/web-search/search.py --deep "What did the US Federal Reserve announce most recently about interest rates?"
35
+ WEB_SEARCH_FAST_REGION='{{params.fast_region}}' WEB_SEARCH_DEEP_REGION='{{params.deep_region}}' \
36
+ python3 ~/.halo/global/skills/web-search/search.py --deep "What did the US Federal Reserve announce most recently about interest rates?"
31
37
  ```
32
38
 
33
39
  - Output: answer text + a `Sources:` URL list (gear/model/token usage on stderr).
@@ -40,8 +46,16 @@ python3 ~/.halo/global/skills/web-search/search.py --deep "What did the US Feder
40
46
  English works best; Chinese works too — deep gear rewrites queries bilingually.
41
47
  - Search output is still a lead, not gospel: for critical figures, `web_fetch`
42
48
  the cited source URL and confirm before acting on it.
43
- - Auth is automatic in both gears (machine AWS credentials — no API key to
44
- configure or rotate). Deep gear needs the `aws-bedrock-token-generator` pip
45
- package (one-time: `pip3 install --user aws-bedrock-token-generator`).
49
+ - Both gears run on AWS Bedrock with the machine's AWS credentials (no API key):
50
+ fast = Amazon Nova 2 Lite + web grounding (`bedrock:InvokeModel` +
51
+ `bedrock:InvokeTool` on `amazon.nova_grounding`, US regions only); deep =
52
+ OpenAI GPT-5.6 on Bedrock Mantle (`bedrock-mantle:CreateInference` +
53
+ `bedrock-mantle:CallWithBearerToken`). Deep gear needs the
54
+ `aws-bedrock-token-generator` pip package (one-time:
55
+ `pip3 install --user aws-bedrock-token-generator`). An AccessDenied /
56
+ credentials error means this machine lacks that access — say so, don't retry.
57
+ - Regions are set in Settings → Skills → web-search (`fast_region` default
58
+ us-east-1; `deep_region` default us-west-2 for luna, us-east-1 for sol — sol
59
+ isn't offered in us-west-2).
46
60
  - A deep call can take 40 s — be patient in shell_exec. One clear question per
47
61
  call; don't fan out parallel calls.
@@ -0,0 +1,13 @@
1
+ displayName_zh: 联网搜索
2
+ description: Real-time web search on AWS Bedrock — needs AWS credentials with Bedrock access.
3
+ description_zh: 基于 AWS Bedrock 的实时联网搜索,需要有 Bedrock 权限的 AWS 凭证。
4
+
5
+ params:
6
+ - key: fast_region
7
+ description: "AWS region for the fast gear (Amazon Nova 2 Lite + web grounding, US regions only: us-east-1 / us-east-2 / us-west-2). Empty = us-east-1."
8
+ description_zh: "快速档(Amazon Nova 2 Lite + Web Grounding)使用的 AWS 区域,仅限美国区域:us-east-1 / us-east-2 / us-west-2。留空 = us-east-1。"
9
+ default: us-east-1
10
+ - key: deep_region
11
+ description: "AWS region for the deep gear (OpenAI GPT-5.6 on Bedrock Mantle). Luna: us-east-1 / us-east-2 / us-west-2; Sol: us-east-1 / us-east-2. Empty = us-west-2 for Luna, us-east-1 for Sol."
12
+ description_zh: "深度档(Bedrock Mantle 上的 OpenAI GPT-5.6)使用的 AWS 区域。Luna:us-east-1 / us-east-2 / us-west-2;Sol:us-east-1 / us-east-2。留空 = Luna 用 us-west-2,Sol 用 us-east-1。"
13
+ default: us-west-2
@@ -12,27 +12,44 @@ Usage:
12
12
  Auth is automatic in both gears (machine AWS credentials): fast uses boto3's
13
13
  default credential chain; deep mints a 12-hour bearer token on every call via
14
14
  aws-bedrock-token-generator — no API key to configure or rotate.
15
+
16
+ Regions: WEB_SEARCH_FAST_REGION / WEB_SEARCH_DEEP_REGION override the defaults
17
+ below (the skill passes its fast_region / deep_region params through them).
15
18
  """
16
19
  import json
20
+ import os
17
21
  import sys
18
22
  import urllib.error
19
23
  import urllib.request
20
24
 
21
- MANTLE_URL = "https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses"
22
25
  MODELS = {"luna": "openai.gpt-5.6-luna", "sol": "openai.gpt-5.6-sol"}
26
+ # Nova web grounding is US-only; us-east-1 / us-east-2 / us-west-2 all verified.
27
+ FAST_REGION = "us-east-1"
28
+ # bedrock-mantle: Luna is in us-east-1 / us-east-2 / us-west-2, Sol only in
29
+ # us-east-1 / us-east-2. us-west-2 answered 3-8x faster than us-east-1 in
30
+ # 2026-10-04 tests, so Luna defaults there; Sol takes us-east-1.
31
+ DEEP_REGION = {"openai.gpt-5.6-luna": "us-west-2", "openai.gpt-5.6-sol": "us-east-1"}
23
32
  # luna (default) runs effort=max to squeeze out its retrieval planning; sol's
24
33
  # retrieval is already exhaustive at medium (measured 48 search queries on one
25
34
  # question), so it gets xhigh to avoid pointless token doubling — max over
26
35
  # xhigh measured only ~6% more reasoning tokens.
27
36
  EFFORTS = {"openai.gpt-5.6-luna": "max", "openai.gpt-5.6-sol": "xhigh"}
37
+ SOL_REGIONS = ("us-east-1", "us-east-2")
38
+
39
+
40
+ def env_region(name: str) -> str:
41
+ """Region from env; empty or an unsubstituted `{{…}}` skill-param placeholder = unset."""
42
+ v = os.environ.get(name, "").strip()
43
+ return "" if v.startswith("{{") else v
28
44
 
29
45
 
30
46
  def fast_search(question: str) -> None:
31
47
  import boto3
32
48
  from botocore.config import Config
33
49
 
50
+ region = env_region("WEB_SEARCH_FAST_REGION") or FAST_REGION
34
51
  bedrock = boto3.client(
35
- "bedrock-runtime", region_name="us-east-1", config=Config(read_timeout=60)
52
+ "bedrock-runtime", region_name=region, config=Config(read_timeout=60)
36
53
  )
37
54
  response = bedrock.converse(
38
55
  modelId="us.amazon.nova-2-lite-v1:0",
@@ -55,13 +72,17 @@ def fast_search(question: str) -> None:
55
72
  for u in sources:
56
73
  print(" -", u)
57
74
  usage = response.get("usage", {})
58
- print(f"\n[gear=fast model=nova-2-lite total_tokens={usage.get('totalTokens')}]", file=sys.stderr)
75
+ print(f"\n[gear=fast model=nova-2-lite region={region} total_tokens={usage.get('totalTokens')}]", file=sys.stderr)
59
76
 
60
77
 
61
78
  def deep_search(question: str, model: str) -> None:
62
79
  from aws_bedrock_token_generator import provide_token
63
80
 
64
- token = provide_token(region="us-east-2")
81
+ region = env_region("WEB_SEARCH_DEEP_REGION") or DEEP_REGION[model]
82
+ if model == MODELS["sol"] and region not in SOL_REGIONS:
83
+ print(f"[sol is not offered in {region}; using {DEEP_REGION[model]}]", file=sys.stderr)
84
+ region = DEEP_REGION[model]
85
+ token = provide_token(region=region)
65
86
  body = json.dumps(
66
87
  {
67
88
  "model": model,
@@ -71,7 +92,7 @@ def deep_search(question: str, model: str) -> None:
71
92
  }
72
93
  ).encode()
73
94
  req = urllib.request.Request(
74
- MANTLE_URL,
95
+ f"https://bedrock-mantle.{region}.api.aws/openai/v1/responses",
75
96
  data=body,
76
97
  headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
77
98
  method="POST",
@@ -101,7 +122,7 @@ def deep_search(question: str, model: str) -> None:
101
122
  for u in sources:
102
123
  print(" -", u)
103
124
  usage = data.get("usage", {})
104
- print(f"\n[gear=deep model={model} total_tokens={usage.get('total_tokens')}]", file=sys.stderr)
125
+ print(f"\n[gear=deep model={model} region={region} total_tokens={usage.get('total_tokens')}]", file=sys.stderr)
105
126
 
106
127
 
107
128
  def main() -> None: