@turmind/halo 1.5.3-alpha → 1.5.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.
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/{133-33aa091fba853037.js → 422-cc74b1fdd3af5415.js} +1 -1
- package/admin-out/_next/static/chunks/989-bc743317498d3fbc.js +1 -0
- package/admin-out/_next/static/chunks/app/page-0a390059f1914664.js +1 -0
- package/admin-out/_next/static/chunks/{webpack-1a264efd965db7ab.js → webpack-edf248e07333566d.js} +1 -1
- package/admin-out/_next/static/css/b53542687ebd4c65.css +3 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/agent.md +4 -4
- package/bundled-docs/design/agentcore.md +13 -10
- package/bundled-docs/design/architecture.md +24 -14
- package/bundled-docs/design/command.md +5 -5
- package/bundled-docs/design/cron.md +2 -2
- package/bundled-docs/design/evolution.md +34 -35
- package/bundled-docs/design/express-self.md +6 -4
- package/bundled-docs/design/feishu.md +18 -14
- package/bundled-docs/design/halo-city.md +5 -4
- package/bundled-docs/design/observability.md +9 -9
- package/bundled-docs/design/prompt-system.md +12 -12
- package/bundled-docs/design/session.md +12 -12
- package/bundled-docs/design/slack.md +22 -20
- package/bundled-docs/design/storage.md +18 -19
- package/bundled-docs/design/telegram.md +26 -8
- package/bundled-docs/design/web.md +3 -1
- package/bundled-docs/design/wechat.md +8 -6
- package/bundled-docs/design/wecom.md +23 -9
- package/bundled-docs/design/ws.md +6 -6
- package/bundled-docs/dev/acp-adapter.md +8 -8
- package/bundled-docs/dev/add-channel.md +12 -9
- package/bundled-docs/dev/add-model-provider.md +31 -20
- package/bundled-docs/dev/api.md +83 -57
- package/bundled-docs/dev/deploy.md +8 -6
- package/bundled-docs/dev/env.md +16 -11
- package/bundled-docs/dev/logging.md +15 -29
- package/bundled-docs/dev/previews.md +20 -11
- package/bundled-docs/dev/tools.md +24 -12
- package/bundled-docs/guide/README.md +1 -0
- package/bundled-docs/guide/agents.md +19 -53
- package/bundled-docs/guide/channels/README.md +7 -4
- package/bundled-docs/guide/channels/acp.md +3 -1
- package/bundled-docs/guide/channels/feishu.md +13 -11
- package/bundled-docs/guide/channels/slack.md +12 -10
- package/bundled-docs/guide/channels/telegram.md +21 -27
- package/bundled-docs/guide/channels/web.md +15 -11
- package/bundled-docs/guide/channels/wechat.md +15 -14
- package/bundled-docs/guide/channels/wecom.md +7 -2
- package/bundled-docs/guide/chat.md +11 -10
- package/bundled-docs/guide/cli.md +10 -8
- package/bundled-docs/guide/getting-started.md +3 -3
- package/bundled-docs/guide/secrets-and-credentials.md +3 -3
- package/bundled-docs/guide/sessions.md +3 -3
- package/bundled-docs/guide/skills.md +8 -6
- package/bundled-docs/guide/testing-agents-and-skills.md +14 -15
- package/bundled-docs/guide/workspace.md +27 -15
- package/bundled-docs/requirements/agent.md +19 -18
- package/bundled-docs/requirements/chat.md +19 -21
- package/bundled-docs/requirements/command.md +10 -10
- package/bundled-docs/requirements/editor.md +10 -9
- package/bundled-docs/requirements/explorer.md +10 -7
- package/bundled-docs/requirements/session.md +16 -17
- package/bundled-docs/requirements/settings.md +18 -14
- package/bundled-docs/requirements/skill.md +4 -4
- package/bundled-docs/requirements/source-control.md +4 -3
- package/bundled-docs/requirements/terminal.md +13 -13
- package/bundled-docs/requirements/workspace.md +19 -13
- package/dist/index.js +2038 -3558
- package/package.json +1 -1
- package/templates/config.yaml +6 -0
- package/templates/skills/workspace/share.md +11 -3
- package/templates/skills/workspace/stage.py +85 -5
- package/admin-out/_next/static/chunks/989-c6c7772db8a50926.js +0 -1
- package/admin-out/_next/static/chunks/app/page-5f41b086cd5ad353.js +0 -1
- package/admin-out/_next/static/css/8809d33379458514.css +0 -3
- /package/admin-out/_next/static/{Wqi_ugna9MYsiV7ECuawQ → cuVcldfdg1PXevaQMQSzb}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{Wqi_ugna9MYsiV7ECuawQ → cuVcldfdg1PXevaQMQSzb}/_ssgManifest.js +0 -0
|
@@ -17,14 +17,20 @@
|
|
|
17
17
|
│ Route mounts (direct): │
|
|
18
18
|
│ /api/auth/* → createAuthRoutes() │
|
|
19
19
|
│ /api/files/* → createFileRoutes() │
|
|
20
|
+
│ /api/data-preview/*→ createDataPreviewRoutes() │
|
|
21
|
+
│ /api/git/* → createGitRoutes() │
|
|
20
22
|
│ /api/fs/* → createFileRoutes (home/exists/browse/resolve)│
|
|
21
23
|
│ /api/agent-configs/*→ createAgentConfigRoutes() │
|
|
22
24
|
│ /api/skills/* → createSkillRoutes() │
|
|
23
25
|
│ /api/settings/* → createSettingsRoutes() │
|
|
24
26
|
│ /api/evolution/* → createEvolutionRoutes() │
|
|
25
27
|
│ /api/cron/* → createCronRoutes() │
|
|
28
|
+
│ /api/extensions/* → createExtensionRoutes() │
|
|
26
29
|
│ /api/sessions/* → createSessionRoutes() │
|
|
30
|
+
│ /api/sessions/logs/:id/archive/:n → createSessionArchiveRoutes() │
|
|
27
31
|
│ /api/show/* → createShowRoutes() (halo-city snapshot) │
|
|
32
|
+
│ /api/metrics → createMetricsRoutes() │
|
|
33
|
+
│ /api/health (inline in index.ts) │
|
|
28
34
|
│ /api/commands → createCommandRoutes() │
|
|
29
35
|
│ Route mounts (via bootChannels — registry-driven): │
|
|
30
36
|
│ /api/web/* (web descriptor) │
|
|
@@ -32,8 +38,10 @@
|
|
|
32
38
|
│ /api/wechat/* (wechat descriptor) │
|
|
33
39
|
│ /api/slack/* (slack descriptor) │
|
|
34
40
|
│ /api/feishu/* (feishu descriptor) │
|
|
41
|
+
│ /api/wecom/* (wecom descriptor) │
|
|
35
42
|
│ /ws → setupWebSocketHandler({wss,registry}) │
|
|
36
43
|
│ /* → static frontend (Next.js out/) │
|
|
44
|
+
│ /ping, /invocations → createAgentCoreRoutes() (agentcore mode) │
|
|
37
45
|
└─────────────────────────────────────────────────────────────────────┘
|
|
38
46
|
```
|
|
39
47
|
|
|
@@ -74,7 +82,7 @@ createModelRuntime(providerId: string, cfg: ModelRuntimeConfig): ModelRuntime
|
|
|
74
82
|
**Current providers**:
|
|
75
83
|
- `aws-bedrock-claude-invoke` → `BedrockAgent` (uses the Bedrock InvokeModelWithResponseStream API — text / thinking chunks reach the UI as they are generated)
|
|
76
84
|
- `anthropic` / `mimo-token-plan-china` → `AnthropicAgent`, `minimax` → `MiniMaxAgent`, `qwen` → `QwenAgent` (Anthropic Messages API over HTTP with `stream: true`; the SSE is folded by the same `AnthropicStreamAccumulator` as Bedrock via `fetchAnthropicStream` in `anthropic-stream.ts`, so they stream too)
|
|
77
|
-
- The OpenAI-family (`openai` / `deepseek` / `kimi` / `zhipu` / `doubao` / `hunyuan`) → `chat/completions` over HTTP with `stream: true` + `stream_options.include_usage`; the `chat.completion.chunk` SSE is folded back into the non-streaming `choices[0].message` shape by `fetchChatCompletionStream` in `openai-chat-stream.ts` (a transport helper, not a base class
|
|
85
|
+
- The OpenAI-family (`openai` / `deepseek` / `kimi` / `zhipu` / `doubao` / `hunyuan`) → `chat/completions` over HTTP with `stream: true` + `stream_options.include_usage`; the `chat.completion.chunk` SSE is folded back into the non-streaming `choices[0].message` shape by `fetchChatCompletionStream` in `openai-chat-stream.ts` (a transport helper, not a base class; message/tool conversion and result parsing are shared via `openai-chat-format.ts`, while each agent keeps its own request body / thinking / usage math), so they stream too
|
|
78
86
|
- Mantle (`aws-bedrock-mantle` / `aws-bedrock-openai`) → `MantleAgent`, OpenAI Responses API over HTTP with `stream: true`; `readResponsesStream` (module-level in `mantle-agent.ts`) reports `response.output_text.delta` frames live and takes the full final response object off the terminal `response.completed` / `response.incomplete` frame, so the existing `output[]` parse runs unchanged (bedrock-mantle sends no `[DONE]`, bedrock-runtime does — `readSseJson` skips it either way)
|
|
79
87
|
- Every provider streams; the `onDelta` hook is still optional per provider, so a future non-streaming one just ignores it
|
|
80
88
|
|
|
@@ -107,11 +115,11 @@ Message dispatch:
|
|
|
107
115
|
|
|
108
116
|
File: `ws/event-processor.ts` (pure functions).
|
|
109
117
|
|
|
110
|
-
`sendWsNotification(event, state, turnId, ctx)` — converts OrchestratorEvent into a WS JSON message and sends it. Called *after* `applyEvent` has already mutated UIState. Does NOT mutate state itself. `bufferDetachedNotification(event, pendingEvents)` is the offline equivalent that buffers structural events for replay.
|
|
118
|
+
`sendWsNotification(event, state, turnId, ctx)` — converts OrchestratorEvent into a WS JSON message and sends it. Called *after* `applyEvent` has already mutated UIState. Does NOT mutate state itself. `bufferDetachedNotification(event, pendingEvents, sessionId)` is the offline equivalent that buffers structural events for replay.
|
|
111
119
|
|
|
112
120
|
### Broadcast — cross-client server-pushed events
|
|
113
121
|
|
|
114
|
-
File: `ws/broadcast.ts`. Per-client `sendJson` is for chat-stream events that belong to one socket; `broadcast(event)` fans an event out to *every* connected admin client. Used for shared-state changes that any tab/browser cares about: evolution run state transitions, cron job/run state,
|
|
122
|
+
File: `ws/broadcast.ts`. Per-client `sendJson` is for chat-stream events that belong to one socket; `broadcast(event)` fans an event out to *every* connected admin client. Used for shared-state changes that any tab/browser cares about: evolution run state transitions, cron job/run state, session list changes, goal state, preview extensions. Replaces the `setInterval(fetch, ...)` polling pattern that earlier admin views used to detect server-side changes.
|
|
115
123
|
|
|
116
124
|
`setBroadcastWss(wss)` is called once at server boot from `index.ts`; modules then `import { broadcast }` without threading the wss handle through. Callers that emit:
|
|
117
125
|
|
|
@@ -119,6 +127,10 @@ File: `ws/broadcast.ts`. Per-client `sendJson` is for chat-stream events that be
|
|
|
119
127
|
- `routes/evolution.ts` — REST mutations (approve/reject/retry) emit immediately so user-action latency is "instant"
|
|
120
128
|
- `cron/runner.ts` — `runJob` insert + `finalize` emit `cron:run_changed`; `reconcileFromDb` emits `cron:job_changed kind=reconciled|deleted` for out-of-band db edits (e.g. the cron skill writing the db directly)
|
|
121
129
|
- `routes/cron.ts` — REST mutations emit `cron:job_changed kind=created|updated|deleted` immediately
|
|
130
|
+
- `agents/session-manager.ts` (root `createSession`), `agents/session-ui-store.ts` (root `complete`), `routes/sessions.ts` — `session:changed`
|
|
131
|
+
- `agents/goal-mode.ts` — `goal:changed`
|
|
132
|
+
- `extensions/watcher.ts` — `extension:changed` (full snapshot)
|
|
133
|
+
- `routes/git.ts` — `broadcastToWorkspace` a `.git` `file:changed` so every socket on that workspace refreshes
|
|
122
134
|
|
|
123
135
|
### UILogBuilder — UI state reducer
|
|
124
136
|
|
|
@@ -146,7 +158,7 @@ File: `agents/session-skill-commands.ts`. Stateless, pure reads. Resolves which
|
|
|
146
158
|
|
|
147
159
|
### SessionStateStore — rawMessages disk persistence
|
|
148
160
|
|
|
149
|
-
File: `agents/session-state-store.ts`. Stateless. Saves/loads an agent's `rawMessages` (LLM-facing history) to its `.json` file via read-merge-write — the `rawMessages` half of session persistence (SessionUIStore owns the UI-log half; both write the same file). `saveAgentState` takes a narrow `SavableSession` (
|
|
161
|
+
File: `agents/session-state-store.ts`. Stateless. Saves/loads an agent's `rawMessages` (LLM-facing history) to its `.json` file via read-merge-write — the `rawMessages` half of session persistence (SessionUIStore owns the UI-log half; both write the same file). `saveAgentState` takes a narrow `SavableSession` (8 fields), not the full AgentSession. Host surface: workspaceRoot + `isSessionDeleted` (the tombstone short-circuit so a late save can't resurrect a deleted file).
|
|
150
162
|
|
|
151
163
|
## Helper modules
|
|
152
164
|
|
|
@@ -163,7 +175,7 @@ File: `prompts/md-loader.ts`. See [design/prompt-system.md](prompt-system.md).
|
|
|
163
175
|
File: `tools/workspace-tools.ts`. 9 tools: file_read / view_image / file_write / file_edit / file_list / shell_exec / grep / glob / web_fetch. See [dev/tools.md](../dev/tools.md).
|
|
164
176
|
|
|
165
177
|
### Detached event buffer — post-disconnect replay
|
|
166
|
-
File: `ws/handler.ts` (`cleanupConnection`) + `bufferDetachedNotification` in `ws/event-processor.ts`. After WS disconnect, an inline handler takes over the session tree's events and buffers structural ones for replay on reconnect.
|
|
178
|
+
File: `ws/handler.ts` (`cleanupConnection`) + `bufferDetachedNotification` in `ws/event-processor.ts`. After WS disconnect, an inline handler takes over the session tree's events and buffers structural ones for replay on reconnect. `/session new` only opens a draft tab — the previous session stays subscribed — see [design/background-dispatch.md](background-dispatch.md).
|
|
167
179
|
|
|
168
180
|
### WorkspaceWatcher — file watching
|
|
169
181
|
File: `ws/file-watcher.ts`. `@parcel/watcher` recursive native subscription (one per workspace root, shared across connections via `ws/watcher-pool.ts`) → 300ms debounce + per-path Map dedup → callback. Ignores node_modules / .git / .next / dist etc. (`IGNORED_SEGMENTS`) — passed to parcel as the bare names (top-level `ignorePaths`, string compare) plus **one** `**/{a,b,…}/**` brace glob for nested occurrences. Not one glob per segment: parcel runs every ignore regex against every inode during the initial walk, and 52 separate regexes made subscribe on a ~26k-inode repo take ~13s (one brace regex: ~0.8s, same watch set). `.halo/sessions/` and `.halo/logs/` are **not** ignored — the dedup keeps volume low, and the front-end drops `change` events for files not open in the editor, so Explorer can reflect session deletions/creations while chat streaming stays cheap.
|
|
@@ -172,7 +184,7 @@ File: `ws/file-watcher.ts`. `@parcel/watcher` recursive native subscription (one
|
|
|
172
184
|
File: `ws/terminal-manager.ts`. Spawns a shell via node-pty. On disconnect, detaches with a 50KB ring buffer; replays on reconnect.
|
|
173
185
|
|
|
174
186
|
### Self-Evolution — workspace prompt-tuning loop
|
|
175
|
-
Files: `evolution/{ticker,evo-wrapper,enqueue,spawn,archive}.ts`, `db/evo-db.ts`, `routes/evolution.ts`. Internal agents `__evo_agent__` / `__score__` / `__apply_agent__` (in `templates/agents/`) drive a 12-phase orchestration: snapshot → evo drafts → wrapper dry-runs → scorer grades → reviewer approves → apply agent merges → wrapper history-snapshots + cps to main. Per-task wrapper Node child process owns all sub-cli calls; ticker is stateless and lives in the server. State in `~/.halo/global/evo.db`. See [
|
|
187
|
+
Files: `evolution/{ticker,evo-wrapper,enqueue,spawn,archive}.ts`, `db/evo-db.ts`, `routes/evolution.ts`. Internal agents `__evo_agent__` / `__score__` / `__apply_agent__` (in `templates/agents/`) drive a 12-phase orchestration: snapshot → evo drafts → wrapper dry-runs → scorer grades → reviewer approves → apply agent merges → wrapper history-snapshots + cps to main. Per-task wrapper Node child process owns all sub-cli calls; ticker is stateless and lives in the server. State in `~/.halo/global/evo.db`. See [evolution.md](evolution.md).
|
|
176
188
|
|
|
177
189
|
### Run ledger — restart nudge for interrupted roots
|
|
178
190
|
Files: `agents/run-ledger.ts` (sweep + skip rules) + `db/runs-db.ts` (the global `~/.halo/global/runs.db` table `running_sessions`, same singleton pattern as `evo.db`/`cron.db`). `runSession` inserts its session id on entry and deletes it in the finally (server-only, gated on `reconcileOrphansOnBoot`), so whatever is left at boot is exactly what the previous process was mid-run on; the sweep drains those rows and nudges each interrupted root. `index.ts` runs this eagerly: right after constructing the `SessionManagerRegistry` and before `bootChannels`, it loops `listRunningWorkspaces()` and claims+builds a `SessionManager` for each one that isn't already owned by another live server, rather than waiting for someone to open the workspace. See [design/session.md](session.md#run-ledger--restart-nudge-for-interrupted-roots-halo-globalrunsdb).
|
|
@@ -185,7 +197,7 @@ See [dev/api.md](../dev/api.md).
|
|
|
185
197
|
|
|
186
198
|
Standalone terminal client — imports agent-core modules from `@turmind/halo-server` via subpath exports, bypassing all HTTP/WS infrastructure.
|
|
187
199
|
|
|
188
|
-
Entry: `packages/cli/src/index.ts` → `harness.ts` (wraps SessionManager) → `cli.ts` (non-interactive) / `tui.
|
|
200
|
+
Entry: `packages/cli/src/index.ts` → `harness.ts` (wraps SessionManager) → `cli.ts` (non-interactive) / `tui.tsx` + `tui/` (interactive, ink/React).
|
|
189
201
|
|
|
190
202
|
Uses `dispatchCommand()` from `channels/shared/commands.ts` for `/session new`, `/session list`, `/session switch`, `/session compact`, etc. Session prefix: `cli_`. Sessions are persisted identically to admin/channel sessions and are visible in the admin panel.
|
|
191
203
|
|
|
@@ -233,10 +245,8 @@ SQLite only holds metadata indexes; all content lives on the filesystem.
|
|
|
233
245
|
|
|
234
246
|
| Medium | Writer | Contents |
|
|
235
247
|
|---|---|---|
|
|
236
|
-
| SQLite `sessions` | SessionRoutes, WS handler | Frontend session metadata |
|
|
237
248
|
| SQLite `agent_sessions` | SessionManager | Agent session metadata (root + children; includes `working_dir` and `access_level` columns) |
|
|
238
249
|
| `.halo/sessions/{agentId}/{sid}.json` | SessionManager, SessionStore | Session messages |
|
|
239
|
-
| `.halo/sessions/{agentId}/{sid}.events.jsonl` | SessionManager | Event audit log |
|
|
240
250
|
| `.halo/agents/{id}/agent.yaml` | AgentConfigRoutes | Agent YAML (workspace scope) |
|
|
241
251
|
| `.halo/agents/{id}/AGENT.md` | AgentConfigRoutes | Agent personality |
|
|
242
252
|
| `~/.halo/global/agents/{id}/agent.yaml` | AgentConfigRoutes | Agent YAML (global scope) |
|
|
@@ -246,12 +256,12 @@ SQLite only holds metadata indexes; all content lives on the filesystem.
|
|
|
246
256
|
| `<project>/.halo/settings.yaml` | SettingsRoutes | Per-project overrides |
|
|
247
257
|
| `~/.halo/global/models/<provider>.yaml` | Manual edit | Model registry — one file per provider, scanned at startup, used to dispatch to the matching runtime |
|
|
248
258
|
| `~/.halo/global/prompts/{bootstrap,all,root}/*.md` | init.ts seed + user | System prompts |
|
|
249
|
-
| `~/.halo/
|
|
250
|
-
| `~/.halo/secrets/channels/channels.db` | All channels | Unified channel accounts (Telegram,
|
|
259
|
+
| `~/.halo/global/logs/server.log` | Logger | Server logs (10 MB rotation) |
|
|
260
|
+
| `~/.halo/secrets/channels/channels.db` | All channels | Unified channel accounts (Web, Telegram, WeChat, Slack, Feishu, WeCom) — see [storage.md](storage.md#channel_accounts) |
|
|
251
261
|
|
|
252
262
|
## Coupling hot spots
|
|
253
263
|
|
|
254
|
-
**SessionManager (~
|
|
264
|
+
**SessionManager (~3350 lines)** — the central hub. Five concerns were carved out into one-directional sibling classes (see Key modules): UI-log/events (SessionUIStore), metadata queries (SessionQueryStore), agent construction (SessionAgentBuilder), skill-command permissions (SessionSkillCommands), rawMessages persistence (SessionStateStore). The carve-out is **complete** — what remains is the genuine high-cohesion core, all bound to the `sessions: Map` mutable state: the turn-execution loop, session lifecycle, the concurrency guards (`locks` init-mutex + `deletedSessionIds` delete-tombstone), and compaction.
|
|
255
265
|
|
|
256
266
|
Compaction stays in, by deliberate decision at two levels. Cluster-level: it's bidirectionally interwoven with the turn loop (a beforeCallModel hook that itself runs a turn) and mutates shared per-session state, so extraction would widen the host interface and tangle control flow. Function-level: `selfCompactSession` is one connected chain (compute cut → summarize via LLM → rebuild `[summary+recent]` → write back) — pulling the pure bits into compact.ts would sever a coherent single-use method and scatter it across files, trading cohesion for testability. A long, single-purpose, sequentially-coupled method is fine; the inherent complexity of session handling doesn't shrink by relocating it.
|
|
257
267
|
|
|
@@ -259,7 +269,7 @@ Compaction stays in, by deliberate decision at two levels. Cluster-level: it's b
|
|
|
259
269
|
|
|
260
270
|
**Low: REST Routes / Core / tools** — no cross-dependencies; interact via DB or filesystem.
|
|
261
271
|
|
|
262
|
-
**Dual writes**: Session data lives in both SQLite and the on-disk JSON; deletion must synchronise
|
|
272
|
+
**Dual writes**: Session data lives in both SQLite and the on-disk JSON; deletion must synchronise both (SQLite → JSON + archive segments).
|
|
263
273
|
|
|
264
274
|
## Frontend (short version)
|
|
265
275
|
|
|
@@ -272,5 +282,5 @@ packages/admin
|
|
|
272
282
|
|
|
273
283
|
Code structure:
|
|
274
284
|
- `app/` — Next.js app router entry
|
|
275
|
-
- `features/` — UI modules grouped by domain (agents / chat / editor / explorer / terminal / workspace / settings / skills / auth)
|
|
285
|
+
- `features/` — UI modules grouped by domain (agents / chat / editor / explorer / terminal / workspace / settings / skills / auth / source-control / cron / evolution / channels, plus one dir per channel)
|
|
276
286
|
- `shared/` — cross-feature resources (stores / components / ws-handlers / ws-client / api-client / types / utils)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Command — Design
|
|
2
2
|
|
|
3
|
-
Unified command processing layer — all channels (WS, WeChat, Telegram, Web, CLI/TUI) share a single dispatch path.
|
|
3
|
+
Unified command processing layer — all channels (WS, WeChat, Telegram, Slack, Feishu, WeCom, Web, CLI/TUI) share a single dispatch path.
|
|
4
4
|
|
|
5
5
|
## Architecture
|
|
6
6
|
|
|
@@ -32,9 +32,9 @@ WS handler special-cases `/session compact` because it needs UI progress callbac
|
|
|
32
32
|
1. Channel receives a slash command from user input (or WS `command:<name>` message)
|
|
33
33
|
2. Channel builds a `CommandContext` (shared interface: sm, userId, sessionPrefix, accessLevel, workspacePath, lang)
|
|
34
34
|
3. Channel calls `dispatchCommand(ctx, '/command', args)`
|
|
35
|
-
4. `dispatchCommand` switch: `/help` and `/evo` call `exec*` directly; object commands (`/session`, `/agent`, `/skill`, `/workspace`) and the default case route through `routeObjectOrSkill`
|
|
35
|
+
4. `dispatchCommand` switch: `/help` and `/evo` call `exec*` directly; object commands (`/session`, `/agent`, `/skill`, `/workspace`, `/goal` when goal mode is enabled) and the default case route through `routeObjectOrSkill`
|
|
36
36
|
5. `routeObjectOrSkill` tries the builtin noun-verb table first (`SUBCOMMAND_ROUTES`, per-verb access via `verbAccessMap`), else falls through to `execSkillCommand` for the same-named skill — verb/permission model in [requirements/command.md](../requirements/command.md)
|
|
37
|
-
6. Returns `CommandResult { text, switchTo?, workspace? }` — channel formats and sends to user
|
|
37
|
+
6. Returns `CommandResult { text, switchTo?, workspace?, startedTurn?, sessionId? }` — channel formats and sends to user
|
|
38
38
|
|
|
39
39
|
`scanSkillDescriptors` drops skill commands that collide with a built-in (or another skill) at scan time, so dispatch and the discovery API stay consistent — see [requirements/command.md](../requirements/command.md#conflict-detection).
|
|
40
40
|
|
|
@@ -53,6 +53,6 @@ packages/server/src/channels/shared/
|
|
|
53
53
|
|
|
54
54
|
## Frontend integration
|
|
55
55
|
|
|
56
|
-
- `features/chat/slash-commands.ts` — `refreshCommands(projectId)` pulls from REST API
|
|
56
|
+
- `features/chat/slash-commands.ts` — `refreshCommands(projectId, sessionId?, agentId?)` pulls from REST API; a client-side fallback list is used only when that fails
|
|
57
57
|
- `features/chat/use-chat.ts` — handles client-only commands locally; server commands sent as `command:<name>` via WS
|
|
58
|
-
- `features/chat/chat-panel.tsx` — calls `refreshCommands()` on project switch
|
|
58
|
+
- `features/chat/chat-panel.tsx` — calls `refreshCommands()` on project / session / agent switch
|
|
@@ -101,8 +101,8 @@ interface CronChannelDispatcher {
|
|
|
101
101
|
- no `supportsMedia` (telegram, feishu, wecom) → the **original text with `MEDIA:` lines intact** — the attachment degrades to a visible path instead of silently vanishing.
|
|
102
102
|
- `CronMedia.workspacePath` is the **job's** workspace (where the run produced its files), not the target account's — it's the sandbox root for `isMediaPathAllowed` (paths outside workspace/OS-tmp are rejected with a failed result row). Cron passes no access level, so the `full`-account relaxation the chat handlers get does not apply here — the job workspace + OS tmp rule stays.
|
|
103
103
|
- Wiring telegram/feishu up for real requires refactoring first, not copying: telegram's file send is an inline `sendPhoto/…/sendDocument` switch in `handler.ts`'s responder; feishu's `sendFeishuMedia` is anchored to an inbound `messageId` a cron run doesn't have. Extract the send-to-chat half in the channel module, then flip `supportsMedia: true` (details in each `cron-dispatcher.ts` header).
|
|
104
|
-
- **Per-channel size limits are the dispatcher's job**, not the runner's: wechat splits the text at `WECHAT_TEXT_LIMIT` (3500 chars, shared `splitText` from `channels/shared/chunk.ts`) and sends the chunks in order — the ilink gateway rejects any single `sendmessage` over 16 KB with `ret=-2`. A failure on chunk *i* of *n* is recorded as `chunk i/n: <error>` in the target's `DispatchResult`, so the admin run row shows how much of the report already landed (a `failed` row no longer means "nothing arrived"). Telegram
|
|
105
|
-
- **`chatId` semantics**: when a job is created from inside a chat, the target pins to that conversation (`chatId` set). When unset, the channel's own
|
|
104
|
+
- **Per-channel size limits are the dispatcher's job**, not the runner's: wechat splits the text at `WECHAT_TEXT_LIMIT` (3500 chars, shared `splitText` from `channels/shared/chunk.ts`) and sends the chunks in order — the ilink gateway rejects any single `sendmessage` over 16 KB with `ret=-2`. A failure on chunk *i* of *n* is recorded as `chunk i/n: <error>` in the target's `DispatchResult`, so the admin run row shows how much of the report already landed (a `failed` row no longer means "nothing arrived"). Telegram does the same at `TELEGRAM_TEXT_LIMIT` (4000 chars, the responder's limit — the Bot API caps a message at 4096), with the same `chunk i/n: <error>` failure row. The slack / feishu / wecom dispatchers still send the whole report as one message — same class of gap, unfixed.
|
|
105
|
+
- **`chatId` semantics**: when a job is created from inside a chat, the target pins to that conversation (`chatId` set). When unset, the channel's own default-recipient logic decides: WeChat falls back to the QR-bind owner, then the last active chat; Telegram/Slack/Feishu/WeCom require an explicit `chatId` and throw without one. WeCom is the one dispatcher with **no HTTP send path**: it borrows the handler's live long-connect `WSClient` (`liveClients`), so a stopped / kicked / auth-exhausted account fails the target loudly (`wecom long-connect not active`) instead of queueing.
|
|
106
106
|
- `listTargets()` aggregates across all dispatchers (`listAllCronTargets`) to feed the admin create-form dropdown without the routes knowing any channel.
|
|
107
107
|
|
|
108
108
|
## Retention
|
|
@@ -37,7 +37,7 @@ Let agents draft and validate improvements to their own prompt surface by learni
|
|
|
37
37
|
next user message uses patched rules
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
All coordination via global db (`~/.halo/global/evo.db`). Wrapper heartbeats every 60s; ticker
|
|
40
|
+
All coordination via global db (`~/.halo/global/evo.db`). Wrapper heartbeats every 60s; ticker requeues a stale `running` row (→ `timeout` once `max_attempts` is spent). Detached process model: server restart doesn't kill in-flight wrappers.
|
|
41
41
|
|
|
42
42
|
## Trigger and Enqueue Flow
|
|
43
43
|
|
|
@@ -52,12 +52,12 @@ User in chat:
|
|
|
52
52
|
Server flow (synchronous, ~10ms):
|
|
53
53
|
|
|
54
54
|
1. Refuse if user is not `full` access (not gated on `evolution.level`).
|
|
55
|
-
2. Resolve
|
|
55
|
+
2. Resolve the active session id; a sub-session id (contains `>`) is rejected.
|
|
56
56
|
3. Call `enqueueEvoRun()` from `/packages/server/src/evolution/enqueue.ts`:
|
|
57
57
|
- mkdir `<ws>/.halo/evo/runs/<id>/`
|
|
58
58
|
- Write `source-snapshot.json` (frozen `session.agent.messages`)
|
|
59
59
|
- Write `tool-flow.md` (tool_result-stripped Markdown view for skimming)
|
|
60
|
-
- Write `meta.json` (metadata:
|
|
60
|
+
- Write `meta.json` (metadata: id, triggerKind, sourceSession, userHint, workspacePath, createdAt)
|
|
61
61
|
- Write `evo-context.json` (snapshot of prompt surface at trigger time: assembled system prompt, all prompt files, agent/skill listings)
|
|
62
62
|
4. INSERT `evolution_runs` row with `status='pending'`, `trigger_kind='note'` (internal trigger id keeps the old name; only the user-visible command was renamed to `/evo`).
|
|
63
63
|
5. Reply via `chat:system`: "📝 Queued for evaluation".
|
|
@@ -66,24 +66,22 @@ Server flow (synchronous, ~10ms):
|
|
|
66
66
|
|
|
67
67
|
### Pre-compact hook
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
Compaction only sets a per-session `compactedThisTurn` flag; `SessionManager.enqueueEvoForCompactedTurn()` consumes it once the compact is done — at the end of `runSession` for auto-compact (mid-turn or turn-end), right after the summary lands for manual `/compact`. A turn that compacts several times therefore enqueues one run.
|
|
70
70
|
|
|
71
|
-
1.
|
|
72
|
-
2.
|
|
71
|
+
1. Checks: `evolution.level` = L1, `evolution.triggers.pre_compact` on, root session (`parentId` null).
|
|
72
|
+
2. `enqueueEvoRun()` with `trigger='pre-compact'` snapshots the session's current `rawMessages`. Because the enqueue is deferred, that is the already-compacted history (summary + kept tail), not the pre-compact one.
|
|
73
73
|
3. INSERT `evolution_runs` row.
|
|
74
|
-
4. Continue with normal compact.
|
|
75
|
-
|
|
76
|
-
Order matters: snapshot first, compact second. Otherwise evo sees the already-compacted (summarized) message log, losing detail it should learn from.
|
|
77
74
|
|
|
78
75
|
## Ticker — Stateless Scheduler (every 30s)
|
|
79
76
|
|
|
80
77
|
Located: `/packages/server/src/evolution/ticker.ts`
|
|
81
78
|
|
|
82
|
-
|
|
79
|
+
Four jobs per pass:
|
|
83
80
|
|
|
84
|
-
1. **Mark timeouts**: `running` rows with heartbeat older than `runTimeoutMinutes` →
|
|
81
|
+
1. **Mark timeouts**: `running` rows (applies: also `syncing`) with heartbeat older than `runTimeoutMinutes` (`applyTimeoutMinutes` for applies) → back to `pending` while `attempts < maxAttempts`; exhausted rows → terminal `timeout` (a `syncing` apply → `failed`).
|
|
85
82
|
2. **Start runs**: count current `running`, claim `pending` runs (oldest first, up to slot budget), spawn `evo-wrapper.js --mode=run --id=<X>`.
|
|
86
83
|
3. **Start applies**: same shape, but with per-workspace mutex (only one apply per workspace at a time, since apply reads **current** main to build sandbox).
|
|
84
|
+
4. **Broadcast**: diff in-flight run/apply statuses against the previous tick and push `evolution:run_changed` / `evolution:apply_changed` — wrappers are detached processes and can't reach the server's WS.
|
|
87
85
|
|
|
88
86
|
Restart-safe: no in-memory state. On server reboot, ticker re-evaluates db on first tick — stale `running` rows timeout, pending rows continue to be picked up. Applies can resume mid-publish if they were in `syncing` state (heartbeat timeout was mid-phase-12).
|
|
89
87
|
|
|
@@ -95,7 +93,7 @@ The wrapper is the orchestrator; individual agents do focused work only. Every p
|
|
|
95
93
|
|
|
96
94
|
### Phase A — Draft
|
|
97
95
|
|
|
98
|
-
`spawn('halo', ['cli', '-a', '__evo_agent__', '-n', '-w', '<runDir>/sandbox'
|
|
96
|
+
`spawn('halo', ['cli', '-a', '__evo_agent__', '-n', '-w', '<runDir>/sandbox'])` — the brief goes in on stdin (multi-KB; argv would hit Windows limits), capped at `PHASE_TIMEOUT_SEC` (30 min).
|
|
99
97
|
|
|
100
98
|
Agent writes:
|
|
101
99
|
- `patch.md` — Markdown with YAML frontmatter + body. Frontmatter includes `testScenario: { agentId, testMessage, originalMessage }` and `target: .halo/<path>`.
|
|
@@ -106,20 +104,20 @@ The `target` can be any file in the prompt surface — `INSTRUCTIONS.md`, the ag
|
|
|
106
104
|
|
|
107
105
|
Sandbox is whitelist-cp from main workspace (only: `INSTRUCTIONS.md`, `INDEX.md`, `USER.md`, `agents/`, `prompts/`, `skills/`, `docs/`). Agent reads via `file_read`, writes only to `.halo/` subset.
|
|
108
106
|
|
|
109
|
-
The copy is `copyDereferenced`
|
|
107
|
+
The copy is `copyDereferenced` in `evo-wrapper.ts`, a hand-rolled recursive walk that resolves every symlink to its target's bytes, so the sandbox holds contents and never a link back into the live workspace (an evo edit through a link would write straight into main). It replaced `fs.cpSync({ dereference: true })` because Node ≥ 22.17 ignores `dereference` for symlinks nested below the top level (nodejs/node#59168; fix merged 2026-09-18, unreleased at the time).
|
|
110
108
|
|
|
111
109
|
### Phase B — Dry-run + Fix Loop
|
|
112
110
|
|
|
113
111
|
```
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
halo cli -a <patch.testScenario.agentId> -n -w <runDir>/sandbox --access workspace
|
|
113
|
+
# testMessage on stdin; Node-side 1800s timer → exit 124
|
|
116
114
|
```
|
|
117
115
|
|
|
118
116
|
`--access workspace` masks `~/.aws`, `~/.ssh`, etc. (safety: test probe's behavior, not side effects).
|
|
119
117
|
|
|
120
118
|
Outcome:
|
|
121
119
|
- **Success** (exit 0 + non-empty stdout): save to `dry-run-output.txt`, proceed to phase C.
|
|
122
|
-
- **Failure**: save log, re-spawn `__evo_agent__` in fix mode with failure log inline. One corrective pass only (`FIX_BUDGET = 1`). If fix also fails, mark `failed` with reason "dry-run
|
|
120
|
+
- **Failure**: save log, re-spawn `__evo_agent__` in fix mode with failure log inline. One corrective pass only (`FIX_BUDGET = 1`). If fix also fails, mark `failed` with reason "phase B: dry-run failed (fix budget exhausted)".
|
|
123
121
|
|
|
124
122
|
Fix budget design:
|
|
125
123
|
- Two-pass (original + one fix) covers common failure modes (bad YAML, scope-too-aggressive test scenario).
|
|
@@ -127,7 +125,7 @@ Fix budget design:
|
|
|
127
125
|
|
|
128
126
|
### Phase C — Score
|
|
129
127
|
|
|
130
|
-
`spawn('halo', ['cli', '-a', '__score__', '-n', '-w', '<runDir>/sandbox'
|
|
128
|
+
`spawn('halo', ['cli', '-a', '__score__', '-n', '-w', '<runDir>/sandbox'])` (brief on stdin)
|
|
131
129
|
|
|
132
130
|
The score brief packs patch.md + dry-run-output.txt + meta.json + evo-context.json inline; the baseline conversation stays on disk (`tool-flow.md` / `source-snapshot.json`) and the brief directs the scorer to `file_read` it so behavior is graded against the actual baseline. Writes `score.json`:
|
|
133
131
|
|
|
@@ -160,7 +158,7 @@ After user approves N runs in the admin UI, `evolution_applies` row created with
|
|
|
160
158
|
|
|
161
159
|
Wrapper builds **fresh sandbox** (whitelist-cp from **current main** workspace), then:
|
|
162
160
|
|
|
163
|
-
`spawn('halo', ['cli', '-a', '__apply_agent__', '-n', '-w', '<applyDir>/sandbox'
|
|
161
|
+
`spawn('halo', ['cli', '-a', '__apply_agent__', '-n', '-w', '<applyDir>/sandbox'])` (brief on stdin)
|
|
164
162
|
|
|
165
163
|
Agent reads each source run's `patch.md` (latest version!), merges changes into sandbox. Respects platform override matrix (workspace replaces global wholesale for agents/, skills/, prompts/). Agent writes `apply.log` (audit trail), edits only under `sandbox/.halo/`.
|
|
166
164
|
|
|
@@ -176,13 +174,13 @@ The wrapper clears any stale ABORT.md (from a crashed previous attempt) at the s
|
|
|
176
174
|
For each source_run_id:
|
|
177
175
|
|
|
178
176
|
1. Read `testScenario` from source run's `patch.md`.
|
|
179
|
-
2. Run `
|
|
177
|
+
2. Run `halo cli -a <agentId> -n -w <applyDir>/sandbox --access workspace` (testMessage on stdin, same 1800s timer) against merged sandbox.
|
|
180
178
|
3. Spawn `__score__` with regress-mode brief, write `regress/<runId>/score.json`.
|
|
181
|
-
4. Any score with `lint < 50` or `behavior < 50` → regression, abort with `failed`.
|
|
179
|
+
4. Any score with `lint < 50` or `behavior < 50` (or a missing / malformed `score.json`) → regression, abort with `failed`.
|
|
182
180
|
|
|
183
181
|
### Phase 12 — Final Sync (checkpointed)
|
|
184
182
|
|
|
185
|
-
|
|
183
|
+
Four steps, with a checkpoint before the dangerous one:
|
|
186
184
|
|
|
187
185
|
1. **Preflight** (idempotent): walk sandbox, diff against main, snapshot pre-apply files to `history/apply-<id>/` with `MANIFEST.json`.
|
|
188
186
|
2. **Checkpoint**: set `evolution_applies.status='syncing'`. From here on, if wrapper crashes, ticker recovery resumes at step 3 (skip A'/B'/preflight to avoid re-running LLM phases).
|
|
@@ -216,6 +214,7 @@ Per-workspace mutex in ticker prevents two applies from racing on the same works
|
|
|
216
214
|
# diagnosis; first line becomes failureReason)
|
|
217
215
|
regress/<runId>/ # one per source run
|
|
218
216
|
dry-run-output.txt
|
|
217
|
+
dry-run-fail.log # only when the regress dry-run failed
|
|
219
218
|
score.json
|
|
220
219
|
history/apply-<id>/ # rollback snapshot (NOT archived)
|
|
221
220
|
MANIFEST.json # which paths were overwritten
|
|
@@ -270,7 +269,7 @@ Active rows (pending/running/awaiting_review/approved/syncing) never archived. `
|
|
|
270
269
|
|
|
271
270
|
Archive daemon runs at server boot (1 min delay) + daily. Idempotent.
|
|
272
271
|
|
|
273
|
-
Zipping is **pure JS** (`jszip`,
|
|
272
|
+
Zipping is **pure JS** (`jszip`, a server dependency). It used to `spawnSync('zip', …)`, which depends on a system binary Windows installs don't have — so every archive pass failed there forever and artifacts grew unbounded. `zipDir()` walks the tree manually rather than using a recursive readdir, because the layout has to match what `zip -rq <out> .` produced and two cases need explicit handling: empty dirs get explicit folder entries, and **directory symlinks are not recursed** (an agent runs inside the sandbox during evaluation and could leave a cycle). File symlinks store the target's contents (what system `zip` without `-y` did); broken links are skipped instead of failing the pass. Contents are buffered per file (`createReadStream` for every entry would open all fds up front — EMFILE risk) and the zip is streamed to disk. On failure the partial zip is deleted, and `archiveRun`/`archiveApply` skip both the `rmDir` and the `archived_at` stamp, so the next pass retries from a clean state.
|
|
274
273
|
|
|
275
274
|
## Integration with SessionManager
|
|
276
275
|
|
|
@@ -280,13 +279,13 @@ After apply publishes to main, **no explicit session-release step needed**. Sess
|
|
|
280
279
|
|
|
281
280
|
## Key Files and Their Roles
|
|
282
281
|
|
|
283
|
-
| File |
|
|
284
|
-
|
|
285
|
-
| `/packages/server/src/evolution/enqueue.ts` |
|
|
286
|
-
| `/packages/server/src/evolution/ticker.ts` |
|
|
287
|
-
| `/packages/server/src/evolution/spawn.ts` |
|
|
288
|
-
| `/packages/server/src/evolution/evo-wrapper.ts` |
|
|
289
|
-
| `/packages/server/src/evolution/archive.ts` |
|
|
282
|
+
| File | Purpose |
|
|
283
|
+
|---|---|
|
|
284
|
+
| `/packages/server/src/evolution/enqueue.ts` | Snapshot session + prompt surface at trigger time. Write runDir/, INSERT evolution_runs. Both `/evo` and pre-compact hook call this. |
|
|
285
|
+
| `/packages/server/src/evolution/ticker.ts` | Stateless 30s scheduler. Mark timeouts, claim pending, spawn wrappers. Per-workspace apply mutex. Broadcast status changes to admin UI. |
|
|
286
|
+
| `/packages/server/src/evolution/spawn.ts` | Real spawner: detached Node child running evo-wrapper.js. Override-able for testing. |
|
|
287
|
+
| `/packages/server/src/evolution/evo-wrapper.ts` | Wrapper orchestrator. 3-phase run mode (draft/dry-run/score). Apply mode (merge/regress/publish). Heartbeat every 60s. Handles Windows command-line limits via stdin briefs. |
|
|
288
|
+
| `/packages/server/src/evolution/archive.ts` | Archive job: 14d → zip (pure-JS jszip) + delete, 30d → purge. Runs daily + at boot. Idempotent. |
|
|
290
289
|
|
|
291
290
|
## Sandbox Model
|
|
292
291
|
|
|
@@ -322,14 +321,14 @@ L0 = manual drafting only (`/evo`); L1 also enables pre-compact triggering. Defa
|
|
|
322
321
|
|
|
323
322
|
Top-level "Evolution" tab shows:
|
|
324
323
|
|
|
325
|
-
- **List**: evolution_runs + latest apply per run
|
|
326
|
-
- **Detail**: patch.md, score.json,
|
|
327
|
-
- **Approve**: Opens
|
|
324
|
+
- **List**: evolution_runs + latest apply per run, newest first (cursor-paginated by `created_at`, up to 300 loaded). Filterable by status, plus an `archived` view. Joins runs+applies to show consolidated view.
|
|
325
|
+
- **Detail**: patch.md (raw), score.json, the `.skip.md` reason, a snapshot summary (message count, first user / assistant message), wrapper log + sub-cli log.
|
|
326
|
+
- **Approve**: Opens an inline panel, optional reviewer_hint. Flips the run to `approved` **and** inserts the pending `evolution_applies` row **inside one `db.transaction`** — it's a single reviewer decision, and half of it (run flipped, apply missing) strands the run in a status the ticker never advances.
|
|
328
327
|
- **Reject**: Sets status='rejected'.
|
|
329
|
-
- **Retry**: Resets row to pending, requires new hint.
|
|
330
|
-
- **Manual Delete**: Removes artifacts (live dir + archive zip, whichever exists) + DB row immediately. Blocked on
|
|
328
|
+
- **Retry**: Resets row to pending, requires new hint. The route allows any status except `pending` / `running`; the UI offers it on `awaiting_review`, `failed` / `timeout` / `skipped` / `rejected`, and on an `approved` run whose apply failed.
|
|
329
|
+
- **Manual Delete**: Removes artifacts (live dir + archive zip, whichever exists) + DB row immediately. Blocked on `pending` / `running` / `approved` runs; also deletes the applies the run produced.
|
|
331
330
|
|
|
332
|
-
Realtime updates via WebSocket (`evolution:run_changed`, `evolution:apply_changed` events). REST mutations broadcast directly; wrapper state changes broadcast at each ticker tick (
|
|
331
|
+
Realtime updates via WebSocket (`evolution:run_changed`, `evolution:apply_changed` events). REST mutations broadcast directly; wrapper state changes broadcast at each ticker tick (up to 30s latency).
|
|
333
332
|
|
|
334
333
|
## Crash Recovery and Restarts
|
|
335
334
|
|
|
@@ -29,10 +29,12 @@ The marker is **stripped from rendered chat** so the user sees only the face mov
|
|
|
29
29
|
|
|
30
30
|
**Runtime expression:** Agent emits `<<<SHOW: payload >>>` in a reply. On `chat:complete`:
|
|
31
31
|
1. `chat-handlers.ts` takes the round's replies with `takeRoundReplies()` (`chat-store.ts`) — every main assistant bubble since the previous `chat:complete`, in log order, each handed out once. A round can span several bubbles (the head an interjection split off, a turnId split, the follow-up answering a queued message), so scanning only the last bubble would miss earlier markers
|
|
32
|
-
2. `maybeHandleShow(replies)` detects all `<<<SHOW:[\s\S]*?>>>` markers (non-greedy, global) in those bubbles; `maybeHandleCapture(wsClient, replies)` looks for `<<<CAPTURE>>>` in the same set
|
|
32
|
+
2. `maybeHandleShow(replies)` detects all `<<<SHOW:[\s\S]*?>>>` markers (non-greedy, global) in those bubbles; `maybeHandleCapture(wsClient, tabStore, replies)` looks for `<<<CAPTURE>>>` in the same set
|
|
33
33
|
3. For each match, extracts the payload (trimmed)
|
|
34
34
|
4. Calls `postToFace(payload)` to forward it
|
|
35
35
|
|
|
36
|
+
`takeRoundReplies()` runs on every tab (it resets that tab's round), but steps 2–4 run only for the tab on screen — a background session must not drive the face the user is looking at.
|
|
37
|
+
|
|
36
38
|
**No dedup set.** `takeRoundReplies()` advances a cursor (`roundStart`) past what it returned, so the duplicate `chat:complete`s a queue drain emits find no new bubbles and fire nothing. `setMessages` (history load, snapshot restore, reattach) and `clear()` reset the cursor to the end of the loaded log, so markers in already-loaded history never fire.
|
|
37
39
|
|
|
38
40
|
**Forwarding:** `face-bridge.ts:postToFace()` posts to every registered iframe:
|
|
@@ -58,7 +60,7 @@ parsed.replace(/<<<CAPTURE>>>/g, '')
|
|
|
58
60
|
|
|
59
61
|
## The `self` API
|
|
60
62
|
|
|
61
|
-
Provided by `self.html`
|
|
63
|
+
Provided by `self.html` as `const self = {…}`. All expression methods are sandboxed to the preview iframe.
|
|
62
64
|
|
|
63
65
|
### Scene queuing (sequential playback)
|
|
64
66
|
|
|
@@ -87,12 +89,12 @@ Provided by `self.html` from line 432 onwards (`const self = {…}`). All expres
|
|
|
87
89
|
|
|
88
90
|
## Key files
|
|
89
91
|
|
|
90
|
-
- **Engine template:** `packages/server/templates/canvas/self.html` — particle field, mode switching, API surface, voice audio graph.
|
|
92
|
+
- **Engine template:** `packages/server/templates/canvas/self.html` — particle field, mode switching, API surface, voice audio graph. Canonical source; force-copied to every workspace on open.
|
|
91
93
|
- **Skill instruction:** `packages/server/templates/skills/self/SKILL.md` — teaches the agent when/how to use the face.
|
|
92
94
|
- **Marker detection:** `packages/admin/src/shared/ws-handlers/chat-handlers.ts:maybeHandleShow()` — regex match `<<<SHOW:([\s\S]*?)>>>` on the round's replies at `chat:complete`; the replies come from `takeRoundReplies()` in `chat-store.ts`, which hands each bubble out once.
|
|
93
95
|
- **Iframe registration:** `packages/admin/src/features/editor/face-bridge.ts` — module-level registry of mounted previews; `postToFace()` forwards payloads via `postMessage`.
|
|
94
96
|
- **Preview component:** `packages/admin/src/features/editor/html-preview.tsx` — sandboxed iframe with `allow-scripts` + `allow-same-origin` and `allow="autoplay"` (so `self.voice` audio, triggered by postMessage rather than a click, isn't gated), calls `registerFaceIframe()` on mount.
|
|
95
|
-
- **Marker stripping:** `packages/admin/src/shared/components/message-list.tsx:TextBlock()`
|
|
97
|
+
- **Marker stripping:** `packages/admin/src/shared/components/message-list.tsx:TextBlock()` — strips both `<<<CAPTURE>>>` and `<<<SHOW:...>>>` before rendering.
|
|
96
98
|
- **Workspace init:** `packages/server/src/init.ts:ensureWorkspaceHalo()` — force-copies engine on workspace open. `self` is in `BUILTIN_SKILL_IDS` so the skill is always available.
|
|
97
99
|
|
|
98
100
|
## Engine architecture
|
|
@@ -14,7 +14,7 @@ Halo server (9527) ──┤├── channels/wechat/ ├── Se
|
|
|
14
14
|
https://open.feishu.cn
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
Feishu uses the official SDK's `Lark.WSClient` to maintain a persistent WebSocket connection for event delivery. Unlike
|
|
17
|
+
Feishu uses the official SDK's `Lark.WSClient` to maintain a persistent WebSocket connection for event delivery. Unlike Telegram (long-polling), Feishu's long-connect receives `im.message.receive_v1` events pushed by the server over wss.
|
|
18
18
|
|
|
19
19
|
## Data model
|
|
20
20
|
|
|
@@ -70,7 +70,7 @@ Files: `packages/server/src/channels/feishu/`
|
|
|
70
70
|
- `accounts.ts` — DAL (listAccounts / getAccount / insertAccount / updateAccount / deleteAccount / findAccountByAppId)
|
|
71
71
|
- `handler.ts` — Lark.WSClient setup, event dispatch, message handling, mention detection, media ingestion; session routing + listener/route bookkeeping come from `channels/shared/inbound.ts` (`InboundBridge` / `deliverInbound` / `dispatchChannelCommand`)
|
|
72
72
|
- `event-adapter.ts` — AgentSessionEvent stream → coalesced Feishu message replies (buffer + flush at paragraph boundaries)
|
|
73
|
-
- `api.ts` — HTTP client (tenant token caching / getBotInfo / sendMessage / replyMessage / uploadImage / uploadFile / downloadResource / decryptWebhookBody / searchFeishuTargets / openLongConnection)
|
|
73
|
+
- `api.ts` — HTTP client (tenant token caching / getBotInfo / sendMessage / replyMessage / uploadImage / uploadFile / downloadResource / decryptWebhookBody / searchFeishuTargets / openLongConnection); authenticated calls go through `fetchWithTenantToken` (401 → drop the cached token, retry once) and replies are parsed by `readFeishuJson`
|
|
74
74
|
- `cron-dispatcher.ts` — registers the cron dispatcher, requires explicit chatId targets
|
|
75
75
|
- `descriptor.ts` — ServerChannelDescriptor entry point
|
|
76
76
|
|
|
@@ -122,7 +122,7 @@ The SDK wraps protobuf marshalling; we just register an `EventDispatcher` callba
|
|
|
122
122
|
|
|
123
123
|
## Slash commands (native Feishu /commands)
|
|
124
124
|
|
|
125
|
-
Implemented via `channels/shared/commands.ts` (shared across Telegram, WeChat, Slack, Feishu):
|
|
125
|
+
Implemented via `channels/shared/commands.ts` (shared across Telegram, WeChat, Slack, Feishu, WeCom):
|
|
126
126
|
|
|
127
127
|
| Command | Purpose |
|
|
128
128
|
|---|---|
|
|
@@ -152,7 +152,9 @@ splitting produces several chunks in one synchronous loop and each is its own
|
|
|
152
152
|
send API call, so firing them concurrently gave arrival order no guarantee. Each
|
|
153
153
|
responder appends every chunk to a `sendTail` promise chain — chunk *n+1* waits
|
|
154
154
|
for *n* to settle — and a rejected link is absorbed so one failed send doesn't
|
|
155
|
-
stall the remainder (`dispatchChunk` logs it).
|
|
155
|
+
stall the remainder (`dispatchChunk` logs it). Both live in the shared
|
|
156
|
+
`ChunkedResponder` (`channels/shared/responder.ts`); `FeishuResponder` only
|
|
157
|
+
supplies the 4500 limit and `formatForFeishu`.
|
|
156
158
|
|
|
157
159
|
`close()` returns that drain promise and `InboundBridge` holds the session's
|
|
158
160
|
reply route until it settles, then deletes it; the `stopAccount` / `closeAll()`
|
|
@@ -170,7 +172,7 @@ trailing chunks of a long reply were dropped.
|
|
|
170
172
|
- `.mp4/.mov/.webm/.m4v/.avi` → `uploadFile(fileType: 'mp4')` → `msg_type: 'media'` (with optional cover frame)
|
|
171
173
|
- everything else → `uploadFile(fileType: 'stream')` → `msg_type: 'file'`
|
|
172
174
|
|
|
173
|
-
Path whitelist: a `full` account may send any readable path; other access levels are limited to the workspace or the OS temp dir (`isMediaPathAllowed(path, workspace, accessLevel)`). A blocked path throws inside the same try as the upload, so it surfaces as the `t('handler.upload_failed')` reply rather than a silent drop.
|
|
175
|
+
Path whitelist: a `full` account may send any readable path; other access levels are limited to the workspace or the OS temp dir (`isMediaPathAllowed(path, workspace, accessLevel)`). A blocked path throws inside the same try as the upload (`sendMediaOrReport` in `channels/shared/media.ts`), so it surfaces as the `t('handler.upload_failed')` reply rather than a silent drop.
|
|
174
176
|
|
|
175
177
|
Inbound images are downloaded via `/im/v1/messages/{messageId}/resources/{imageKey}?type=image`, decrypted if needed, saved under `<workspace>/.halo/assets/feishu/inbound/<accountId>/<date>/`, and fed to vision.
|
|
176
178
|
|
|
@@ -180,17 +182,19 @@ Inbound images are downloaded via `/im/v1/messages/{messageId}/resources/{imageK
|
|
|
180
182
|
|
|
181
183
|
**Group (chat):** Replies sent via `replyMessage` with `reply_in_thread: true` and the inbound message id. This auto-creates a thread rooted at the inbound message if it doesn't exist, or appends to the existing thread.
|
|
182
184
|
|
|
185
|
+
**After a restart**, `startAccount` calls `restoreReplyRoute()` to re-wire the reply route from `lastActiveChatId` (`restoreChannelRoute` in `channels/shared/inbound.ts`, no session created). p2p only: a group-thread reply needs the inbound message id, which isn't persisted, so group threads re-wire on their next inbound message.
|
|
186
|
+
|
|
183
187
|
## Similarities vs Slack and Telegram
|
|
184
188
|
|
|
185
189
|
| Aspect | Feishu | Slack | Telegram |
|
|
186
190
|
|--------|--------|-------|----------|
|
|
187
|
-
| **Inbound delivery** | Long-connect (wss) |
|
|
191
|
+
| **Inbound delivery** | Long-connect (wss) | Socket Mode (wss) | Long-polling |
|
|
188
192
|
| **Session model** | Per-thread in groups, p2p anchored | Per-thread + channels | Per-user (one active) |
|
|
189
193
|
| **Mention required** | Yes in groups, no in p2p | Yes in channels, no in DMs | No (all messages routed to agent) |
|
|
190
194
|
| **Cron target** | Explicit chatId only | Explicit chatId only (DM / channel / thread) | Explicit numeric chatId only |
|
|
191
195
|
| **Media upload** | Separate image + file endpoints | Unified File upload API | Bot API sendPhoto / sendVideo / sendDocument |
|
|
192
|
-
| **Text limit** |
|
|
193
|
-
| **Thread support** | Native (root_id in messages) | Native (thread_ts replies) | N/A (group chats
|
|
196
|
+
| **Text limit** | 4500 chars per message | 35000 chars per message | 4000 chars per message |
|
|
197
|
+
| **Thread support** | Native (root_id in messages) | Native (thread_ts replies) | N/A (group chats supported, no threads; sessions keyed by sender) |
|
|
194
198
|
|
|
195
199
|
## Configuration
|
|
196
200
|
|
|
@@ -230,10 +234,10 @@ Not supported: group chat without mention (we require explicit @mention for grou
|
|
|
230
234
|
|
|
231
235
|
## Key file references
|
|
232
236
|
|
|
233
|
-
- Long-connect: `packages/server/src/channels/feishu/handler.ts
|
|
234
|
-
- Token caching: `packages/server/src/channels/feishu/api.ts
|
|
235
|
-
- Message parsing: `packages/server/src/channels/feishu/handler.ts
|
|
236
|
-
- Event dispatch: `packages/server/src/channels/feishu/handler.ts
|
|
237
|
-
- Cron dispatch: `packages/server/src/channels/feishu/cron-dispatcher.ts
|
|
238
|
-
- Event coalescing: `packages/server/src/channels/feishu/event-adapter.ts
|
|
237
|
+
- Long-connect: `packages/server/src/channels/feishu/handler.ts` `connect()`
|
|
238
|
+
- Token caching: `packages/server/src/channels/feishu/api.ts` `getTenantAccessToken()`
|
|
239
|
+
- Message parsing: `packages/server/src/channels/feishu/handler.ts` `parseContent()`
|
|
240
|
+
- Event dispatch: `packages/server/src/channels/feishu/handler.ts` `handleInbound()`
|
|
241
|
+
- Cron dispatch: `packages/server/src/channels/feishu/cron-dispatcher.ts` `dispatch()`
|
|
242
|
+
- Event coalescing: `packages/server/src/channels/feishu/event-adapter.ts` `FeishuResponder`, extending `channels/shared/responder.ts` `ChunkedResponder`
|
|
239
243
|
- REST routes: `packages/server/src/routes/feishu.ts` (all account CRUD + search)
|
|
@@ -65,7 +65,7 @@ Halo 服务器运行态的**街景式**像素可视化:侧视、剖面的像素
|
|
|
65
65
|
| 🛸 UFO | 每天 0:00 | 午夜从街头飘过 |
|
|
66
66
|
| ✈️ 飞机 | 每半点(`:30`) | 高空掠过,方向随机 |
|
|
67
67
|
| 🚌 巴士 | 每 3 分钟 | 进站、开门、接走在站台等车的人,再开走 |
|
|
68
|
-
| 🚗 进出场载具 | 跟随会话 |
|
|
68
|
+
| 🚗 进出场载具 | 跟随会话 | 新根会话坐出租车或小轿车送到门口(子代理直接淡入,不配车);结束的会话走到站台**体面地等车离场**,不再凭空消失 |
|
|
69
69
|
| 🏗️ 盖楼 / 拆楼 | 跟随楼层增减 | 长楼层有施工动效;拆楼留 ~30s 缓冲(先让人撤离再慢慢消散,不穿帮) |
|
|
70
70
|
|
|
71
71
|
> 定时全用真实 `Date.now()` 计算(`?hour=` 只冻结天色、不冻结这些事件)。
|
|
@@ -73,8 +73,8 @@ Halo 服务器运行态的**街景式**像素可视化:侧视、剖面的像素
|
|
|
73
73
|
## 交互与显示
|
|
74
74
|
|
|
75
75
|
操作:滚轮缩放(九档 `0.3×–6×`)· 拖拽平移 · 点击查看 · **双击**在 1×/3× 间快速切 ·
|
|
76
|
-
**F** 全街区 · **H**(或点左上角 logo)切纯净模式 · **Esc**
|
|
77
|
-
右上角 **中/EN**
|
|
76
|
+
**F** 全街区 · **H**(或点左上角 logo)切纯净模式 · **Esc** 先关面板,没有面板时切纯净模式 ·
|
|
77
|
+
右上角 **中/EN** 一键切语言。
|
|
78
78
|
|
|
79
79
|
- **纯净模式(zen)**:淡出所有 HUD(统计/动态/图例/缩放条/面板),只留街景,适合大屏常驻展示
|
|
80
80
|
- **多语言(i18n)**:中文 / English 一键切;`?lang=en`/`?lang=zh` 直达,选择存 localStorage。
|
|
@@ -104,6 +104,7 @@ js/
|
|
|
104
104
|
camera.js 整数吸附镜头(九档缩放 0.3×–6×)
|
|
105
105
|
inspector.js 面板 + 实时会话日志查看器
|
|
106
106
|
ticker.js 街区动态 feed
|
|
107
|
+
music.js 背景音乐:纯 Web Audio 程序生成
|
|
107
108
|
traffic.js 街景事件:UFO(0点)/飞机(每半点)/进出场载具/每 3 分钟 bus/盖楼·拆楼
|
|
108
109
|
i18n.js 多语言词典(zh/en)+ 运行时 t() + 静态 data-i18n 填充
|
|
109
110
|
util.js hash/PRNG/格式化
|
|
@@ -117,7 +118,7 @@ js/
|
|
|
117
118
|
token 铸造的会话**(id 以 `web_<accountId>_` 开头);点其它市民会 403,
|
|
118
119
|
前端在 inspector 里显示加载失败框而不是 transcript
|
|
119
120
|
- `/api/show/state` — 无活跃 UIState 的会话(idle/stopped/重启后)token
|
|
120
|
-
从会话文件头读取(
|
|
121
|
+
从会话文件头读取(按会话 `updatedAt` 缓存,未变化时不随轮询重读),不显示 0;含 `messageCount` 字段
|
|
121
122
|
|
|
122
123
|
> **`messageCount` 与 admin 的 `exchangeCount` 是两个语义,不要互相对齐**:
|
|
123
124
|
> 这里读的是会话文件里的 `messageCount`(活跃 UI 日志的长度),城市只把它
|