@turmind/halo 1.6.3 → 1.6.5

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 (45) hide show
  1. package/README.md +66 -67
  2. package/admin-out/404.html +1 -1
  3. package/admin-out/_next/static/chunks/87-6a2716c98a819c4e.js +1 -0
  4. package/admin-out/_next/static/chunks/989-066a874961bd519f.js +1 -0
  5. package/admin-out/_next/static/chunks/app/page-812bcbb46db628b6.js +1 -0
  6. package/admin-out/_next/static/css/e890aa0d13e94290.css +1 -0
  7. package/admin-out/index.html +1 -1
  8. package/admin-out/index.txt +5 -5
  9. package/bin/halo.js +4 -0
  10. package/bundled-docs/design/architecture.md +1 -1
  11. package/bundled-docs/design/canvas-extensions.md +40 -9
  12. package/bundled-docs/design/cron.md +12 -4
  13. package/bundled-docs/design/halo-city.md +14 -149
  14. package/bundled-docs/design/observability.md +7 -5
  15. package/bundled-docs/design/storage.md +14 -7
  16. package/bundled-docs/design/stt-local.md +106 -0
  17. package/bundled-docs/design/web.md +1 -1
  18. package/bundled-docs/design/wechat.md +1 -1
  19. package/bundled-docs/dev/add-model-provider.md +2 -1
  20. package/bundled-docs/dev/api.md +13 -9
  21. package/bundled-docs/dev/deploy.md +114 -26
  22. package/bundled-docs/dev/desktop-packaging.md +50 -10
  23. package/bundled-docs/dev/env.md +4 -4
  24. package/bundled-docs/dev/logging.md +13 -2
  25. package/bundled-docs/dev/tools.md +2 -2
  26. package/bundled-docs/guide/agents.md +2 -2
  27. package/bundled-docs/guide/cli.md +20 -8
  28. package/bundled-docs/guide/getting-started.md +13 -5
  29. package/bundled-docs/guide/workspace.md +2 -0
  30. package/bundled-docs/requirements/agent.md +1 -1
  31. package/bundled-docs/requirements/chat.md +8 -0
  32. package/bundled-docs/requirements/explorer.md +14 -2
  33. package/bundled-docs/requirements/settings.md +2 -2
  34. package/bundled-docs/requirements/workspace.md +4 -0
  35. package/dist/index.js +3645 -1725
  36. package/package.json +4 -2
  37. package/templates/models/gemini.yaml +93 -0
  38. package/templates/skills/cron/SKILL.md +2 -1
  39. package/templates/skills/cron/manage-cron.py +2 -1
  40. package/admin-out/_next/static/chunks/147-d4200c70445aabb6.js +0 -1
  41. package/admin-out/_next/static/chunks/989-ddba0eb72566da23.js +0 -1
  42. package/admin-out/_next/static/chunks/app/page-7ffcbddb49dc56c4.js +0 -1
  43. package/admin-out/_next/static/css/3b4dcd0ac6b8eae8.css +0 -1
  44. /package/admin-out/_next/static/{k5zuJrivSdbaPifBatwdr → 8v1hCXbUqJhyMuK7LVeet}/_buildManifest.js +0 -0
  45. /package/admin-out/_next/static/{k5zuJrivSdbaPifBatwdr → 8v1hCXbUqJhyMuK7LVeet}/_ssgManifest.js +0 -0
@@ -29,7 +29,7 @@ Schedules are **durable** — the source of truth is the `cron_jobs` table; in-m
29
29
 
30
30
  - **Recurring**: standard cron expression (`job.schedule`), optional `timezone`, optional **active window** `[activeFrom, activeUntil)` (epoch ms, `active_from` / `active_until`, NULL = unbounded; `CRON_MIGRATIONS` slot 2). The window is passed to croner as native `startAt` / `stopAt` (`activeWindowOptions` in `runner.ts`; `startAt` is `activeFrom − 1 ms` because croner's startAt is exclusive), so fires outside it simply never happen — no `cron_runs` row. "Pause until X" = push `activeFrom` to X; there is no skip-list for a gap in the middle. Past `activeUntil` the job stays `enabled` (so it can be extended later) but `scheduleJob` creates no croner and logs `past activeUntil; not scheduled` once — the id goes into `_expired` so reconcile's "fingerprint cached but not in `_active`" retry branch treats it as handled instead of re-scheduling (and re-logging) every 10s. The REST `nextRunAt` uses the same options, so the list shows the first post-pause fire; the admin badges a job "active from …" / "ended". Run-now ignores the window. Recurring only: a window together with `runAt` is rejected at every write surface (REST on the merged row, skill helper).
31
31
  - **One-shot**: `job.runAt` (epoch ms) — croner fires once at that instant. After it completes, `finalize` sets `enabled=0` so it never re-fires. A `runAt` already in the past at schedule time is marked `lastRunStatus='missed'` and disabled (rather than firing immediately or retrying every reconcile). The `run_at` column is `CRON_MIGRATIONS` slot 0 in `db/cron-db.ts`, run through the shared `runMigrations` / `PRAGMA user_version` mechanism (see [storage.md](storage.md#schema-change-rules)).
32
- - **Folder + stateless** (`CRON_MIGRATIONS` slot 4, v5): `cron_jobs.working_dir` — the folder the run focuses on, passed as `halo cli --dir` (same semantics as a sub-agent's `working_dir`: the folder's `.halo/INSTRUCTIONS.md` chain joins the prompt, plus a `Working directory:` line); stored workspace-relative, NULL = the workspace root; validated at every write surface (inside the workspace, existing — REST re-checks a stored folder when `workspacePath` moves). `cron_jobs.stateless` (0/1) — **new session every run**: each fire gets a fresh root session `cron-<id>-<base36 ms>` (`perRunSessionId`, same no-double-prefix rule as `effectiveSessionId`), so nothing accumulates across fires (only the newest 10 runs are kept; per-run sessions are deleted along with their run rows, see [Retention](#retention)); mutually exclusive with `session_id` (`400` on the merged row). `cron_runs.session_id` — the session that run used, the only record of a stateless run's id (NULL on rows from before v5).
32
+ - **Folder + stateless** (`CRON_MIGRATIONS` slot 4, v5): `cron_jobs.working_dir` — the folder the run focuses on, passed as `halo cli --dir` (same semantics as a sub-agent's `working_dir`: the folder's `.halo/INSTRUCTIONS.md` chain joins the prompt, plus a `Working directory:` line); stored workspace-relative, NULL = the workspace root; validated at every write surface (inside the workspace, existing — REST re-checks a stored folder when `workspacePath` moves). `cron_jobs.stateless` (0/1) — **new session every run**: each fire gets a fresh root session `cron-<id>-<base36 ms>` (`perRunSessionId`, same no-double-prefix rule as `effectiveSessionId`), so nothing accumulates across fires (each job keeps only its own newest 10 runs; per-run sessions are deleted along with their run rows, see [Retention](#retention)); mutually exclusive with `session_id` (`400` on the merged row). `cron_runs.session_id` — the session that run used, the only record of a stateless run's id (NULL on rows from before v5). `cron_runs.workspace_path` (slot 5, v6) — the workspace the run fired against (the job's at fire time), i.e. where a stateless run's session lives even after the job moves workspace or is deleted; v6 backfills it from the job row, leaving NULL on rows whose job was already gone.
33
33
 
34
34
  ### Trigger-mode exclusivity (the db contract)
35
35
 
@@ -59,7 +59,7 @@ Each fire:
59
59
  - The server itself is mid-turn anywhere in that session's tree (`hasActiveWorkInTree` on the workspace's live SessionManager, looked up with the registry's `peek` — cache-only, never builds a manager, whose constructor would run the boot reconcile as a side effect) → `session <id> busy: a turn is running in it`. In practice this is a picked session someone is chatting in.
60
60
 
61
61
  Two overlapping writers would double-write the same on-disk session state; SessionManager's per-session lock is in-process and can't see across cli children, so these sets are the cheapest place to enforce serialization. They are in-memory, but their restart blind spot is covered: the boot-time orphan sweep (below) re-enters stale jobIds and session keys before croner re-arms. The runner reaches the server's managers through `setCronSessionRegistry(registry)` (called in `index.ts` next to `setRelayRegistry`); unset — tests, a bare CLI — means there are no server-side sessions to guard.
62
- 2. Insert a `cron_runs` row with `status='running'` up front (UI sees it mid-flight), recording the session it runs in (`cron_runs.session_id`); broadcast `cron:run_changed`.
62
+ 2. Insert a `cron_runs` row with `status='running'` up front (UI sees it mid-flight), recording the session it runs in (`cron_runs.session_id`) and the job's current workspace (`cron_runs.workspace_path`; step 1's `skipped` row records the workspace too); broadcast `cron:run_changed`.
63
63
  3. Check `job.workspacePath` exists — fail loudly if the workspace was moved/deleted. Same for the job folder (`job.workingDir`, re-resolved with `resolveWorkingDir`): moved / deleted since the job was saved → `failed` with `folder not found: …`, nothing spawned.
64
64
  4. Spawn `halo cli -a <agent> -s <session> -w <workspace> --dir=<folder|.> [--access <level>]`, prompt on **stdin** (avoids Windows argv length limits), with a fixed `[Unattended cron run — nobody will answer questions …]` line stamped ahead of `user_prompt` on every fire (`UNATTENDED_PREAMBLE` in `runner.ts`) — the model can't see the `cron-*` session id, and prompts typed into the admin Cron form carry no such notice, so the runner adds it rather than trusting the prompt author. **Session strategy**: every job runs in one stable session — its own by default, or an existing **root** session picked on the job (`cron_jobs.session_id`, nullable; `CRON_MIGRATIONS` slot 1 in `db/cron-db.ts`). The job's own session id **is the job id** (generated ids are already `cron-<ts>-<rand>`; a custom id without that prefix gets `cron-<id>`) — `effectiveSessionId` in `runner.ts` is the one copy of the rule, and the REST list returns its result as `effectiveSessionId` so the admin never re-derives it. Older builds always prefixed, giving `cron-cron-…`; `CRON_MIGRATIONS` slot 3 (v4) pins every job that ever spawned a cli (`last_run_id` set, or a non-skipped run row — covers a first run in flight across the upgrade restart) to that session via `session_id`, so its history and the orphan sweep's `-s` fingerprint stay the same; jobs that never ran take the new rule. The cli's `-s` is create-on-missing, so the first fire creates the session with the job's agent; every later fire *resumes the same session*, so the conversation accumulates and is reviewable in the admin Sessions tab. A **stateless** job (`cron_jobs.stateless`) skips all of this: every fire creates a fresh session (`perRunSessionId`), recorded on the run row.
65
65
 
@@ -120,8 +120,16 @@ interface CronChannelDispatcher {
120
120
 
121
121
  ## Retention
122
122
 
123
- - **Runs**: keep newest `RUNS_PER_JOB_KEEP` (100) rows per job, `STATELESS_RUNS_PER_JOB_KEEP` (10) for a stateless job; older pruned on each new run (`pruneOldRuns`). A stateless run's session follows its row: a run whose `session_id` is its own `perRunSessionId` has that session deleted first (through the live session manager when the workspace is open, else its db row + files directly), skipping a session that is still running or that the user continued (`updated_at` after the run's `completed_at`). A stateful job's session is never deleted. Deleting a job (`deleteJobRuns`) applies the same rule to all its runs.
124
- - **Logs**: files older than 30 days deleted on a daily timer (db row kept; UI shows "log unavailable"). `cron_runs` rows older than 60 days are also dropped.
123
+ - **Runs**: the limit is **per job**: the newest `RUNS_PER_JOB_KEEP` (100) rows of each job, `STATELESS_RUNS_PER_JOB_KEEP` (10) for a stateless job, pruned after each of that job's runs finalizes (`pruneOldRuns`). Each stateless job keeps its own 10 runs and their sessions, so several stateless jobs together showing more than 10 `cron-…` sessions is normal.
124
+ - **A stateless run's session follows its row** (`deleteRuns`): a run whose `session_id` is its own `perRunSessionId` has that session tree deleted first (root + `root>…`: db rows, files, archive segments; through the live session manager when the workspace is open, else the rows + files directly), then the row. The workspace to look in is the run's `cron_runs.workspace_path`, so sessions left in the old workspace are still found after the job moves (REST PUT or skill `update --workspace`). A NULL `workspace_path` (a row from before v6) falls back to the job's current workspace. A row with neither, or a workspace without a `halo.db`, has no session to delete, so only the row goes. A stateful job's own session, or a picked one, is never deleted.
125
+ - Session kept, row deleted: a turn is running in the tree (`hasActiveWorkInTree`), or the user continued it (any row's `updated_at` after the run's `completed_at`).
126
+ - Row and session both kept: the run is still in progress (`status='running'` / `completed_at` NULL), or the session delete threw. The next prune retries.
127
+ - **Reclaim paths** (all end in `deleteRuns`):
128
+ - **Per-run prune**: `pruneOldRuns` after every finalize.
129
+ - **REST delete**: `DELETE /api/cron/jobs/:id` runs `deleteJobRuns` before deleting the job row.
130
+ - **Skill delete**: `manage-cron.py delete` drops only the `cron_jobs` row. The server's next full `reconcileFromDb` pass then calls `deleteOrphanRuns` without awaiting it (failures are logged as warnings). That function handles every run whose `job_id` is no longer in `cron_jobs`, so rows and sessions go within about 10 s. A full pass runs after any other connection writes `cron.db`, and once on the first tick after boot, which also covers jobs deleted while the server was down.
131
+ - **Deleted mid-run** (REST or skill): the delete keeps the in-progress row and its session. After the run finalizes, `pruneOldRuns` finds the job row gone and calls `deleteJobRuns`, which removes both.
132
+ - **Logs**: files older than 30 days deleted on a daily timer (db row kept; UI shows "log unavailable"). `cron_runs` rows older than 60 days are also dropped, through `deleteRuns`, so the session rule above applies.
125
133
 
126
134
  ## Key files
127
135
 
@@ -1,128 +1,22 @@
1
1
  # Halo City · 像素街区可视化
2
2
 
3
- Halo 服务器运行态的**街景式**像素可视化:侧视、剖面的像素城,实时天色下,
4
- 每个 **workspace 是一栋楼**,每个 **agent session 是一只 Q 版动物市民**,爬真楼梯
5
- 到真工位上班。点任何人打开他的**真实会话日志**(服务端流式拉取,和 admin 面板同源)。
3
+ Halo 服务器运行态的**街景式**像素可视化:每个 **workspace 是一栋楼**,每个
4
+ **agent session 是一只 Q 版动物市民**,点任何人打开他的真实会话日志。前端从
5
+ 1.6.5 起迁到 halo-hub,作为扩展 `halo-city` 安装(装好后 admin 活动栏多一个
6
+ 入口);本仓库只留数据接口 `packages/server/src/routes/halo-city.ts`。
6
7
 
7
- 只读、零构建、零贴图素材(每个像素都是代码画的)。网络流量:一个
8
- `GET /api/show/state` 轮询,外加面板打开时的 `GET /api/show/session`。
9
-
10
- > 接口契约见 [dev/api.md](../dev/api.md#show-world-snapshot)(`/api/show/state`、
11
- > `/api/show/session` 的字段与鉴权)。本文只讲前端可视化设计。
12
-
13
- ## 街区结构
14
-
15
- ```
16
- ☀/☾ parallax skyline · clouds · stars ──────────────────────────
17
- ┌─[roof: water tank / AC / antenna / billboard]─┐
18
- │ W3 desks ×3 · skill station · flavor prop │
19
- ├───────────────────────────────────────────────┤ ┌──────────┐
20
- │ C 公共层: 冰箱·水吧·书架·沙发·街机/鱼缸/猫 ─[阳台]│ │ next │
21
- ├───────────────────────────────────────────────┤ │ building │
22
- │ W2 / W1 work floors … │ │ │
23
- ├───────────────────────────────────────────────┤ │ │
24
- │ 大堂: 前台 · 咖啡机 · 沙发 · 大门 │ │ │
25
- └─⌐stairs──────────────────────────────awning───┘ └──────────┘
26
- ──┴── sidewalk · 餐车 · 野餐桌 · 篮筐 · 单车架 · 路灯 ──┴───────────
27
- road (楼距加宽:巷子就是楼下放风的地方)
28
- ```
29
-
30
- | 看到的 | 意思 |
31
- |---|---|
32
- | 🏬 一栋楼 | 一个 workspace。**每 3 个工作层插 1 个公共层**(楼够高时),楼层数**贴合当前 root 会话数**(每层对应一个真实会话,顶上不留空层;增减都跟,但保留 1 层缓冲——会话数在两次轮询间 ±1 抖动不会让楼顶反复抽搐);砖材/霓虹竖牌/楼顶装置由 id hash 决定 |
33
- | 🐾 动物市民 | 一个 agent session。**制服**(衬衫+条纹)认 agent 名 → 在哪都认得出"researcher";**物种/毛色/花纹/配件**(10 物种 × 多毛色 × 眼镜/耳机/斑纹)认 session id → 同 agent 并发的 session 是穿同款衬衫的不同动物。会眨眼,说话动嘴,狗走路摇尾巴 |
34
- | 🪜 楼梯间 | 大楼左侧,真的逐层爬上爬下(没有传送) |
35
- | 💺 工作层 | 每层 3 张工位(按 agent 名固定)+ 1 个技能站 + 随机小设施(盆栽/小书架/白板/售货机);干活时屏幕滚代码 |
36
- | 🍵 公共层 | 冰箱(偶尔开门漏光)、水吧(水壶冒汽)、书报架、沙发,外加街机/鱼缸/猫爬架三选一;右侧伸出**阳台**(烟灰桶+盆栽)——抽烟点 |
37
- | 🚬 习惯 | 每个 session 由 id hash 派生 **3 个固定偏好**(抽烟/咖啡/泡茶/翻冰箱/看书/街机/看鱼/撸猫/打电话/下楼遛弯/沙发/工位打游戏/工位刷手机),摸鱼时偏好权重 ~4×——"这个会话就爱在阳台抽烟"是稳定人设,不是随机骰子(工位打游戏/工位刷手机不挪窝,就在自己座位上摸鱼;面板文案里"工位刷手机"沿用了"打电话"那条文案) |
38
- | 🧭 就近原则 | 市民**生在自己的归属层**(自己的工位层;子代理不论嵌套多深都解析到所在会话树的**根会话**工位层,不再掉进大堂)。休息活动以**归属层**为锚点找最近的公共层,不会为了倒杯茶爬通天,**也不会随时间往低层漂**——12 楼的人永远回 F11 休息,不会一路沉到大堂。街机/鱼缸/猫爬架这类稀有设施只在归属层 **±4 层**的漫游带内找最近一个,带外直接放弃改走最近的休息层;`下楼遛弯` 同样按漫游带算——归属层在 F3 及以下才真下街,F4+ 改在带内最近的休息层放风 |
39
- | 📌 稳定工位 | 每个**根会话**的工位楼层只在**第一次出现**时分配一次(选最低空位),离开释放、回来复用——不会因为 `/api/show/state` 按 `updated_at DESC` 排序每次轮询都重排,否则摸鱼回来就会撞上"工位换人"的错觉 |
40
- | 💡 灯光 | 有人在的楼层灯亮(running 或 idle 都算——摸鱼也开灯),只有彻底 stopped 的层才熄;天黑后街灯/霓虹/远景窗火全亮 |
41
- | ↳ 小一号 | 子代理:生在所在会话树的**根会话**工位层(而不是直接父代理那层),干活时聚在**直接**父代理桌边,不占工位 |
42
-
43
- 进出有戏:新会话直接出现在自己的工位层(从本层楼梯口走到工位,不再从街上爬通天);结束的走出门沿街远去淡出。
44
-
45
- ## 点击 = 真实数据
46
-
47
- 点任何市民,面板里是**真东西**:
48
-
49
- - **会话日志**:`/api/show/session` 拉来的最近 40 条消息流——用户输入、助手回复、
50
- 工具调用(带耗时),像 `tail -f` 一样贴底自动滚,面板开着每 6s 只刷日志框
51
- (面板本体原地补丁,不再整页重建闪烁)
52
- - **上下文仪表**:`134k / 200k (67%)` —— 分母是该会话的**真实** `maxContextTokens`
53
- - **委派链**:父/子会话可点击跳转,每行带 token 数和消息数
54
- - **点楼层**:该层的会话委派关系树(缩进、可点跳转)——工作层看"谁在这层干活、
55
- 带着哪些子代理",公共层看"谁在摸鱼";**工位在这层的人即使正外出休息也照样算在这层**
56
- (按归属工位层统计,不是按人此刻站在哪),点楼层永远能看全常驻名单
57
- - 点大楼:全员 roster + token 合计;点技能站:谁在用
58
-
59
- ## 街景与载具
60
-
61
- 街区不只是静态背景,会按**真实时间**自己演(`traffic.js`):
62
-
63
- | 事件 | 节奏 | 说明 |
64
- |---|---|---|
65
- | 🛸 UFO | 每天 0:00 | 午夜从街头飘过 |
66
- | ✈️ 飞机 | 每半点(`:30`) | 高空掠过,方向随机 |
67
- | 🚌 巴士 | 每 3 分钟 | 进站、开门、接走在站台等车的人,再开走 |
68
- | 🚗 进出场载具 | 跟随会话 | 新根会话坐出租车或小轿车送到门口(子代理直接淡入,不配车);结束的会话走到站台**体面地等车离场**,不再凭空消失 |
69
- | 🏗️ 盖楼 / 拆楼 | 跟随楼层增减 | 长楼层有施工动效;拆楼留 ~30s 缓冲(先让人撤离再慢慢消散,不穿帮) |
70
-
71
- > 定时全用真实 `Date.now()` 计算(`?hour=` 只冻结天色、不冻结这些事件)。
72
-
73
- ## 交互与显示
74
-
75
- 操作:滚轮缩放(九档 `0.3×–6×`)· 拖拽平移 · 点击查看 · **双击**在 1×/3× 间快速切 ·
76
- **F** 全街区 · **H**(或点左上角 logo)切纯净模式 · **Esc** 先关面板,没有面板时切纯净模式 ·
77
- 右上角 **中/EN** 一键切语言。
78
-
79
- - **纯净模式(zen)**:淡出所有 HUD(统计/动态/图例/缩放条/面板),只留街景,适合大屏常驻展示
80
- - **多语言(i18n)**:中文 / English 一键切;`?lang=en`/`?lang=zh` 直达,选择存 localStorage。
81
- 运行时文案走 `t()`,静态 DOM 走 `data-i18n` 属性填充
82
- - **背景音乐**:🎵/🔇 HUD 按钮(挨着 中/EN),纯 Web Audio 程序生成、零素材,
83
- 默认开、偏好存 localStorage;受浏览器自动播放策略限制,AudioContext 在首次
84
- 用户手势后才创建,关掉时真正 suspend(零 CPU),音频图与画面 rAF 循环完全独立
85
-
86
- ## 调试参数
87
-
88
- - `?hour=22.5` — 把昼夜定格在某个时刻(支持小数;只冻结天色,不冻结街景事件)
89
- - `?lang=en` / `?lang=zh` — 指定语言
90
- - kiosk 直链:`/#api=…&token=…`(token 立即从地址栏抹掉)
91
-
92
- ## 文件结构
93
-
94
- ```
95
- js/
96
- main.js 入口:setup → world → 输入/轮询
97
- api.js state 轮询 + session 详情客户端(content-type 防呆)
98
- world.js 快照 → 城市/市民 diff,渲染循环,拾取
99
- city.js 街区:楼(材质/楼层/霓虹/楼顶)、大堂、街道、视差天际线
100
- citizen.js 市民:楼层×楼梯 2D 路径规划 + 行为 FSM
101
- people.js 人物绘制:动物物种×毛色×配件池,眨眼/口型
102
- props.js 家具:工位/技能站/咖啡机/街机/鱼缸/猫爬架/窗/灯
103
- palette.js EDG32 + 建筑材质 ramp + 关键帧天空
104
- camera.js 整数吸附镜头(九档缩放 0.3×–6×)
105
- inspector.js 面板 + 实时会话日志查看器
106
- ticker.js 街区动态 feed
107
- music.js 背景音乐:纯 Web Audio 程序生成
108
- traffic.js 街景事件:UFO(0点)/飞机(每半点)/进出场载具/每 3 分钟 bus/盖楼·拆楼
109
- i18n.js 多语言词典(zh/en)+ 运行时 t() + 静态 data-i18n 填充
110
- util.js hash/PRNG/格式化
111
- ```
8
+ > 前端设计(街区结构、交互、嵌入模式、文件结构、前端渲染性能)见 hub 的
9
+ > [extensions/halo-city/DESIGN.md](https://github.com/turmind/halo-hub/blob/main/extensions/halo-city/DESIGN.md)。本文只讲服务端配套。
112
10
 
113
11
  ## 服务端配套
114
12
 
115
- - **部署:和 Halo 同源。** 页面所在域名下把 `/api/show/` 反代到 Halo,浏览器
116
- 只访问自己的 origin,也就不经过 CORS(`main.js` 自动连接时先试
117
- `location.origin`)。不要跨域部署:请求带 `credentials: 'include'`,跨域时
118
- 只有 `server.cors_origins` 里逐字列出的 origin 才拿得到 Allow-Credentials,
119
- 换域名就挂;前置 SSO 的 307 也会被浏览器报成跨域错误。nginx 写法见
120
- [halo-city/README.md](../../../halo-city/README.md#正式部署和-halo-同源)
121
- - `GET /api/show/session?ws=&id=` — 只读会话详情:裁剪后的消息日志 + 真实
122
- token 上限。鉴权同 `/api/show/state`(x-token;`full`/`observer` 可跨
123
- workspace 读取,其余 accessLevel 只能看自己 workspace,**且只能看自己
124
- token 铸造的会话**(id 以 `web_<accountId>_` 开头);点其它市民会 403,
125
- 前端在 inspector 里显示加载失败框而不是 transcript
13
+ - 接口契约见 [dev/api.md](../dev/api.md#show-world-snapshot):`GET /api/show/state`
14
+ (快照轮询)和 `GET /api/show/session?ws=&id=`(只读会话详情)的字段与鉴权。
15
+ - **鉴权二选一**:带 token(`x-token`,Web Token,按 accessLevel 限定可见范围;
16
+ 带了 token 就只认 token),或不带 token、靠同源的 admin 登录 cookie,按 `full`
17
+ 处理——admin 里的扩展页就是这样免 token 的。
18
+ - 独立部署(不进 admin、走 token)要和 Halo 同源反代 `/api/show/`,nginx 写法见
19
+ hub [README.md](https://github.com/turmind/halo-hub/blob/main/extensions/halo-city/README.md#和-halo-同源)。
126
20
  - `/api/show/state` — 无活跃 UIState 的会话(idle/stopped/重启后)token
127
21
  从会话文件头读取(按会话 `updatedAt` 缓存,未变化时不随轮询重读),不显示 0;含 `messageCount` 字段
128
22
 
@@ -133,19 +27,7 @@ js/
133
27
  > 轮次数,归档后不缩)。城市这边继续走文件头是有意的:它必须能在**没有活跃
134
28
  > SessionManager**的情况下降级出图,不依赖 db 镜像何时写入。
135
29
 
136
- > **`observer` token 的权限面**:除了 `/show/state` 的聚合计数外,`observer`
137
- > 还能调 `/show/session` 读取**任意 workspace** 任意会话的明细——最近 40 条
138
- > 消息(每条截断到 600 字,tool input 截到 200 字)。它是"看板/监控"用的
139
- > 全局只读角色,但这条 transcript 读取能力不止是计数;签发 observer token 时
140
- > 要知道它相当于给了跨 workspace 的会话内容只读权限,不要把它当成纯粹的
141
- > "数字仪表盘"凭证随手发放。
142
-
143
- ## 性能设计
144
-
145
- 原则:一个纯只读可视化页面,轮询**不得对服务端会话生命周期产生任何副作用**,
146
- 前端渲染成本随"画面里实际可见、实际变化的东西"伸缩,而不是随世界大小。
147
-
148
- ### 轮询零副作用(服务端)
30
+ ## 轮询零副作用(服务端)
149
31
 
150
32
  show 路由只用 `registry.peek()`(只查内存 Map,**绝不创建** SessionManager——
151
33
  构造函数是写重的:boot 孤儿回收会批量 stop 子会话行、还会往目录里播 `.halo/`)。
@@ -153,20 +35,3 @@ peek miss 时降级为**只读 sqlite 快照**:readonly 模式直开该 workspac
153
35
  `.halo/halo.db`(连接进程内缓存,DB 缺失静默降级为空),行直接投影成 wire shape。
154
36
  代价是非内存会话的数据滞后到上次持久化点、live 信号(lastTool/activeSkill)为空
155
37
  ——对可视化用途是正确取舍。详见 [dev/api.md](../dev/api.md#show-world-snapshot)。
156
-
157
- ### 前端渲染(js/ 各文件的注释有完整细节)
158
-
159
- - **palette 记忆化**(palette.js):`shade/tint/alpha/mix` 纯函数按输入两级 Map
160
- 缓存;alpha 比例 8-bit 量化、sky 颜色按 5s 量化,确定性验证像素 0 差异
161
- - **vGradient 缓存**(city.js):sky/beach/sea/haze 的 `createLinearGradient`
162
- 按几何+颜色 key 缓存,相机静止时 steady-state 分配归零
163
- - **skyline offscreen layer**(city.js `Layer`):天际线剪影带在相机静止时每 5s
164
- sky tick 渲一次,其余帧一次 blit。**gradient 填充不得入 layer**——Skia 的
165
- gradient dither 锚定 device y,带偏移 blit 会整片移相(drawSea 因此回退直绘,
166
- 教训详见 [memory/2026-07-02-canvas-gradient-dither.md](../../memory/2026-07-02-canvas-gradient-dither.md))
167
- - **视口剔除**:楼整体(`buildingVisible`)与楼内逐层(`drawFloors`)双级剔除;
168
- 市民绘制列表同样剔除(但位置模拟不剔——否则回屏跳变)
169
- - **stateSig 签名比对**(main.js):快照签名(剔除 serverTime、uptime 取分钟粒度)
170
- 未变时跳过整个 ingest/diff/rebuild,空闲服务器的轮询近乎零成本
171
- - **后台停轮询**(api.js / inspector.js):`document.hidden` 时停止轮询,
172
- `visibilitychange` 恢复并立即刷一次——后台标签页零流量零 CPU
@@ -8,12 +8,12 @@ A vendor-neutral observability layer built on the official OpenTelemetry JS SDK.
8
8
 
9
9
  | key | type | default | description |
10
10
  |---|---|---|---|
11
- | `general.observability.endpoint` | string | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`). Traces, metrics and logs all exported over OTLP http/protobuf — `http://` or `https://`, on the collector's OTLP/HTTP port (4318 by default); gRPC (4317) not supported. Empty = off. Restart required. |
12
- | `general.observability.service_name` | string | `halo` | OTel resource `service.name`. Restart required. |
13
- | `general.observability.headers` | string, **secret** | `''` | Extra headers for every OTLP request, comma-separated `k=v` (e.g. `authorization=Bearer …`). Restart required. |
14
- | `general.observability.capture_content` | boolean | `false` | Put prompt / completion / tool argument+result text on spans (`gen_ai.*.messages` etc.). Off = metadata only (model, tokens, latency, tool names). Restart required. |
11
+ | `general.observability.endpoint` | string | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`). Traces, metrics and logs all exported over OTLP http/protobuf — `http://` or `https://`, on the collector's OTLP/HTTP port (4318 by default); gRPC (4317) not supported. Empty = off. Server: restart required; cron / evolution runs: next run. |
12
+ | `general.observability.service_name` | string | `halo` | OTel resource `service.name`. Server: restart required; cron / evolution runs: next run. |
13
+ | `general.observability.headers` | string, **secret** | `''` | Extra headers for every OTLP request, comma-separated `k=v` (e.g. `authorization=Bearer …`). Server: restart required; cron / evolution runs: next run. |
14
+ | `general.observability.capture_content` | boolean | `false` | Put prompt / completion / tool argument+result text on spans (`gen_ai.*.messages` etc.). Off = metadata only (model, tokens, latency, tool names). Server: restart required; cron / evolution runs: next run. |
15
15
 
16
- All four keys are `globalOnly` and take effect on server restart — `otel.ts` reads them once at boot, before anything else touches the OTel API. A `halo cli` / `halo tui` process reads them the same way at its own start, so a change reaches the next run.
16
+ All four keys are `globalOnly`. The server's own exporter switches only on restart — `otel.ts` reads them once at boot, before anything else touches the OTel API. A `halo cli` / `halo tui` process reads them the same way at its own start, so a change reaches the next run — including the cron and evolution children the server spawns, which don't inherit the server's boot-time values (see the child-env rule below). A changed or cleared endpoint therefore reaches their next run without a server restart.
17
17
 
18
18
  **Which process exported.** Every process shares `service.name`, so the resource also carries `halo.process` = `server` \| `cli` \| `tui` (`HaloProcess` in `otel.ts`; `initObservability()` defaults to `server`). Cron and evolution children are `halo cli` runs, so they report `cli`; their `session.id` (`cron-<jobId>`, or the `__evo_agent__`-style agent name) tells them apart from a hand-run cli. The startup log line names it: `[Observability] OTLP export → … (service.name=…, halo.process=cli, …)`.
19
19
 
@@ -26,6 +26,8 @@ All four keys are `globalOnly` and take effect on server restart — `otel.ts` r
26
26
 
27
27
  No `OTEL_EXPORTER_OTLP_ENDPOINT` after the mapping → `enabled=false`, `initObservability()` returns without registering anything.
28
28
 
29
+ **Child env.** Every key the mapping actually set (one that was unset before) is recorded in `settingsOtelEnvKeys` (`child-env.ts`), and `cleanChildEnv()` strips those keys from every child it builds. Inherited, the server's boot-time values would win a cron / evolution `halo cli` child's own `??=`, so a changed endpoint would never arrive and a cleared one would keep exporting until a server restart. Operator-exported `OTEL_*` keys aren't recorded and still pass through ("operator wins" holds in children too). Side effect: terminals, sandbox shells and the Jupyter server — also built from `cleanChildEnv()` — no longer inherit Halo's settings-derived collector config.
30
+
29
31
  **External-SDK detection.** If an external SDK is already registered (e.g. via `node --require` of a vendor distro) — detected by comparing `ProxyTracerProvider.getDelegate()` against a fresh proxy's `NoopTracerProvider` delegate — halo logs `[Observability] external OpenTelemetry SDK already registered — using it, skipping built-in exporters` and skips its own exporters. The `??=` env mapping above still applies first, so the external SDK picks up halo's settings unless it already set its own.
30
32
 
31
33
  **TLS / HTTPS.** `https://` endpoints work out of the box against CA-signed certs — the exporter picks `node:http` vs `node:https` by URL scheme. Self-signed certs / mTLS go through the standard exporter env vars `OTEL_EXPORTER_OTLP_CERTIFICATE` / `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` / `OTEL_EXPORTER_OTLP_CLIENT_KEY` (read by the exporter itself, not by halo code). Verified: a self-signed sink is rejected with `DEPTH_ZERO_SELF_SIGNED_CERT` unless the cert is trusted — no silent bypass.
@@ -30,6 +30,9 @@ Defines the persisted-data format for every Halo surface. Format changes must re
30
30
  │ ├── runs.db # Run ledger: (workspace, session_id) rows for server-driven runs in flight; steady state empty (running_sessions)
31
31
  │ ├── a2a.db # A2A: inbound tasks, push configs + outbox, outbound dispatches (see design/a2a.md)
32
32
  │ ├── extensions/<id>/ # Installed Canvas preview extensions (halo-extension.json + bundle)
33
+ │ ├── stt-models/ # On-device STT model files, downloaded on demand (see design/stt-local.md)
34
+ │ │ ├── sense-voice-zh-en-ja-ko-yue-2024-07-17/ # model.int8.onnx + tokens.txt (in-progress downloads: <file>.part)
35
+ │ │ └── silero-vad-v4/silero_vad.onnx
33
36
  │ ├── logs/ # Runtime logs (server.log)
34
37
  │ │ ├── evo/ # Per-evo-run wrapper logs
35
38
  │ │ └── cron/ # Per-cron-run cli stdout/stderr (30-day retention)
@@ -39,9 +42,12 @@ Defines the persisted-data format for every Halo surface. Format changes must re
39
42
  │ ├── settings.yaml # Global settings (API keys, credentials)
40
43
  │ ├── config.yaml # System config (admin password, server settings)
41
44
  │ ├── a2a-remotes.yaml # A2A remote list (server-wide, full-only; see design/a2a.md)
45
+ │ ├── server.env # Env snapshot for the systemd user unit (600), rewritten by `halo server start -d` / `restart`
42
46
  │ └── channels/
43
47
  │ └── channels.db # Channel accounts DB
44
48
 
49
+ ~/.config/systemd/user/halo.service # Outside ~/.halo: the user unit `halo server start -d` writes on Linux with systemd
50
+
45
51
  <workspace>/.halo/ # Workspace-level (overrides same-name global configs)
46
52
  ├── USER.md # Optional, overrides global
47
53
  ├── INSTRUCTIONS.md # Project-level instructions (overrides global INSTRUCTIONS.md)
@@ -133,7 +139,7 @@ Path: `.halo/sessions/{agentId}/{sessionId}.json`
133
139
  }
134
140
  ```
135
141
 
136
- Written as **compact JSON** (no indent) by all five writers of this path — nothing reads it by eye. `messageCount` is `messages.length` and has a single writer (`saveSessionToFile`); it *shrinks* when a compact archives history, so the lifetime count for listings is `exchangeCount` (mirrored to sqlite, see [agent_sessions](#agent_sessions)). See [session.md](session.md#session-file-format).
142
+ Written as **compact JSON** (no indent) by all five writers of this path — nothing reads it by eye. `messageCount` is `messages.length` and has a single writer (`saveSessionToFile`); it *shrinks* when a compact archives history, so the lifetime count for listings is `exchangeCount` (mirrored to sqlite, see [`agent_sessions`](#tables)). See [session.md](session.md#session-file-format).
137
143
 
138
144
  ### Archive segment file (UI-log archiving)
139
145
 
@@ -413,16 +419,16 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
413
419
  | `general.logging.level` | warn | `debug` / `info` / `warn` / `error`. Restart required |
414
420
  | `general.a2a.public_url` | `''` | Global-only. Origin peers use to reach this server for A2A (no trailing slash): the agent card's interface URL and the default push-webhook base for `a2a_send`. Empty = derived from the request host (no outbound dispatch without it or a remote's `push_base`). Env `HALO_A2A_PUBLIC_URL` overrides. See [a2a.md](a2a.md) |
415
421
  | `general.a2a.url_allowlist` | `100.64.0.0/10,*.ts.net` | Global-only. CIDRs / host patterns A2A egress may reach besides public https (push webhooks, remote cards and RPC, inbound image URLs). Private / loopback only when listed; plain http only to listed hosts / ranges; link-local always refused. See [a2a.md](a2a.md#push-delivery-and-url-policy) |
416
- | `general.observability.endpoint` | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`); empty = off. Restart required. See [observability.md](observability.md) |
417
- | `general.observability.service_name` | `halo` | OTel resource `service.name`. Restart required |
418
- | `general.observability.headers` | `''` | **Secret.** Extra OTLP request headers, comma-separated `k=v`. Restart required |
419
- | `general.observability.capture_content` | `false` | Put prompt/completion/tool text on spans. Restart required |
422
+ | `general.observability.endpoint` | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`); empty = off. Restart required (server); cron / evolution runs use a new value on their next run. See [observability.md](observability.md) |
423
+ | `general.observability.service_name` | `halo` | OTel resource `service.name`. Restart required (server); cron / evolution: next run |
424
+ | `general.observability.headers` | `''` | **Secret.** Extra OTLP request headers, comma-separated `k=v`. Restart required (server); cron / evolution: next run |
425
+ | `general.observability.capture_content` | `false` | Put prompt/completion/tool text on spans. Restart required (server); cron / evolution: next run |
420
426
 
421
427
  Schema source: [packages/server/src/settings-schema.ts](../../../packages/server/src/settings-schema.ts) `generalSection()`.
422
428
 
423
429
  ### Caching
424
430
 
425
- `config.ts` reads settings.yaml lazily with mtime-watching: every read stats the file and reparses if mtime changed. UI saves bump mtime, so the server picks up new values on the next read **without a restart** — except the keys marked "Restart required" above plus `general.agent.max_retries`, which `config.ts` reads once into object literals at boot (observability: `initObservability()`); their schema fields carry `restartRequired: true` and the Settings page shows a restart notice after saving one.
431
+ `config.ts` reads settings.yaml lazily with mtime-watching: every read stats the file and reparses if mtime changed. UI saves bump mtime, so the server picks up new values on the next read **without a restart** — except the keys marked "Restart required" above plus `general.agent.max_retries`, which `config.ts` reads once into object literals at boot (observability: `initObservability()`, mapped onto `OTEL_*` env; the cron / evolution `halo cli` children don't inherit those settings-derived vars — `cleanChildEnv()` strips them — so each re-reads settings at its own start); their schema fields carry `restartRequired: true` and the Settings page shows a restart notice after saving one.
426
432
 
427
433
  ### Orphans
428
434
 
@@ -666,7 +672,7 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
666
672
  - `schema.sql` / each db's `CREATE_SQL` always describes the FULL current shape, so a fresh db is complete after the CREATEs alone
667
673
  - Every change to an already-existing db gets a numbered slot in that file's ordered migration list — `HALO_MIGRATIONS` (`db/index.ts`), `CRON_MIGRATIONS` (`db/cron-db.ts`), `A2A_MIGRATIONS` (`db/a2a-db.ts`); `channel-db.ts` / `evo-db.ts` / `runs-db.ts` currently pass an empty list. `runMigrations(sqlite, list)` (`db/migrate.ts`) runs `list[user_version..]` in order, each in its own transaction, stamping `PRAGMA user_version = i + 1` after each
668
674
  - A fresh db is also at `user_version 0` and runs the whole list, so every slot must be a no-op against the current shape (`addColumnIfMissing`, `CREATE … IF NOT EXISTS`). A CHECK constraint can't be altered in SQLite: the slot rebuilds the table (create `_v2`, copy rows, drop, rename) and skips when `sqlite_master` already shows the new constraint — `HALO_MIGRATIONS` v2 (`disabled_items.scope` admits `'extension'`) is the pattern
669
- - Current slots worth knowing: `HALO_MIGRATIONS` v2 — the `disabled_items` rebuild above (rows kept). `CRON_MIGRATIONS` v5 — `cron_jobs.working_dir TEXT NULL` (job folder → `halo cli --dir`), `cron_jobs.stateless INTEGER NOT NULL DEFAULT 0` (1 = fresh session every run), `cron_runs.session_id TEXT NULL` (the session that run used); column semantics in [cron.md](cron.md)
675
+ - Current slots worth knowing: `HALO_MIGRATIONS` v2 — the `disabled_items` rebuild above (rows kept). `CRON_MIGRATIONS` v5 — `cron_jobs.working_dir TEXT NULL` (job folder → `halo cli --dir`), `cron_jobs.stateless INTEGER NOT NULL DEFAULT 0` (1 = fresh session every run), `cron_runs.session_id TEXT NULL` (the session that run used). `CRON_MIGRATIONS` v6 — `cron_runs.workspace_path TEXT NULL` (the workspace the run fired against, where a stateless run's session lives), backfilled from the job row (`UPDATE … SET workspace_path = (SELECT workspace_path FROM cron_jobs …) WHERE workspace_path IS NULL`), so rows whose job is already gone stay NULL; column semantics in [cron.md](cron.md)
670
676
  - Append only — never reorder or edit a shipped slot. A db opened by a newer halo (user_version > list length) logs a warning and continues
671
677
  - Do not drop or rename existing columns
672
678
  - New channel types: add rows with a new `channel_type` value + define config shape in the channel's `accounts.ts` adapter
@@ -685,3 +691,4 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
685
691
  | v1+ | 2026-09-18 | Relay + liveness (additive, no version bump): `agent_sessions.reply_to` column (idempotent ALTER); session file gains `lastActivityAt` beside `output`, read back by `get_session_output` for released sessions. |
686
692
  | v1+ | 2026-09-20 | Schema migrations moved from ad-hoc boot-time ALTERs to `PRAGMA user_version` + ordered per-db migration lists (`db/migrate.ts`); existing dbs pick up at slot 0 and every slot is idempotent. Session-message + WS frame types moved to `@turmind/halo-core/protocol` (re-exported from their old server paths; no wire change). |
687
693
  | v1+ | 2026-10-10 | Model registry (additive): per-model `endpoints:`, `maxContextWindow`, `compressAt`; recommended `contextWindow` ≤ 272000; registry context / compressAt / verbosity defaults are scaffold-only; installer validates model fields and asks consent for URLs new to a provider. `HALO_MIGRATIONS` v2: `disabled_items.scope` admits `'extension'`. `CRON_MIGRATIONS` v5: `cron_jobs.working_dir`, `cron_jobs.stateless`, `cron_runs.session_id`. |
694
+ | v1+ | 2026-10-13 | `CRON_MIGRATIONS` v6 (additive): `cron_runs.workspace_path`, backfilled from the job row (NULL where the job is already gone). |
@@ -0,0 +1,106 @@
1
+ # On-device speech-to-text (`stt-local`)
2
+
3
+ A canvas extension that declares capability `stt-local` can stream microphone audio to the Halo server and get back text. Everything runs on the server: SenseVoice-Small (int8) decodes, Silero VAD cuts the audio into segments, and `sherpa-onnx-node` hosts both models. There is no cloud call and no credential to configure. Model files are downloaded the first time they're needed. Not mounted in AgentCore mode, since that mode runs neither the extension watcher nor any WS surface.
4
+
5
+ Code: `packages/server/src/stt/models.ts` (model files + downloader), `stt/engine.ts` (recognizer, VAD, decode queue), `routes/stt-ws.ts` (WS route), `extensions/watcher.ts` (`reconcileSttModels`), mounted in `index.ts` next to the Transcribe proxy.
6
+
7
+ ## Models and licenses
8
+
9
+ | File (under `~/.halo/global/stt-models/`) | Bytes | Model | License |
10
+ |---|---|---|---|
11
+ | `sense-voice-zh-en-ja-ko-yue-2024-07-17/model.int8.onnx` | 239233841 | SenseVoice-Small int8 (FunAudioLLM / Alibaba), sherpa-onnx export | [FunASR Model License v1.1](https://github.com/modelscope/FunASR/blob/main/MODEL_LICENSE) |
12
+ | `sense-voice-zh-en-ja-ko-yue-2024-07-17/tokens.txt` | 315894 | its token table | same |
13
+ | `silero-vad-v4/silero_vad.onnx` | 1807522 | Silero VAD v4 | MIT |
14
+
15
+ - **FunASR Model License v1.1** allows free use, copying, modification and sharing, provided you credit the source and the authors and keep the model's name (§2.2). The attribution goes on the **halo-hub extension's description page**. The hub ships no weights: the server pulls them from the sources below, the extension never does.
16
+ - The VAD is the v4 export (1.8 MB). The 643 KB export the first evaluation used exists only on GitHub releases, not on HF or ModelScope. On sherpa's `test_silero_vad.wav`, the two cut identical segments.
17
+ - Language is `auto`. SenseVoice identifies Mandarin, Cantonese, English, Japanese and Korean by itself. Setting a language on the recognizer or the stream barely changed results in testing, so there is no `lang` parameter. Output is whatever the model writes (Simplified for Mandarin); there's no Traditional conversion.
18
+
19
+ ## Download
20
+
21
+ Each file has its sha256 and size pinned in `STT_MODEL_FILES` (`models.ts`), and response headers are never trusted. Each file tries its sources in order, falling back file by file:
22
+
23
+ 1. **Hugging Face**, pinned to a commit: `csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17@2365baea…` and `csukuangfj/vad@fba88cd2…`.
24
+ 2. **ModelScope** `https://www.modelscope.cn/api/v1/models/<repo>/repo?Revision=master&FilePath=<file>` (`pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue`, `pengzhendong/silero-vad` `v4/silero_vad.onnx`). Only this API form 302s to a CDN that honours Range. `resolve/master` returns a full 200 to a Range request.
25
+
26
+ Mechanics:
27
+
28
+ - Writes `<file>.part` and resumes it with `Range: bytes=<have>-`. A **206** whose `Content-Range` starts at `<have>` appends. A **200** rewrites from zero. A **416** deletes the `.part` and moves to the next source. The sha256 is computed while writing (on a resume, the existing prefix is hashed first). Only a `.part` that matches **both** size and sha gets renamed into place.
29
+ - A sha / oversize mismatch deletes the `.part` and moves on. An HTTP error, a short body or a timeout **keeps** it, and the next source resumes it: the bytes are pinned by sha, not by source. A `.part` that is already full-size (process killed between verify and rename) is re-hashed and renamed without any request.
30
+ - Timeouts: **15 s** from request to first body byte (redirects included), then **30 s** without a new chunk counts as a stall. Either one moves to the next source.
31
+ - Transport is `node:http(s)` with ≤ 5 redirects, keeping headers (Range) on every hop. It is not `fetch`, because Node 22's bundled undici (6.28) trips an internal `assert(!this.paused)` when a `Connection: close` body ends while the parser is paused under backpressure. That surfaces as an **uncaught exception**, which kills the server (reproduced locally with a 64–100 KB body). A `User-Agent` is mandatory: ModelScope's CDN returns 403 without one (`node:http` sends none by default).
32
+ - **Single flight**: one `ensure()` promise. Concurrent triggers join it.
33
+ - **Shutdown** (`gracefulShutdown` → `stt.close()`) aborts the running request and keeps the `.part`. The next boot resumes it (logged as `resuming at N bytes`).
34
+
35
+ Measured on the dev box (temp HOME): HF, all three files, 3.5 s total (228 MB model in 2.5 s). ModelScope-only (HF forced to fail): tokens + VAD in 2.4 s. Model aborted at 43 MB, then resumed from ModelScope in 10.9 s. SIGTERM mid-download then reboot resumed from HF in 2.3 s. Every sha matched.
36
+
37
+ ### State machine
38
+
39
+ ```
40
+ missing ──ensure()──▶ downloading{received,total} ──all files ok──▶ ready
41
+ ▲ │
42
+ │ └─ some file: every source failed ─▶ error{message}
43
+ └──────── next ensure() (WS connect / extensions rescan) retries ◀─┘
44
+ ```
45
+
46
+ - `ready` means every final file exists **with its pinned size**. Boot doesn't re-hash: only verified files were ever renamed in.
47
+ - `received` / `total` count bytes across all files (already-present files included), so the progress bar covers the whole set.
48
+ - Pushes go to subscribers: state changes immediately, progress at most every 500 ms (≤ 2/s).
49
+
50
+ ### Triggers
51
+
52
+ - **WS connect** (`routes/stt-ws.ts`): if the model isn't ready, `ensure()` runs, and that socket then receives `model` frames until `ready` or `error`.
53
+ - **Extensions reconcile** (`extensions/watcher.ts` → `reconcileSttModels`): after `start()`'s first scan and after every `rescanAndBroadcast()` scan (install / uninstall / watcher event). If any valid extension declares `stt-local` and the state is neither `ready` nor `downloading`, it calls `ensure()`. This makes installing the extension the moment the ~240 MB fetch starts, and it retries an `error` on the next rescan. The watcher doesn't run in AgentCore mode.
54
+
55
+ ## Engine
56
+
57
+ - `sherpa-onnx-node` is `await import()`ed on first `load()`. The CJS package's ESM namespace only has `OnlineRecognizer` + `default`, so `OfflineRecognizer` / `Vad` come off `default`. A failure (unsupported platform, missing native package) is a `SttError('engine')` on that stream and is retried on the next stream. It can never fail server boot.
58
+ - **One recognizer**, created on first use: `numThreads = clamp(floor(os.availableParallelism() / 2), 1, 4)`, `provider: 'cpu'`, `language: ''`, `useInverseTextNormalization: 1`. Load takes ~0.9 s. **It stays resident, about 400 MB RSS** (temp server: 472 MB total after a session). There is no idle release: dropping the recognizer + `global.gc()` for 20 s gave back only 13–119 MB of the ~325 MB it added (varied run to run), and reloading climbs straight back to ~400 MB. sherpa has no explicit free, and the onnxruntime / glibc arenas don't return to the OS.
59
+ - **Global decode queue**: FIFO, concurrency 1 server-wide, via `decodeAsync` (libuv pool; the event loop keeps running). 10 s of speech decodes in ~0.2–0.3 s.
60
+ - **Caps → `busy`**: more than **32** segments waiting server-wide, or more than **60 s** of one stream's audio queued or decoding. The stream fails, and its own queued segments are dropped.
61
+ - Results: `lang` `<|zh|>` → `zh` (`zh` / `en` / `yue` / `ja` / `ko`). A segment whose text is empty after trimming is dropped (no frame), but still counts in the segment log.
62
+
63
+ ### VAD (one per stream)
64
+
65
+ Silero v4, 512-sample windows at 16 kHz, internal buffer 30 s. Constants in `engine.ts`:
66
+
67
+ | Constant | Value | Why |
68
+ |---|---|---|
69
+ | `VAD_THRESHOLD` | 0.5 | 0.3 split Cantonese into fragments; the English head loss 0.5 caused is fixed by padding instead |
70
+ | `VAD_MIN_SILENCE_S` | 0.5 | Silence that ends a segment. It is also the final's latency after speech stops. Shorter values cut mid-sentence pauses into separate, worse decodes |
71
+ | `VAD_MIN_SPEECH_S` | 0.25 | Shorter blips (clicks, breaths) aren't speech |
72
+ | `VAD_MAX_SPEECH_S` | 20 | Hard cut for a monologue. SenseVoice is non-streaming, so nothing comes back until a segment ends. This bounds latency and per-segment memory |
73
+ | `PAD_BEFORE_S` | 0.3 | Raw audio prepended. Silero fires a few windows into the first syllable, and at 0.5 it swallowed English heads ("Fous to foreigners…") |
74
+ | `PAD_AFTER_S` | 0.2 | Soft endings fall under the threshold early. 0.3 + 0.2 = min-silence, so at a normal pause padding doesn't reach into the neighbour |
75
+
76
+ Each stream keeps a 30 s ring of raw samples (it must reach back past a 20 s segment plus pad when that segment is emitted), and each segment is cut from the ring. The pre-pad is clamped to the previous segment's padded end, so no audio is decoded twice. The final's `start` / `end` are the **padded** bounds: exactly the audio that was decoded.
77
+
78
+ **Concatenation test** (zh / en / yue + ja / ko from the SenseVoice HF repo's `test_wavs/`, joined with 1.2 s of silence, streamed through `engine.ts` in 100 ms s16le frames, each final compared with a whole-file decode of the same clip): 5/5 clips produced one segment each, with the right language. **No head or tail word was dropped** in any language: after stripping punctuation and spaces, each streamed text has exactly as many characters as its whole-file decode (zh 13/13, en 66/66, yue 15/15, ja 34/34, ko 17/17). Exact string match was 1/5 (yue). The other four are substitutions, not losses: zh 开饭→开放 (one homophone, at the head), en "code"→"gold" (at the tail; the clip actually says *gold*), ja an extra `、`, ko word-spaced Hangul (identical letters). Isolating which differences come from padding: decoding the unpadded VAD segment alone already gives the ja / ko variants, while zh 开放 and en "gold" appear only with the pre-pad. Both are single-token flips, not dropped audio. For context, a sweep over pre/post pads of 0–0.5 s never got more than 2/5 exact. On sherpa's 60 s English `test_silero_vad.wav`, 8 of 10 padded segments decode identically to the unpadded ones. One gains a comma. One changes both ways: "Capital in thet century argues for a 4%" becomes "Capital in the 21st century argue for a a 4%".
79
+
80
+ ## WS `/api/stt/stream?ext=<id>`
81
+
82
+ `noServer` ws server behind `ws/upgrade-router.ts`, same structure as the Transcribe proxy (`routes/transcribe-ws.ts`).
83
+
84
+ - **Handshake**: login cookie (`isAuthenticated(getTokenFromCookieHeader(cookie))`), else **401**. `ext` must be installed and declare `stt-local`, else **403**. Frames are capped at 1 MiB (`maxPayload`). Every socket has an `error` listener, so a protocol violation (oversize → 1009, bad UTF-8 → 1007) closes only that socket.
85
+ - **Server → client** (text JSON):
86
+ - `{"type":"model","state":"missing"|"downloading"|"ready"|"error","received"?,"total"?,"message"?}`: always the **first** frame. While the model isn't ready, more `model` frames follow (progress ≤ 2/s, plus every state change). `received` / `total` are present only for `downloading`, `message` only for `error`.
87
+ - `{"type":"ready"}`: model ready **and** engine loaded. Audio is accepted from here on.
88
+ - `{"type":"final","start":<s>,"end":<s>,"text":"…","lang":"zh"|"en"|"yue"|"ja"|"ko"}`: one per non-empty segment. `start` / `end` are seconds (3 decimals) from this connection's first accepted audio sample.
89
+ - `{"type":"error","code":"model"|"engine"|"busy"|"bad-request"|"io","message":"…"}`, then close **1011**. `model` = download failed (the reason is also in the preceding `model` frame's `message`), `engine` = native load / VAD / decode failure, `busy` = queue caps above, `bad-request` = a text frame other than `{"type":"end"}`, `io` = anything else.
90
+ - **Client → server**:
91
+ - binary = PCM s16le mono 16 kHz, any frame length (an odd trailing byte carries over). **Binary sent before `ready` is dropped**, because there's no stream yet to feed. A client should start sending only after `ready` (`start` / `end` count from the first audio sent after it).
92
+ - text `{"type":"end"}`: flush the VAD, wait for every remaining segment of this connection to be decoded and its `final` sent, then close **1000**. Sent before `ready`, it closes 1000 at once (nothing was accepted).
93
+ - **Disconnect**: this connection's segments that haven't started decoding are removed from the global queue. A segment already decoding finishes, and its result is discarded.
94
+ - **Shutdown**: open sockets close 1001 and the download is aborted, keeping its `.part`.
95
+
96
+ ## Logs
97
+
98
+ `[STT] …` only, with counts and durations: `stream open ext=…`, `stream closed after Ns ext=… segments=N audio=Ns`, `stream error ext=… code=…`, `engine loaded in Nms (threads=N)` / `engine load failed: …`, `model download start`, `downloaded <file> from <source> (N MB in Ns)`, `download <file> from <source> failed: <reason>`, `<file>: resuming at N bytes`. **Recognized text and audio are never logged.** The server's default level is `warn`, so these `console.log` lines show up at `general.logging.level: info` (or `HALO_LOG_LEVEL=info`).
99
+
100
+ ## Known limits
101
+
102
+ - **Proxies**: neither `fetch` nor `node:http(s)` reads `HTTPS_PROXY` / `HTTP_PROXY` on Node 22 by default (verified: with an unreachable `HTTPS_PROXY`, both connected directly). `NODE_USE_ENV_PROXY=1` (experimental in Node 22, prints `UNDICI-EHPA` warnings) makes both honour it. A server behind a mandatory egress proxy can't download the models without it. The workaround is to drop the three files into `~/.halo/global/stt-models/` by hand with the exact names and sizes above.
103
+ - ~400 MB resident once any stream has loaded the engine (no idle release, see above). The decode burst uses up to 4 cores.
104
+ - One decode at a time: with many concurrent speakers, latency grows linearly until `busy`.
105
+ - No partial results (SenseVoice is non-streaming): a final arrives ~0.5 s after the speaker pauses, at most 20 s after a segment starts.
106
+ - Desktop (win-x64 / darwin-arm64): `stage-runtime.mjs` `installSherpaBinary()` ships the target's `sherpa-onnx-<platform>-<arch>` package (see [dev/desktop-packaging.md](../dev/desktop-packaging.md)). A build without it fails streams with `engine`, never server boot. The macOS addon declares minimum 14.0; older macOS is unverified.
@@ -62,7 +62,7 @@ File: `packages/server/src/routes/web.ts`
62
62
 
63
63
  All public endpoints require `x-token` header (or `?token=` query param) with a valid account token. No cookie auth needed.
64
64
 
65
- > These paths are listed in `PUBLIC_PATHS` (`middleware/auth.ts`) so they bypass the admin cookie gate: `/api/web/chat`, `/api/web/sessions` (POST mint + GET list), `/api/web/stop`, `/api/web/history`, `/api/web/subscribe`, `/api/web/file`, and `/api/show/state` (the [halo-city](../../../halo-city/) snapshot — see [dev/api.md](../dev/api.md#show-world-snapshot)). The server CORS allowlist includes the `x-token` header, so browser-based custom frontends (e.g. halo-city) can call these cross-origin.
65
+ > These paths are listed in `PUBLIC_PATHS` (`middleware/auth.ts`) so they bypass the admin cookie gate: `/api/web/chat`, `/api/web/sessions` (POST mint + GET list), `/api/web/stop`, `/api/web/history`, `/api/web/subscribe`, `/api/web/file`, and `/api/show/state` (the [halo-city](https://github.com/turmind/halo-hub/tree/main/extensions/halo-city) snapshot — see [dev/api.md](../dev/api.md#show-world-snapshot)). The server CORS allowlist includes the `x-token` header, so browser-based custom frontends (e.g. halo-city) can call these cross-origin.
66
66
 
67
67
  ### POST `/api/web/chat`
68
68
 
@@ -96,7 +96,7 @@ loop {
96
96
  }
97
97
  ```
98
98
 
99
- > **单实例依赖**:长轮询循环跟 HTTP server 解耦 —— 即便 `:9527` 没绑上,只要进程活着,`runAccountLoop` 就继续拉消息。如果有孤儿 server 进程残留,每个进程都会独立收到同一条消息并 fan out 到不同 session(每个进程内存里的 `activeOverrides` 是独立的,`findActiveSessionId` 可能返回不同结果)。因此 `src/index.ts` 在启动时要抢 `~/.halo/global/server.pid` 单实例锁,详见 [dev/deploy.md](../dev/deploy.md#4-start-the-server)。
99
+ > **单实例依赖**:长轮询循环跟 HTTP server 解耦 —— 即便 `:9527` 没绑上,只要进程活着,`runAccountLoop` 就继续拉消息。如果有孤儿 server 进程残留,每个进程都会独立收到同一条消息并 fan out 到不同 session(每个进程内存里的 `activeOverrides` 是独立的,`findActiveSessionId` 可能返回不同结果)。因此 `src/index.ts` 在启动时要抢 `~/.halo/global/server.pid` 单实例锁,详见 [dev/deploy.md](../dev/deploy.md#5-start-the-server)。
100
100
 
101
101
  `handleInbound(account, msg)` — WeChat-specific head, then the shared tail:
102
102
  1. Extract `from_user_id` / text + media items / `context_token`
@@ -18,7 +18,7 @@ Total effort: typically a few hours depending on how weird the upstream API is a
18
18
 
19
19
  ### Existing providers
20
20
 
21
- Runtime names (`runtime:` value → class): `bedrock-invoke` → `BedrockAgent`, `bedrock-mantle` → `MantleAgent` (OpenAI Responses API), `anthropic-messages` → `AnthropicAgent`, `openai-chat` → `OpenAIAgent` (Chat Completions); the vendor subclasses keep their vendor name (`kimi`, `deepseek`, `minimax`, `qwen`, `hunyuan`, `doubao`, `zhipu`). No implementation class reads the provider id — the endpoint (required in `agent.yaml`) carries everything host-specific.
21
+ Runtime names (`runtime:` value → class): `bedrock-invoke` → `BedrockAgent`, `bedrock-mantle` → `MantleAgent` (OpenAI Responses API), `anthropic-messages` → `AnthropicAgent`, `openai-chat` → `OpenAIAgent` (Chat Completions); the vendor subclasses keep their vendor name (`kimi`, `deepseek`, `minimax`, `qwen`, `hunyuan`, `doubao`, `zhipu`, `gemini`). No implementation class reads the provider id — the endpoint (required in `agent.yaml`) carries everything host-specific.
22
22
 
23
23
  | Provider ID | `runtime` | Class | File | Notes |
24
24
  |---|---|---|---|---|
@@ -30,6 +30,7 @@ Runtime names (`runtime:` value → class): `bedrock-invoke` → `BedrockAgent`,
30
30
  | `hunyuan` | `hunyuan` | `HunyuanAgent` | `hunyuan-agent.ts` | Tencent Hy3 / Hy4 preview / Hy3 preview via OpenAI-compatible (tokenhub.tencentmaas.com), streaming via `fetchChatCompletionStream` (`stream_options.include_usage` accepted; usage in its own `choices: []` chunk), reasoning_effort low/high, thinking off sends `reasoning_effort: 'none'` (an omitted field thinks by default — live-probed 2026-10-10), automatic prompt caching (cached_tokens), no vision |
31
31
  | `doubao` | `doubao` | `DoubaoAgent` | `doubao-agent.ts` | ByteDance Doubao Seed 2.1 Pro + Seed-2.0 (pro/lite/mini/code) on Volcengine Ark, OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage in its own `choices: []` chunk) + `thinking:{type:'enabled'/'disabled'}` (no `auto`), automatic prompt caching. Images go out as `image_url` content parts; only 2.1 Pro is `image: true` (the 2.0 models misread a red PNG), so the registry flag gates the rest |
32
32
  | `zhipu` | `zhipu` | `ZhipuAgent` | `zhipu-agent.ts` | Zhipu GLM-5.x on open.bigmodel.cn, OpenAI-compatible, streaming via `fetchChatCompletionStream` (tool-call arguments arrive complete in one fragment; usage chunk carries `choices`). Output cap via `max_tokens` only (`max_completion_tokens` is silently ignored). Thinking branched on modelId: **glm-5.3\*** always thinks — always sends `thinking:{type:'enabled',clear_thinking:false}`, `reasoning_effort` only when low/high/max (`disabled` / other efforts 400); other ids (glm-5.2) honour `thinking:{type:'disabled'}`. Vision per model: `glm-5.3-flash` / `glm-5.3-flashx` see `image_url` data URLs; `glm-5.3` / `glm-5.2` are text-only and 400 on any image part (code 1210, classified `multimodal_4xx` so leftover history images degrade). Automatic caching, `prompt_tokens_details.cached_tokens` (included in `prompt_tokens`, subtracted). |
33
+ | `gemini` | `gemini` | `GeminiAgent` | `gemini-agent.ts` | Google Gemini 3.x on the Gemini API's OpenAI-compatible surface (`generativelanguage.googleapis.com/v1beta/openai`, Bearer key), streaming via `fetchChatCompletionStream`. **Thought signatures**: the first tool call of a reply carries `extra_content.google.thought_signature`, and a follow-up that replays a current-turn call without it 400s. The runtime takes the signatures from the raw frames (the `onChunk` hook) and re-attaches them by tool-call id. A call it never saw (rebuilt instance) gets Google's placeholder `skip_thought_signature_validator`. Tool-call replies end with `finish_reason: "stop"`, so they're folded as `tool_calls`. Thinking can't be turned off on Gemini 3. The level goes as `extra_body.google.thinking_config.thinking_level` with `include_thoughts` (this 400s next to `reasoning_effort`). The summary streams inline as `<thought>…</thought>` at the start of `content` and is split out into thinking. Levels per model: 3.8-flash low / medium / high, 3.5-flash-lite minimal…high, xhigh / max invalid everywhere. Usage: `completion_tokens` excludes thought tokens, so output = `total_tokens − prompt_tokens`. Implicit caching via `prompt_tokens_details.cached_tokens` (included in `prompt_tokens`, subtracted). Vision via `image_url` data URLs (probed 2026-10-12) |
33
34
  | `openai` | `openai-chat` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp). Ships a short Bedrock Mantle list callable with a Bedrock API key (`openai.gpt-5.5` default, `openai.gpt-5.4`; `defaultEndpoint` Mantle us-east-1 `/openai/v1`, presets us-east-1 / us-west-2 / api.openai.com — GPT-6.x / 5.6 reject `max_tokens`, so they stay on the Responses-API providers) plus the legacy `gpt-4o` placeholder (official API only); any other endpoint + model id can be typed, and capabilities for unlisted ids are openly editable in the form. Streaming via `fetchChatCompletionStream` — always sends `stream: true` + `stream_options: { include_usage: true }` (OpenAI's own field; Ollama ≥ 0.3 / vLLM / LM Studio accept it too). A gateway that rejects unknown fields would 400 — accepted trade-off, no non-streaming fallback. Forgiving about cache field names (`cachedPromptTokens`: first value > 0 of `prompt_tokens_details.cached_tokens`, top-level `cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens`) and reasoning field names (`reasoning_content` for OpenAI o-series/DeepSeek, `reasoning` for Ollama/llama.cpp). |
34
35
  | `anthropic` | `anthropic-messages` | `AnthropicAgent` | `anthropic-agent.ts` | **Generic Anthropic Messages**. Use for api.anthropic.com or any Anthropic-compatible gateway. Ships a short Bedrock Mantle list callable with a Bedrock API key as `x-api-key` (`anthropic.claude-opus-5-5` default, `anthropic.claude-haiku-4-5`; `defaultEndpoint` Mantle us-east-1 `/anthropic`, presets us-east-1 / us-west-2 / api.anthropic.com) plus the legacy `claude-sonnet-4-6` placeholder (official API only); any other endpoint + model id can be typed. Streaming via `fetchAnthropicStream` (the gateway must support `stream: true` SSE — the reference Anthropic behaviour). Standard `cache_control: ephemeral` with optional `ttl: '1h'`. |
35
36
  | `mimo-token-plan-china` | `anthropic-messages` | `AnthropicAgent` | (reuses `anthropic-agent.ts`) | Xiaomi MiMo (UI name "Xiaomi MiMo"; the id keeps its historical name so existing configs don't break) — Anthropic-compatible gateway, streams like `anthropic` (gateway built for Claude Code, which always streams; not live-probed); no dedicated class, just `runtime: anthropic-messages` in its yaml (`defaultEndpoint` `token-plan-cn.xiaomimimo.com/anthropic`). The key type picks the endpoint: Token Plan `tp-`/`ttp-` keys only work on `token-plan-{cn,sgp,ams}.xiaomimimo.com/anthropic`, pay-as-you-go `sk-` keys only on `api.xiaomimimo.com/anthropic` — all four ship as `endpointPresets` (pay-as-you-go live-probed 2026-09-24; sgp / ams from the docs only). V2.6 (`mimo-v2.6-pro` / `-flash`, both vision, 1M / 131072) is current; `mimo-v2.5(-pro)` kept in the template until the official 2026-10-21 deprecation, then drop |