@turmind/halo 1.5.4 → 1.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-e0faeff1c4a0b0b9.js +1 -0
- package/admin-out/_next/static/chunks/app/page-44f707ea8dad57da.js +1 -0
- package/admin-out/_next/static/chunks/{webpack-1a264efd965db7ab.js → webpack-edf248e07333566d.js} +1 -1
- package/admin-out/_next/static/css/b8c87b919758c52b.css +3 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/agent.md +10 -8
- package/bundled-docs/design/agentcore.md +13 -10
- package/bundled-docs/design/architecture.md +24 -14
- package/bundled-docs/design/canvas-extensions.md +19 -4
- 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 +14 -14
- package/bundled-docs/design/slack.md +22 -20
- package/bundled-docs/design/storage.md +30 -29
- 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 +86 -57
- package/bundled-docs/dev/deploy.md +8 -6
- package/bundled-docs/dev/env.md +29 -21
- package/bundled-docs/dev/logging.md +15 -29
- package/bundled-docs/dev/previews.md +20 -11
- package/bundled-docs/dev/tools.md +26 -14
- 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 +10 -9
- package/bundled-docs/guide/cli.md +10 -8
- package/bundled-docs/guide/delegation-and-access.md +1 -1
- 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 +9 -7
- package/bundled-docs/guide/testing-agents-and-skills.md +14 -15
- package/bundled-docs/guide/workspace.md +29 -17
- package/bundled-docs/requirements/agent.md +19 -18
- package/bundled-docs/requirements/chat.md +16 -17
- package/bundled-docs/requirements/command.md +10 -10
- package/bundled-docs/requirements/editor.md +12 -10
- package/bundled-docs/requirements/explorer.md +10 -7
- package/bundled-docs/requirements/session.md +16 -17
- package/bundled-docs/requirements/settings.md +30 -18
- 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 +10587 -12088
- package/package.json +1 -1
- package/templates/agents/default/agent.yaml +1 -0
- package/templates/config.yaml +6 -0
- package/templates/skills/extension/SKILL.md +25 -14
- package/templates/skills/extension/config.yaml +9 -0
- package/templates/skills/extension/templates/ext.sh +166 -34
- package/templates/skills/web-search/SKILL.md +20 -6
- package/templates/skills/web-search/config.yaml +13 -0
- package/templates/skills/web-search/search.py +27 -6
- package/templates/skills/workspace/share.md +11 -3
- package/templates/skills/workspace/stage.py +85 -5
- package/admin-out/_next/static/chunks/989-92e9683ae8d39750.js +0 -1
- package/admin-out/_next/static/chunks/app/page-7a635969198fc62b.js +0 -1
- package/admin-out/_next/static/css/4ea5f674146e54d8.css +0 -3
- /package/admin-out/_next/static/{lm8Bz3ugTSORyuSXfyx56 → visjvN510yFK-Ff5zwx-0}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{lm8Bz3ugTSORyuSXfyx56 → visjvN510yFK-Ff5zwx-0}/_ssgManifest.js +0 -0
|
@@ -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 日志的长度),城市只把它
|
|
@@ -34,14 +34,14 @@ No `OTEL_EXPORTER_OTLP_ENDPOINT` after the mapping → `enabled=false`, `initObs
|
|
|
34
34
|
|
|
35
35
|
| module | role |
|
|
36
36
|
|---|---|
|
|
37
|
-
| `packages/server/src/observability/otel.ts`
|
|
38
|
-
| `packages/server/src/observability/otel-sdk.ts`
|
|
39
|
-
| `packages/server/src/observability/genai-spans.ts`
|
|
37
|
+
| `packages/server/src/observability/otel.ts` | Bootstrap. `initObservability()` / `shutdownObservability()`, the `enabled` gate, `tracer` / `getMeter()` / `otelLogger` / `captureContent()` exports. |
|
|
38
|
+
| `packages/server/src/observability/otel-sdk.ts` | The ONE module that imports the SDK packages and OTLP exporters. |
|
|
39
|
+
| `packages/server/src/observability/genai-spans.ts` | The span model: `beginTurn` / `onAgentEvent` / `recordRetry` / `endTurn`. |
|
|
40
40
|
| `packages/server/src/agents/session-manager.ts` | Calls the four hooks above from the turn loop (see below). |
|
|
41
41
|
| `packages/server/src/logger.ts` | Bridges every `logger.*` call into an OTel LogRecord when `enabled`. |
|
|
42
|
-
| `packages/server/src/index.ts` | `await initObservability()` (
|
|
42
|
+
| `packages/server/src/index.ts` | `await initObservability()` (before `initLogger()`) and `await shutdownObservability()` (in `gracefulShutdown`). |
|
|
43
43
|
|
|
44
|
-
**Hook call sites in `session-manager.ts
|
|
44
|
+
**Hook call sites in `session-manager.ts`** (all in `runAgentTurn`): `beginTurn(session, message)` at the start, `onAgentEvent(session, event)` inside the agent event loop (one call per `AgentEvent`), `recordRetry(kind)` at the 7 retry catch sites, `endTurn(session, {error?})` once after the retry loop (the turn's single exit point, success or failure).
|
|
45
45
|
|
|
46
46
|
**The `enabled` gate.** `otel.ts` exports a single `enabled: boolean`, set once by `initObservability()`. Every hook in `genai-spans.ts` and the `logger.ts` bridge checks it first — an unconfigured server pays nothing beyond one boolean read per hook call.
|
|
47
47
|
|
|
@@ -49,9 +49,9 @@ No `OTEL_EXPORTER_OTLP_ENDPOINT` after the mapping → `enabled=false`, `initObs
|
|
|
49
49
|
|
|
50
50
|
**Why the meter is lazy.** The `@opentelemetry/api` tracer and logger are proxies — they stay no-op until a real provider is registered, then transparently start forwarding, so `tracer` / `otelLogger` can be module-level consts. The metrics API has **no proxy**: a `Meter` fetched via `metrics.getMeter()` before `setGlobalMeterProvider()` runs is a permanent no-op forever after, even once a provider registers later. `getMeter()` in `otel.ts` is therefore a function, not a const, and `genai-spans.ts` calls it lazily on first metric emission (`instruments()`, cached after).
|
|
51
51
|
|
|
52
|
-
**Dependencies** (`packages/server/package.json`): `@opentelemetry/api` 1.9.1, `api-logs` 0.222.0, `sdk-trace-node` / `sdk-trace-base` / `sdk-metrics` / `
|
|
52
|
+
**Dependencies** (`packages/server/package.json`): `@opentelemetry/api` 1.9.1, `api-logs` 0.222.0, `sdk-trace-node` / `sdk-trace-base` / `sdk-metrics` / `resources` 2.11.0, `sdk-logs` / `exporter-trace-otlp-proto` / `exporter-metrics-otlp-proto` / `exporter-logs-otlp-proto` 0.222.0, `semantic-conventions` 1.43.0. No gRPC exporter (see [Rejected alternatives](#rejected-alternatives)).
|
|
53
53
|
|
|
54
|
-
**Test coverage**: `packages/server/test/observability-genai-spans.test.ts` (
|
|
54
|
+
**Test coverage**: `packages/server/test/observability-genai-spans.test.ts` (8 cases) drives `beginTurn` / `onAgentEvent` / `endTurn` against an in-memory tracer + `InMemorySpanExporter`, asserting the three-level span tree, parent ids, semconv attribute names, and the `capture_content` on/off split.
|
|
55
55
|
|
|
56
56
|
## Span model
|
|
57
57
|
|
|
@@ -87,7 +87,7 @@ Child of `invoke_agent`, one per model call — created retroactively on the `us
|
|
|
87
87
|
| `gen_ai.input.messages` | | semconv JSON (see below) — **delta only**: messages appended since this turn's previous `chat` span (first call → the user message; later calls → that cycle's `tool_result`s) |
|
|
88
88
|
| `gen_ai.output.messages` | | semconv JSON, only the trailing assistant message |
|
|
89
89
|
|
|
90
|
-
Semconv message JSON: `[{role, parts:[{type:"text",content}|{type:"tool_call",id,name,arguments}|{type:"tool_call_response",id,
|
|
90
|
+
Semconv message JSON: `[{role, parts:[{type:"text",content}|{type:"tool_call",id,name,arguments}|{type:"tool_call_response",id,response}]}]`.
|
|
91
91
|
|
|
92
92
|
`gen_ai.input.messages` is incremental on purpose (since 1.3.1): replaying the full history on every model call made a turn's exported bytes O(n²) and the system prompt alone was ~60% of the volume — a 4-turn / 9-call session dropped from 129 KB to 44 KB of content attributes. Nothing is lost: the full conversation for a trace is the concatenation of its `chat` spans' input + output deltas in order (a `TurnState.messageCursor` tracks the boundary; a mid-turn compact that shrinks the history yields an empty delta and re-syncs the cursor). Verified that AgentCore Evaluations scores are unchanged — its evaluators read `invoke_agent`'s `task.*` and `execute_tool`'s `arguments/result`, not the chat messages.
|
|
93
93
|
|
|
@@ -105,7 +105,7 @@ Child of the `chat` span that requested it, one per tool call — created on the
|
|
|
105
105
|
| `gen_ai.tool.call.arguments` | | JSON |
|
|
106
106
|
| `gen_ai.tool.call.result` | | text, empty → `[no output]` |
|
|
107
107
|
|
|
108
|
-
Tool calls still pending when `endTurn` fires (turn interrupted/aborted mid-cycle) are closed as orphans
|
|
108
|
+
Tool calls still pending when `endTurn` fires (turn interrupted/aborted mid-cycle) are closed as orphans (no status set) with `halo.tool.orphaned=true`, so no trace is left with a dangling tool-call id.
|
|
109
109
|
|
|
110
110
|
All content attributes are capped (32 KB general cap, 2 KB per message part, 8 KB for system instructions); over-cap content is truncated with a `…[truncated N chars]` marker, and an over-cap message list drops the oldest messages first with an omission marker.
|
|
111
111
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
On agent startup, Halo assembles the system prompt by concatenating various MD files in a fixed order.
|
|
4
4
|
|
|
5
|
-
Entry point: `session-
|
|
5
|
+
Entry point: `session-agent-builder.ts` `SessionAgentBuilder.buildAgentInstance(agentId, sessionId, parentId?, workingDir?, accessLevel?)`
|
|
6
6
|
|
|
7
7
|
**Root agent rule**: `!parentId` (parentId null/undefined = root). Does not depend on any `is_default` field.
|
|
8
8
|
|
|
@@ -17,7 +17,7 @@ Entry point: `session-manager.ts` `buildAgentInstance(agentId, sessionId, parent
|
|
|
17
17
|
├── prompts/ ← user-editable system prompts (externalised)
|
|
18
18
|
│ ├── bootstrap/BOOTSTRAP.md ← first-run guidance
|
|
19
19
|
│ ├── all/ ← every-agent rules (TOOL_GUIDELINES.md, TOOL_SHELL[.windows].md, WORKSPACE_CONVENTIONS.md, RUNTIME.md)
|
|
20
|
-
│ └── root/ ← root-agent-only (
|
|
20
|
+
│ └── root/ ← root-agent-only (DELEGATION.md, WORKSPACE_MEMORY.md)
|
|
21
21
|
├── agents/<id>/{agent.yaml, AGENT.md}
|
|
22
22
|
└── skills/<id>/SKILL.md ← built-in `halo` skill carries platform self-knowledge (loaded on demand via activate_skill)
|
|
23
23
|
```
|
|
@@ -52,7 +52,7 @@ Resolution is by the agent's **folder** (`agentSourceDir`), not per file:
|
|
|
52
52
|
agent entirely (both `agent.yaml` and `AGENT.md`), with no per-file fallback
|
|
53
53
|
to global — a missing file inside it is just absent. YAML fields: `name` /
|
|
54
54
|
`description` / `model` / `tools` / `skills` / `system_prompt` / `context` /
|
|
55
|
-
`priority`.
|
|
55
|
+
`priority` / `internal` / `team`.
|
|
56
56
|
|
|
57
57
|
## Step 2 — Resolve MD file paths
|
|
58
58
|
|
|
@@ -127,14 +127,14 @@ Startup seeds `templates/prompts/{bootstrap,all,root}/` into `~/.halo/global/pro
|
|
|
127
127
|
|
|
128
128
|
Replacement is **whole-file**, not per-section — so split the platform-divergent content into its own file and keep the common content in an un-suffixed file. Example in `prompts/all/`: `TOOL_GUIDELINES.md` (common) + `TOOL_SHELL.md` (unix Shell section) / `TOOL_SHELL.windows.md` (cmd.exe, `dir`/`findstr`, `%USERPROFILE%`, `python` vs `python3`, `.py` skill caveats). To add a new platform difference anywhere, extract that block to `FOO.md` and add `FOO.windows.md`.
|
|
129
129
|
|
|
130
|
-
Result:
|
|
131
|
-
- `prompts/bootstrap/*.md` → `
|
|
132
|
-
- `prompts/all/*.md` → `
|
|
133
|
-
- `prompts/root/*.md` → `
|
|
130
|
+
Result (the returned `systemPrompts` object; the sections below write these fields as `bootstrapPrompt` / `allPrompt` / `rootPrompt`):
|
|
131
|
+
- `prompts/bootstrap/*.md` → `systemPrompts.bootstrap`
|
|
132
|
+
- `prompts/all/*.md` → `systemPrompts.all`
|
|
133
|
+
- `prompts/root/*.md` → `systemPrompts.root`
|
|
134
134
|
|
|
135
135
|
Missing directory or read failure: warn + use built-in fallback.
|
|
136
136
|
|
|
137
|
-
`loadSystemPrompts` also returns `files: { bootstrap: string[]; all: string[]; root: string[] }` — the absolute paths of the `.md` files actually read for each scope (empty when the built-in fallback was used). `session-
|
|
137
|
+
`loadSystemPrompts` also returns `files: { bootstrap: string[]; all: string[]; root: string[] }` — the absolute paths of the `.md` files actually read for each scope (empty when the built-in fallback was used). `session-agent-builder.ts` forwards this list into `AgentMeta.mdFiles`, which `/session context` renders one line per file (label `prompt/<scope>/<basename>`). When a scope falls back to the built-in constants, a single line `prompt/<scope> (built-in fallback): <dir>` is shown instead.
|
|
138
138
|
|
|
139
139
|
## Step 5 — Compose the MD prompt
|
|
140
140
|
|
|
@@ -159,8 +159,8 @@ Global and workspace-root INSTRUCTIONS share the same `## User Instructions` hea
|
|
|
159
159
|
mdPrompt ← incl. roster + working_dir scope (both inside composeMdPrompt)
|
|
160
160
|
+ "\n\nThe project workspace is at: {workspaceRoot}\n"
|
|
161
161
|
+ [optional] "Working directory: {workingDir}\n"
|
|
162
|
-
+ allPrompt
|
|
163
162
|
+ rootPrompt
|
|
163
|
+
+ allPrompt
|
|
164
164
|
```
|
|
165
165
|
|
|
166
166
|
(The `working_dir` directory-chain INSTRUCTIONS.md live *inside* `mdPrompt` — folded into the `## User Instructions` region by `composeMdPrompt` — not after the `Working directory:` tagline. That tagline is just a one-line focus marker.)
|
|
@@ -216,7 +216,7 @@ If `mdPrompt` is empty:
|
|
|
216
216
|
|
|
217
217
|
## Step 7 — Append skills and tool list
|
|
218
218
|
|
|
219
|
-
[session-
|
|
219
|
+
[session-agent-builder.ts](../../../packages/server/src/agents/session-agent-builder.ts) `composeSystemPrompt`
|
|
220
220
|
|
|
221
221
|
### Skills (progressive disclosure)
|
|
222
222
|
|
|
@@ -245,7 +245,7 @@ AGENT.md ← workspace > global
|
|
|
245
245
|
## Your Team ← roster (dropped when team is empty/unset)
|
|
246
246
|
## User Instructions ← ~/.halo/global/INSTRUCTIONS.md (suppressed when ws has its own)
|
|
247
247
|
## User Instructions ← <ws>/.halo/INSTRUCTIONS.md (workspace root)
|
|
248
|
-
## Project Knowledge ← <ws>/.halo/INDEX.md (
|
|
248
|
+
## Project Knowledge ← <ws>/.halo/INDEX.md (skipped when absent)
|
|
249
249
|
"The project workspace is at: ..."
|
|
250
250
|
"Working directory: ..." ← if workingDir is set
|
|
251
251
|
rootPrompt ← prompts/root/*.md (ws > global): DELEGATION.md, WORKSPACE_MEMORY.md
|
|
@@ -403,4 +403,4 @@ Schema declared by each package + values stored centrally is the same model VSCo
|
|
|
403
403
|
- **Bootstrap trigger**: `!parentId && !userMd`
|
|
404
404
|
- **System prompts are external**: resolved from workspace then global `prompts/{bootstrap,all,root}/*.md`, read live, sorted by filename.
|
|
405
405
|
- **System prompt missing**: warn + use built-in fallback; `system-prompts.ts` is the seed source and fallback
|
|
406
|
-
- **`.halo/` is
|
|
406
|
+
- **`.halo/` is grep/globbed** except its runtime subtrees (`sessions/ logs/ evo/ tmp/ assets/ canvas/`)
|
|
@@ -82,9 +82,9 @@ Manages every agent session's lifecycle (root + sub-agent). Each session is 1:1
|
|
|
82
82
|
|
|
83
83
|
| Method | Purpose |
|
|
84
84
|
|---|---|
|
|
85
|
-
| `createSession(agentId, parentId, description, agentName?, explicitId?, workingDir?, accessLevel?)` | Create a session (SQLite + memory). `workingDir` = absolute path at runtime, stored as workspace-relative in DB; null = project root. `accessLevel` = `'readonly'`, `'workspace'`, or `null` (full). |
|
|
86
|
-
| `sendUserMessage(sessionId, text, images?)` | Send a message — run immediately if idle, queue if busy |
|
|
87
|
-
| `compactSession(sessionId)` | LLM-summary compact |
|
|
85
|
+
| `createSession(agentId, parentId, description, agentName?, explicitId?, workingDir?, accessLevel?, title?)` | Create a session (SQLite + memory). `workingDir` = absolute path at runtime, stored as workspace-relative in DB; null = project root. `accessLevel` = `'readonly'`, `'workspace'`, or `null` (full). |
|
|
86
|
+
| `sendUserMessage(sessionId, text, images?, accessLevel?)` | Send a message — run immediately if idle, queue if busy; returns `'running'` or `'queued'` |
|
|
87
|
+
| `compactSession(sessionId, opts?)` | LLM-summary compact (`opts.onProgress` for UI progress) |
|
|
88
88
|
| `interruptSession(sessionId)` | Abort the in-flight turn now (fire-and-forget); `interruptRequested` is set so the unwind repairs rather than errors, then the queued message drains. Shared by esc and the `interrupt_session` tool |
|
|
89
89
|
| `stopSession(sessionId)` | Fold the whole `messageQueue` into `agent.messages` (preserve, don't drop), abort + repair, no re-run, sets `stoppedAt`. Cascades to descendants |
|
|
90
90
|
| `deleteSession(sessionId)` | Cascade-delete a session and all descendants (SQLite) |
|
|
@@ -166,7 +166,7 @@ Hierarchical encoding: `root_id>child_segment>grandchild_segment`.
|
|
|
166
166
|
|
|
167
167
|
A sub-session whose process was killed mid-run never got its `stoppedAt` written, so it stays `stoppedAt IS NULL` forever — displaying as a false "running" and permanently blocking its parent's auto-report bubbling (`tryReportToParent` sees a "live" child that will never report back). When the server process first builds a workspace's SessionManager, `reconcileOrphansOnBoot` batch-stamps `stoppedAt` on every non-root, non-stopped, non-archived session. Only the long-lived server passes `reconcileOrphansOnBoot: true` through the registry — CLI/TUI/channel-subprocess/evo-wrapper share the same db while the server may be running sessions, so they never reconcile. If an orphan is later revived via `query_session`, that path clears `stoppedAt` again, so nothing is trapped permanently.
|
|
168
168
|
|
|
169
|
-
**Workspace-level gate (`.halo/runtime.lock`)**: `server.lock` ownership alone is not sufficient — two servers with different `HALO_HOME` (e.g. prod + dev) each hold their own `server.lock` yet can point at the *same* workspace directory, and one server's boot reconcile would batch-stop the other's actually-live sub-sessions (the incident this gate exists for). So the reconcile additionally requires `claimWorkspaceRuntime(workspaceRoot)` (`agents/workspace-runtime-lock.ts`) — a pid marker at `<workspace>/.halo/runtime.lock` with a liveness probe. Claim fails → skip reconcile and log a warning: **prefer missing a crash-orphan cleanup over stopping another process's live sessions**. `reconcileOrphansOnBoot: true` therefore means "reconcile if the workspace claim succeeds," not "always." Lock protocol details in [storage.md](storage.md#workspace-runtime-lock); known residual: when two servers both actively use one workspace long-term, the non-owner never reconciles — its own crash orphans stay un-cleaned until the owner restarts and takes over. The run-ledger eager boot loop below deals with the *un-opened* case for the same residual: a workspace whose runtime another live process owns is skipped before a SessionManager is even built, so the rows wait for a later boot instead of freezing behind a cached non-owner instance.
|
|
169
|
+
**Workspace-level gate (`.halo/runtime.lock`)**: `server.lock` ownership alone is not sufficient — two servers with different `HALO_HOME` (e.g. prod + dev) each hold their own `server.lock` yet can point at the *same* workspace directory, and one server's boot reconcile would batch-stop the other's actually-live sub-sessions (the incident this gate exists for). So the reconcile additionally requires `claimWorkspaceRuntime(workspaceRoot)` (`agents/workspace-runtime-lock.ts`) — a pid marker at `<workspace>/.halo/runtime.lock` with a liveness probe. Claim fails → skip reconcile and log a warning: **prefer missing a crash-orphan cleanup over stopping another process's live sessions**. `reconcileOrphansOnBoot: true` therefore means "reconcile if the workspace claim succeeds," not "always." Lock protocol details in [storage.md](storage.md#workspace-runtime-lock); known residual: when two servers both actively use one workspace long-term, the non-owner never reconciles — its own crash orphans stay un-cleaned until the owner restarts and takes over. The run-ledger eager boot loop below deals with the *un-opened* case for the same residual: a workspace whose runtime another live process owns is skipped before a SessionManager is even built, so the rows wait for a later boot instead of freezing behind a cached non-owner instance. A `HALO_BADGE=DEV` server never takes part (`config.server.ownsWorkspaceRuntimes`): its registry is built with `reconcileOrphansOnBoot: false` and it skips the eager loop, so it never claims or writes `runtime.lock` and never runs reconcile, goal or ledger sweeps. Its ledger is off as well. Channel route restore builds its SessionManager without a claim. Prod owns all of this outright. Costs: orphans from a dev crash wait for prod's next restart, and a prod restart stamps the sub-sessions dev is running in a shared workspace stopped.
|
|
170
170
|
|
|
171
171
|
**Shutdown** (`gracefulShutdown` in `index.ts`): for every loaded workspace, `sm.flushAll()` first lands the UI logs not yet persisted (a dirty root plus every live sub-session log — the pending 500ms debounces would die with the process), then `releaseWorkspaceRuntime` unlinks `.halo/runtime.lock` only when it holds *this* process's pid. A crash / SIGKILL leaves the file behind and takes the "pid dead → next boot takes over" path, so the release is an optimisation (a recycled pid can't read as a live holder), never a correctness dependency. The desktop app's `before-quit` (POSIX) holds the quit until the server process has actually exited (3s hard cap), so `killServer`'s SIGKILL fallback for a wedged server still runs instead of being dropped with the Electron process — otherwise a stuck server keeps the port and `server.lock`.
|
|
172
172
|
|
|
@@ -182,7 +182,7 @@ The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the *
|
|
|
182
182
|
|
|
183
183
|
**Eager boot** (`index.ts`): rather than waiting for someone to open a workspace, right after constructing `SessionManagerRegistry` (before `bootChannels`) the server calls `listRunningWorkspaces()` and for each workspace with leftover rows whose `.halo` dir still exists: `claimWorkspaceRuntime(ws)` first — if another live server owns it, `continue` (rows stay for a later boot, and no SessionManager is built, since caching a non-owner SM would freeze "not owner" for the whole process lifetime) — otherwise `registry.getOrCreate(ws)`, which fires the same constructor chain (claim → reconcile → retained `sweepActiveGoals` → `sweepInterruptedRuns`). An interrupted root with nobody around is exactly the one that must nudge itself without a human opening the tab first.
|
|
184
184
|
|
|
185
|
-
`runs.db`/`-wal`/`-shm` join the sandbox `hidden_files`
|
|
185
|
+
`runs.db`/`-wal`/`-shm` join the built-in sandbox hidden-files list (`DEFAULT_HIDDEN_FILES`, always included; `general.sandbox.hidden_files` only adds to it) alongside `evo.db` / `cron.db` (see [storage.md](storage.md)). Known gap carried from the design doc: `<ISO>` in the nudge is sweep time, not the actual interruption time — for a workspace that sat locked by another server before this boot could claim it, that's a real but accepted drift.
|
|
186
186
|
|
|
187
187
|
### Message queue and drain
|
|
188
188
|
|
|
@@ -196,7 +196,7 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
|
|
|
196
196
|
|
|
197
197
|
**Arrival stamp.** Both paths run the turn through `runAgentTurn`, which prepends `[<ISO-8601 UTC>] ` (e.g. `[2026-09-12T15:17:44.153Z] `) to the first text block of the model-facing input — an image-only input gets a stamp-only text block unshifted. The model has no clock; without this it couldn't tell a reply that came two days later from one that came instantly, or how long a sub-agent report took to land. Stamped **once, before the retry loop**, so every attempt (and the [multimodal 4xx degrade](#multimodal-4xx-degrade) rebuild) reuses the identical input. Agent reports therefore read `[<iso>] (from: session X)\n…` — stamp outermost, no special-casing. Only `agent.messages` changes: the UI `type:'user'` event keeps the raw text (see [Turn matching](#exchange-deletion-soft-ui--hard-raw) for the strip on compare), and the self-compact instruction / `[Conversation Summary]` injection / `tool_result` messages are never stamped.
|
|
198
198
|
|
|
199
|
-
**Final-text flag.** The agent loop marks each text block `final` when the model call ended for a reason other than `tool_use` (i.e. it is the turn's wrap-up, not the filler emitted before a tool call), and `runAgentTurn`'s `case 'text'` forwards it on the `stream` event as `AgentSessionEvent.final`. Block-send consumers gate on it: the channel responders (wechat / telegram / slack / feishu) append only `final` text, and `halo cli` prints the last root turn's `final` text as its stdout (fallback: that turn's full text). `finalOutput` (used by `tryReportToParent`) is the same accumulation on the server side. Streaming providers additionally yield `text_delta` / `thinking_delta` events during the model call (forwarded as `stream_delta` / `thinking_delta`, UI-only); they never touch `output` / `finalOutput` — those only ever see the whole `text` event, which follows every completed call exactly as before. The one place the loop itself keeps the text deltas is the interrupted-stream landing (see [Partial streamed text lands](#conversation-repair)): a call cancelled mid-stream pushes what it had streamed as a marked assistant turn so the model's history matches what the UI showed.
|
|
199
|
+
**Final-text flag.** The agent loop marks each text block `final` when the model call ended for a reason other than `tool_use` (i.e. it is the turn's wrap-up, not the filler emitted before a tool call), and `runAgentTurn`'s `case 'text'` forwards it on the `stream` event as `AgentSessionEvent.final`. Block-send consumers gate on it: the channel responders (wechat / telegram / slack / feishu / wecom) append only `final` text, and `halo cli` prints the last root turn's `final` text as its stdout (fallback: that turn's full text). `finalOutput` (used by `tryReportToParent`) is the same accumulation on the server side. Streaming providers additionally yield `text_delta` / `thinking_delta` events during the model call (forwarded as `stream_delta` / `thinking_delta`, UI-only); they never touch `output` / `finalOutput` — those only ever see the whole `text` event, which follows every completed call exactly as before. The one place the loop itself keeps the text deltas is the interrupted-stream landing (see [Partial streamed text lands](#conversation-repair)): a call cancelled mid-stream pushes what it had streamed as a marked assistant turn so the model's history matches what the UI showed.
|
|
200
200
|
|
|
201
201
|
**drainQueue** folds the **whole queue** into ONE merged follow-up turn per round, re-checking after each round (a fresh interrupt or a sibling's report can land mid-drain):
|
|
202
202
|
- The batch is `splice(0)`'d; agent entries keep a `(from: session X)` prefix, user entries fold raw, and all entries' `images` are merged in.
|
|
@@ -205,7 +205,7 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
|
|
|
205
205
|
|
|
206
206
|
**`complete` invariants** (root only):
|
|
207
207
|
- The **terminal** `complete` is emitted from `runSession`'s `finally`, and `promise = null` is set **before** emitting it — the CLI / web stream-close logic gates on `complete && !hasRunningSessions()`, which reads `promise !== null`; emitting `complete` first would leave the session still "running" at the moment the client decides whether to close.
|
|
208
|
-
- **`batchBoundary` complete**: when a merged round finishes **and the queue still has a next round**, `drainQueue` emits `{ type: 'complete', batchBoundary: true }`. This is a per-round flush signal for **block-oriented channels** (wechat / telegram / slack / feishu), which buffer streamed text and only ship a message on `complete` — without it, N drain rounds buffer into one blob that lands only at the terminal `complete` ("8 reports in one lump"). **Stream-terminating consumers (web-channel SSE, ACP) must ignore `batchBoundary` and keep the stream open** — the root is still running and more output follows; only the terminal (unmarked) `complete` closes the stream. See [web.md](web.md) and the per-channel coalescing notes (e.g. [wechat.md](wechat.md)). The
|
|
208
|
+
- **`batchBoundary` complete**: when a merged round finishes **and the queue still has a next round**, `drainQueue` emits `{ type: 'complete', batchBoundary: true }`. This is a per-round flush signal for **block-oriented channels** (wechat / telegram / slack / feishu / wecom), which buffer streamed text and only ship a message on `complete` — without it, N drain rounds buffer into one blob that lands only at the terminal `complete` ("8 reports in one lump"). **Stream-terminating consumers (web-channel SSE, ACP) must ignore `batchBoundary` and keep the stream open** — the root is still running and more output follows; only the terminal (unmarked) `complete` closes the stream. See [web.md](web.md) and the per-channel coalescing notes (e.g. [wechat.md](wechat.md)). The WS `chat:complete` frame carries the field too — admin closes its bubble either way but rings the finish bell only on the terminal one.
|
|
209
209
|
|
|
210
210
|
**Three-tier interrupt model** — two **soft** paths and one **hard** path, distinguished by *when* the abort fires, never by whether the message is kept (all three preserve the queue):
|
|
211
211
|
1. **`query_session` while busy — soft interrupt.** `querySession(..., interrupt=false)` enqueues **and** sets `interruptRequested` (same as a user message). This is what makes a sub-agent fold two queued questions into **one merged answer** instead of replying one-by-one: the in-flight turn unwinds after its current tool, then `drainQueue`'s `splice(0)` folds every message that landed alongside it into a single round — matching how root handles two user messages. An **idle** target has no turn to interrupt, so it just runs the message directly.
|
|
@@ -229,7 +229,7 @@ A hard interrupt aborts the turn's `AbortSignal`, which `agent-loop.ts` forwards
|
|
|
229
229
|
|
|
230
230
|
- **`full` access (non-Windows)** spawns via `spawn(command, { shell: true, detached: true })` so the command becomes a **process-group leader** (`pgid === child.pid`). On abort *or* timeout, `process.kill(-pid, 'SIGTERM')` signals the **whole group**, so a compound command's worker dies with the shell.
|
|
231
231
|
- **Why not `execAsync(command, { signal })`** (the previous implementation): `exec` wraps the command in `/bin/sh -c "<command>"` and, on abort, only SIGTERMs that `sh`. A compound command (`sleep 60 && …`) has already forked the real worker (`sleep`) as a child of `sh` — the signal never reaches it, so it **reparents to init (PPID 1) and runs to completion as an orphan**. The agent turn unwinds correctly (the promise rejects), but the command keeps running, which made `interrupt_session` *look* like it didn't really interrupt. Single non-compound commands didn't expose it (Node optimizes them to a direct `exec` with no `sh` layer).
|
|
232
|
-
- **`workspace` / `readonly` access** run under `bwrap` with `--die-with-parent`, a kernel-level guarantee that the sandboxed child dies with its parent — that path was never affected and is unchanged. **Windows** `full` keeps the `execAsync` path (no process groups; the orphan case doesn't arise the same way).
|
|
232
|
+
- **`workspace` / `readonly` access** run under `bwrap` with `--die-with-parent`, a kernel-level guarantee that the sandboxed child dies with its parent — that path was never affected and is unchanged. On macOS they run under Seatbelt (`sandbox-exec`) through the same `spawnGroupExec` group kill. **Windows** `full` keeps the `execAsync` path (no process groups; the orphan case doesn't arise the same way).
|
|
233
233
|
|
|
234
234
|
The `spawnGroupExec` helper preserves `promisify(exec)`'s contract exactly: resolve `{ stdout, stderr }` on exit 0; reject with an `Error` carrying `.message` / `.stdout` / `.stderr` / `.code` otherwise (abort rejects with `name: 'AbortError'`). Covered by `test/sandbox-process-group.test.ts`, which asserts by side effect — a sentinel file the orphaned worker *would* create must never appear after the abort.
|
|
235
235
|
|
|
@@ -375,7 +375,7 @@ Frontend network issues don't affect the backend:
|
|
|
375
375
|
| **4xx multimodal rejection** (`Multimodal data is corrupted` / `Could not process image`) | Replace all image blocks in history with text placeholders, persist, retry — see [Multimodal 4xx degrade](#multimodal-4xx-degrade) below |
|
|
376
376
|
| Unrecoverable error | Report to user, stop |
|
|
377
377
|
|
|
378
|
-
**Refusal stop.** `stop_reason: "refusal"` (Anthropic models on Bedrock invoke + generic anthropic) is an HTTP 200 with `stop_details {category, explanation}` — not an error, so it never enters the retry matrix above. `agent-loop.ts
|
|
378
|
+
**Refusal stop.** `stop_reason: "refusal"` (Anthropic models on Bedrock invoke + generic anthropic) is an HTTP 200 with `stop_details {category, explanation}` — not an error, so it never enters the retry matrix above. `agent-loop.ts` handles it explicitly: no assistant message is pushed, any partial `tool_use` is **not** executed, the loop ends; `session-manager.ts` emits a `system` event `⚠️ [<agent>] Model declined to respond (<category>): <explanation>` suggesting `/new`. Previously it fell through the `end_turn` path and looked like a silent stall.
|
|
379
379
|
|
|
380
380
|
**Classification order: structured signal first, message text last.** The interrupt branch is decided by the attempt's own `AbortController` signal (every interrupt path we own goes through `abortReason()` on it), never by the words `cancelled` / `aborted` in the message — an upstream error body can legitimately read `"The operation was aborted due to …"` (Bedrock), and matching that swallowed a real failure as a user interrupt: silent `break`, no retry, no error event, an empty reply. Likewise the account-level branch trusts `httpStatus` when one was recovered: 401/402/403 is terminal, any *other* status is not, whatever the body says — an OpenAI-compatible 503 whose body reads `"authentication service temporarily unavailable"` used to be classified as a dead key and never retried. The keyword list is only consulted when no status could be extracted. Pinned by `turn-retry-idempotent.test.ts` (five classification cases).
|
|
381
381
|
|
|
@@ -401,7 +401,7 @@ The transient-5xx branch is the one that all providers share, and getting the **
|
|
|
401
401
|
|
|
402
402
|
**Retry decision** — a failure is treated as a transient server-side error (→ retry with backoff) when **either**:
|
|
403
403
|
|
|
404
|
-
- `err.name` is `InternalServerException`, `ModelTimeoutException`, or `ServiceUnavailableException`; **or**
|
|
404
|
+
- `err.name` is `InternalServerException`, `ModelTimeoutException`, `ModelStreamErrorException`, or `ServiceUnavailableException`; **or**
|
|
405
405
|
- the extracted `httpStatus` is one of `500` / `502` / `503` / `504` / `529` (Anthropic "Overloaded") / `408` (request/model timeout).
|
|
406
406
|
|
|
407
407
|
Throttling is checked the same way one branch earlier — structured `ThrottlingException` name or status 429 first, message keywords second; the real Bedrock throttle message ("Too many requests, please wait before trying again.") carries no keyword, so the name/status check is what catches it.
|
|
@@ -418,7 +418,7 @@ Three entry points with different quality / safety trade-offs:
|
|
|
418
418
|
|---|---|---|---|
|
|
419
419
|
| 90% soft threshold (mid-turn auto) | `maybeAutoCompact()` via agent-loop's `beforeCallModel` hook — runs before each model call within a turn | **Self-compact** (`selfCompactSession`) — the agent summarizes its own context, then a tail micro-compact pass clears bulk tool output. **Not interruptible, no wall-clock cap; local fallback** if the summary throws, idle-times-out or comes back empty (see below) | The agent already has full context cached (prompt cache hit). No extra model call, no input duplication, no risk of losing tool_result semantics. Firing mid-turn (not just at turn end) stops a single long turn that accumulates many large tool results from blowing the window. |
|
|
420
420
|
| Overflow mid-loop (`too many input tokens`, `Input is too long`, …) | `runAgentTurn` retry catch → `localCompactMessages` → retry | **Local** — `[role]: <first N chars>` concat, no network call | The model just refused this payload; an LLM round-trip now could stall the recovery path. Local is deterministic and instant; the next end-of-turn can re-summarize via self-compact. |
|
|
421
|
-
| User `/session compact` (web, WeChat) | `commands/
|
|
421
|
+
| User `/session compact` (web, WeChat) | `channels/shared/commands.ts` (admin WS: `ws/handler.ts`) / `SessionManager.compactSession` | **Self-compact**, cancellable, **no local fallback** | User explicitly requested it; self-compact reuses the cached context so it's fast. A failure (model error / idle timeout) leaves the context unchanged (`Compaction failed — context unchanged`) and is rethrown to the caller; an empty summary emits `Compaction skipped — no summary produced`. |
|
|
422
422
|
|
|
423
423
|
Self-compact **deep-snapshots the keep-region before running the summarize turn** (`messages.slice(cut).map(structuredClone)`), then injects a summarization instruction into the agent's own stream, captures the response, and rebuilds messages as `[summary + snapshot]`. This reuses the provider's prompt cache (no separate model needed) and preserves full semantic context including tool results.
|
|
424
424
|
|
|
@@ -426,7 +426,7 @@ The pre-run snapshot is load-bearing, not an optimization: `agent.run()` coalesc
|
|
|
426
426
|
|
|
427
427
|
As a final byte-trimming step, self-compact runs **micro-compact** over that snapshot (`microCompactMessages(cleanRecent, 1)`). Each kept-recent message may still carry a large tool result (e.g. a 50 KB `file_read`), so after summarizing it would otherwise re-cross the threshold immediately. Micro keeps only the newest `tool_result`'s content and clears the rest in place, preserving `tool_use_id` pairing so the next API call stays valid — no extra LLM round-trip. Micro-compact is **not** a standalone compaction path; `selfCompactSession` is its only call site — self-compact is the entry point, micro is its tail cleanup.
|
|
428
428
|
|
|
429
|
-
All paths share the same split logic — `compactCut(messages)`, exported from `agents/compact.ts` (single source of truth for SessionManager — `selfCompactSession` + the two feasibility gates below — and `localCompactMessages`): keep the last `
|
|
429
|
+
All paths share the same split logic — `compactCut(messages)`, exported from `agents/compact.ts` (single source of truth for SessionManager — `selfCompactSession` + the two feasibility gates below — and `localCompactMessages`): keep the last `keep_messages` turns, advance the cut forward past any orphan `tool_result`-first user message (otherwise the next API call gets `unexpected tool_use_id`). The tail loop only ever moves the cut *up*, so `cut === 0` is exactly the `messages.length <= keep_messages` "nothing to compact" case.
|
|
430
430
|
|
|
431
431
|
**Bounds and Stop.** Self-compact has no wall-clock timeout: a long but still-streaming summary is fine, and a hung call is bounded only by the model-call idle timeout (`timeout.model_request`, re-armed on any data the stream delivers; expiry throws `MODEL_TIMEOUT_ERROR`). An **auto-compact is not interruptible** — `maybeAutoCompact` passes no signal. Stop / Esc (`stopSession`, `interruptSession`, `stopUserSession`, WS `chat:stop` / `chat:interrupt`, `/api/web/stop`) abort only the turn's own controller, which the loop checks once `beforeCallModel` returns, so they take effect after the compact finishes (the stop request itself waits until then). Messages arriving meanwhile queue and run as the next turn once the compact ends; a Stop that folds the queue into history during the compact parks the text in `foldAfterCompact`, and `clearCompacting` lands it on the post-compact history (`foldIntoAgentMessages`). A **manual `/compact`** has no turn, so `cancelCompact` (WS `chat:stop` / `chat:interrupt`, channel `/stop` / `/interrupt` and `POST /api/web/stop` while compacting with no turn running, and `stopUserSession`) cancels it: history rolls back and `Compact cancelled` is emitted.
|
|
432
432
|
|
|
@@ -438,7 +438,7 @@ All paths share the same split logic — `compactCut(messages)`, exported from `
|
|
|
438
438
|
|
|
439
439
|
**Local compact output is API-legal on its own.** `localCompactMessages` returns `[summary, ...recent]` where `recent` starts past any `tool_result`-first user message, so every kept `tool_result` has its `tool_use` in the kept tail and `repairConversationMessages` is a no-op on the result (also on a second pass over its own output). Pinned by `local-compact-roundtrip.test.ts` — without it the overflow retry would only have "worked" via the later corrupted-conversation repair retry.
|
|
440
440
|
|
|
441
|
-
Config (see `config.compact`): `
|
|
441
|
+
Config (see `config.compact`): `keep_messages` / `max_summary_input` / `max_message_slice` (settings keys `general.compact.*`) — editable in Settings → General → compact.
|
|
442
442
|
|
|
443
443
|
**Self-compact also archives the UI log.** `selfCompactSession` calls `uiStore.archiveOldMessages(session.id)` for **root and sub-sessions alike** (root-only until 1.5.3-alpha; a long-running sub-agent's UI log then grew without bound — 4.77 MB in testing), right before the compaction notice is emitted so the notice lands in the kept exchange rather than inside the archived segment. Compaction is the one path that already means "history shrinks here", which is why archiving hangs off it rather than off a timer or its own sweep — the size threshold below decides whether the call does anything. See [UI-log archiving](#ui-log-archiving).
|
|
444
444
|
|
|
@@ -464,7 +464,7 @@ Files: `packages/server/src/sessions/session-archive.ts` (segment IO) · `agents
|
|
|
464
464
|
|
|
465
465
|
**Trigger: file size.** A compact archives only when the active `<seg>.json` is larger than `ARCHIVE_SIZE_THRESHOLD = 3 MB` (one `statSync`; a missing file counts as 0, so the check is also the "nothing written yet" guard). Below it, `archiveOldMessages` returns 0 and touches nothing, however many exchanges have piled up.
|
|
466
466
|
|
|
467
|
-
Bytes, not an exchange count, because bytes are the actual problem — file size is what makes a log slow to read, parse and ship — and per-exchange size varies by ~three orders of magnitude (a bare "yes" versus a turn carrying several 50 KB tool results), so a count is a badly distorted proxy: the same "15 exchanges" is 40 KB in one session and 40 MB in another. It is also self-limiting: after an archive the file restarts near-empty, so it must grow another 3 MB of real content before the next segment, which bounds segment count by total bytes written rather than by how often the user compacts. Still no relation to `config.compact.
|
|
467
|
+
Bytes, not an exchange count, because bytes are the actual problem — file size is what makes a log slow to read, parse and ship — and per-exchange size varies by ~three orders of magnitude (a bare "yes" versus a turn carrying several 50 KB tool results), so a count is a badly distorted proxy: the same "15 exchanges" is 40 KB in one session and 40 MB in another. It is also self-limiting: after an archive the file restarts near-empty, so it must grow another 3 MB of real content before the next segment, which bounds segment count by total bytes written rather than by how often the user compacts. Still no relation to `config.compact.keep_messages` (that counts raw LLM messages — a different granularity with no honest mapping to UI rows), and no setting: one constant, tuned once.
|
|
468
468
|
|
|
469
469
|
**Split point.** Over the threshold, `archiveSplitIndex(messages, 1)` keeps only the **newest main user exchange** (`role === 'user'` without `taskId` — the same subset the admin renders and `deleteExchange` counts ordinals over) and cuts at its start, so a turn's responses are never split from their user message. A log holding a single exchange yields cut 0 and is left alone: there is nothing to move without splitting the turn the user is looking at, so an oversized *single* exchange is deliberately not archived.
|
|
470
470
|
|
|
@@ -61,7 +61,7 @@ Files: `packages/server/src/channels/slack/`
|
|
|
61
61
|
- `types.ts` — SlackAccount, SlackSocketEnvelope, SlackMessageEvent interfaces
|
|
62
62
|
- `accounts.ts` — DAL (thin wrapper over `channels/shared/accounts.ts` for Slack-specific mappings)
|
|
63
63
|
- `handler.ts` — Socket Mode connection + event routing, mention filtering; session resolution + per-thread listener/route bookkeeping come from `channels/shared/inbound.ts` (`InboundBridge` / `deliverInbound` / `dispatchChannelCommand`)
|
|
64
|
-
- `event-adapter.ts` — AgentSessionEvent → Slack messages (buffer + flush
|
|
64
|
+
- `event-adapter.ts` — AgentSessionEvent → Slack messages (buffer + flush on `complete`; split at 35k chars)
|
|
65
65
|
- `api.ts` — HTTP client (auth.test / openSocketModeConnection / chat.postMessage / files.upload / files.download / searchSlackTargets)
|
|
66
66
|
- `cron-dispatcher.ts` — CronDispatcher registration; requires an explicit Slack chatId per dispatch
|
|
67
67
|
|
|
@@ -109,13 +109,13 @@ Unlike Telegram (grammy polling) or WeChat (HTTP long-poll), Socket Mode elimina
|
|
|
109
109
|
|
|
110
110
|
1. SessionManager emits AgentSessionEvent stream chunks
|
|
111
111
|
2. SlackResponder buffers only chunks flagged `final` (the turn's wrap-up text; pre-tool-call filler is dropped) until `complete` event
|
|
112
|
-
3. On `complete`
|
|
113
|
-
- Split at paragraph boundary if
|
|
112
|
+
3. On `complete` (Slack doesn't set `splitMidStream`, so the buffer is never flushed early at the limit):
|
|
113
|
+
- Split at the 35k-char limit, at a paragraph boundary if possible, via the shared `splitText` (`channels/shared/chunk.ts`; Slack hard-caps messages at ~40k)
|
|
114
114
|
- Extract `MEDIA: <path>` markers (sent as native file uploads)
|
|
115
115
|
- Convert CommonMark → Slack's mrkdwn format
|
|
116
116
|
- Post as a single message (or series of messages if split)
|
|
117
117
|
- This fires on **any** `complete`; the responder ignores its `batchBoundary` flag, so a multi-round queue drain posts each merged turn as its own message rather than one blob (see [session.md](session.md#message-queue-and-drain))
|
|
118
|
-
4. Errors and system notices flush immediately (don't wait for complete)
|
|
118
|
+
4. Errors (`❌ …`) and system notices (`ℹ️ …`) flush immediately (don't wait for complete)
|
|
119
119
|
5. Sub-agent events (taskId set) are dropped (visible in web UI only)
|
|
120
120
|
|
|
121
121
|
**Chunk sends are serialized per responder.** One `flushBuffer` can produce
|
|
@@ -125,7 +125,9 @@ previous one, so Slack could accept them out of order and a long reply arrived
|
|
|
125
125
|
scrambled (audit A-L3). Each responder now keeps a `sendTail` promise chain and
|
|
126
126
|
appends every chunk to it, so chunk *n+1* only goes out after *n* settles; a
|
|
127
127
|
rejected link is absorbed (`dispatchChunk` logs its own send failures) so one
|
|
128
|
-
bad send can't stall the rest of the reply.
|
|
128
|
+
bad send can't stall the rest of the reply. The buffer + chain live in the
|
|
129
|
+
shared `ChunkedResponder` (`channels/shared/responder.ts`), which slack / feishu
|
|
130
|
+
/ wecom / wechat / telegram extend with their own limit and formatter.
|
|
129
131
|
|
|
130
132
|
`close()` therefore returns that drain promise, and `InboundBridge` keeps the
|
|
131
133
|
session's reply route registered until it settles before deleting it — including
|
|
@@ -138,20 +140,20 @@ dropped.
|
|
|
138
140
|
|
|
139
141
|
### DM sessions
|
|
140
142
|
|
|
141
|
-
DM channels have a stable channel_id (`D…`). A message in a DM stays in the same session regardless of turn count. Session
|
|
143
|
+
DM channels have a stable channel_id (`D…`). A message in a DM stays in the same session regardless of turn count. Session prefix: `slack_${channelId}:dm_`.
|
|
142
144
|
|
|
143
145
|
### Channel / group / thread sessions
|
|
144
146
|
|
|
145
|
-
A mention on a top-level message starts a thread and creates a session keyed by that message's `ts`. Replies inside that thread reuse the same `thread_ts`, so they map to the same session and preserve context. Session
|
|
147
|
+
A mention on a top-level message starts a thread and creates a session keyed by that message's `ts`. Replies inside that thread reuse the same `thread_ts`, so they map to the same session and preserve context. Session prefix: `slack_${channelId}:${rootTs}_`.
|
|
146
148
|
|
|
147
149
|
### Session resolution
|
|
148
150
|
|
|
149
|
-
|
|
150
|
-
1. Build session prefix
|
|
151
|
-
2.
|
|
152
|
-
3.
|
|
153
|
-
|
|
154
|
-
|
|
151
|
+
`handleInbound()` hands the message to `deliverInbound` (`channels/shared/inbound.ts`), which calls `getOrCreateChannelSession`:
|
|
152
|
+
1. Build the session prefix with `buildSessionPrefixForThread(channelId, rootTs)` (see above; `rootTs` is `dm` for DMs); the override key is `${userId}@${channelId}:${rootTs}`
|
|
153
|
+
2. `findActiveSessionId` (`channels/shared/commands.ts`): honour the per-user activeOverrides entry (set by `!session switch` in DMs), else the latest session under the prefix
|
|
154
|
+
3. If none found, create one with ID `${prefix}${Date.now().toString(36)}` on the workspace's default agent and store it in activeOverrides (`setOverrideOnCreate`)
|
|
155
|
+
|
|
156
|
+
**Reply route after a restart.** Routes and listeners are in-memory, so `startAccount` calls `restoreReplyRoute()`: it reads `lastActiveChatId` (`<channelId>:<rootTs>`) and re-wires the latest existing session under that prefix via `restoreChannelRoute` (no session is created), so a turn that resumes on its own after a restart still reaches Slack. Only that one conversation per account is restored; other threads re-wire on their next inbound message.
|
|
155
157
|
|
|
156
158
|
## Cron-dispatcher role
|
|
157
159
|
|
|
@@ -203,7 +205,7 @@ Text, images (base64 attached to agent input for vision), files. All files are d
|
|
|
203
205
|
|
|
204
206
|
The agent runtime produces `MEDIA: <path>` markers. SlackResponder:
|
|
205
207
|
- Intercepts the markers and calls `uploadFile()` for each one
|
|
206
|
-
- Blocks sandbox violation for non-`full` accounts (files must be under workspace or /tmp — `isMediaPathAllowed(path, workspace, accessLevel)`; `full` accounts may send any readable path). A block throws inside the same try as the upload, so it surfaces as the `handler.upload_failed` text below
|
|
208
|
+
- Blocks sandbox violation for non-`full` accounts (files must be under workspace or /tmp — `isMediaPathAllowed(path, workspace, accessLevel)`; `full` accounts may send any readable path). A block throws inside the same try as the upload (`sendMediaOrReport` in `channels/shared/media.ts`), so it surfaces as the `handler.upload_failed` text below
|
|
207
209
|
- Falls back to error text if upload fails
|
|
208
210
|
|
|
209
211
|
Outbound media uses Slack's v2 upload flow (getUploadURLExternal → signed PUT → completeUploadExternal).
|
|
@@ -218,9 +220,9 @@ Not supported: interactive messages (blocks/buttons/callbacks), slash commands i
|
|
|
218
220
|
|
|
219
221
|
Slack-specific paths:
|
|
220
222
|
|
|
221
|
-
- `packages/server/src/channels/slack/handler.ts
|
|
222
|
-
- `packages/server/src/channels/slack/handler.ts
|
|
223
|
-
- `packages/server/src/channels/slack/handler.ts
|
|
224
|
-
- `packages/server/src/channels/slack/api.ts
|
|
225
|
-
- `packages/server/src/channels/slack/cron-dispatcher.ts
|
|
226
|
-
- `packages/server/src/channels/
|
|
223
|
+
- `packages/server/src/channels/slack/handler.ts` `connect()` (inside `startSlackChannel()`) — Socket Mode setup
|
|
224
|
+
- `packages/server/src/channels/slack/handler.ts` `handleInbound()` — event dispatch
|
|
225
|
+
- `packages/server/src/channels/slack/handler.ts` `buildSessionPrefixForThread()` + `packages/server/src/channels/shared/inbound.ts` `deliverInbound()` / `getOrCreateChannelSession()` — session keying + resolution
|
|
226
|
+
- `packages/server/src/channels/slack/api.ts` `openSocketModeConnection()` — fetch wss:// URL
|
|
227
|
+
- `packages/server/src/channels/slack/cron-dispatcher.ts` `dispatch()` — cron push logic
|
|
228
|
+
- `packages/server/src/channels/shared/responder.ts` — `ChunkedResponder.dispatchChunk()` buffer flush + media extraction (`slack/event-adapter.ts` supplies the 35k limit + `formatForSlack`)
|
|
@@ -11,8 +11,8 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
11
11
|
│ ├── USER.md # User profile (bootstrap-generated)
|
|
12
12
|
│ ├── prompts/ # User-editable system prompts (externalised)
|
|
13
13
|
│ │ ├── bootstrap/BOOTSTRAP.md # First-run guidance
|
|
14
|
-
│ │ ├── all/ # Every agent (TOOL_GUIDELINES.md, TOOL_SHELL[.windows].md)
|
|
15
|
-
│ │ └── root/ # Root agent only
|
|
14
|
+
│ │ ├── all/ # Every agent (TOOL_GUIDELINES.md, TOOL_SHELL[.windows].md, WORKSPACE_CONVENTIONS.md, RUNTIME.md)
|
|
15
|
+
│ │ └── root/ # Root agent only (DELEGATION.md, WORKSPACE_MEMORY.md)
|
|
16
16
|
│ ├── agents/<id>/
|
|
17
17
|
│ │ ├── agent.yaml # Agent config
|
|
18
18
|
│ │ └── AGENT.md # Agent personality
|
|
@@ -26,7 +26,8 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
26
26
|
│ ├── evo.db # Cross-workspace evolution queue (evolution_runs + evolution_applies)
|
|
27
27
|
│ ├── cron.db # Cross-workspace cron jobs + run history (cron_jobs + cron_runs)
|
|
28
28
|
│ ├── runs.db # Run ledger: (workspace, session_id) rows for server-driven runs in flight; steady state empty (running_sessions)
|
|
29
|
-
│ ├──
|
|
29
|
+
│ ├── extensions/<id>/ # Installed Canvas preview extensions (halo-extension.json + bundle)
|
|
30
|
+
│ ├── logs/ # Runtime logs (server.log)
|
|
30
31
|
│ │ ├── evo/ # Per-evo-run wrapper logs
|
|
31
32
|
│ │ └── cron/ # Per-cron-run cli stdout/stderr (30-day retention)
|
|
32
33
|
│ ├── server.lock # Single-instance pid lock
|
|
@@ -62,7 +63,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
62
63
|
├── tmp/ # Agent scratch files (logs, downloads, intermediate artifacts) — convention from TOOL_GUIDELINES
|
|
63
64
|
├── assets/<channel>/inbound/<accountId>/<date>/ # Inbound media per channel (image/voice/video/file)
|
|
64
65
|
├── runtime.lock # Workspace runtime ownership marker (pid) — see below
|
|
65
|
-
├── halo.db # Per-workspace sqlite (sessions metadata,
|
|
66
|
+
├── halo.db # Per-workspace sqlite (sessions metadata, disabled-items)
|
|
66
67
|
└── docs/ # Project docs (requirements/design/dev/test/plans)
|
|
67
68
|
```
|
|
68
69
|
|
|
@@ -96,6 +97,8 @@ Protocol (single-machine scope; NFS / cross-host explicitly out):
|
|
|
96
97
|
|
|
97
98
|
Bias is deliberate: pid reuse can read a stale pid as "alive," which only costs one missed cleanup round; a false-dead could stop another process's live sessions. Full race analysis in the file's header comment.
|
|
98
99
|
|
|
100
|
+
**`HALO_BADGE=DEV` servers never claim.** dev and prod serve the same workspaces, so prod (any other badge, or none) owns the lock outright: a DEV server (`config.server.ownsWorkspaceRuntimes === false`) builds its registry with `reconcileOrphansOnBoot: false`, skips the run-ledger eager sweep, and has channel route restore build the SessionManager directly. It never creates, rewrites or takes over `runtime.lock`, and runs no boot cleanup. Two costs: orphans left by a dev crash stay until prod's next restart cleans them, and a prod restart stamps the sub-sessions dev is running in a shared workspace stopped. That is what prod ownership means.
|
|
101
|
+
|
|
99
102
|
## Session file format (v1)
|
|
100
103
|
|
|
101
104
|
Path: `.halo/sessions/{agentId}/{sessionId}.json`
|
|
@@ -148,11 +151,11 @@ type MessageType =
|
|
|
148
151
|
interface SessionMessage {
|
|
149
152
|
// Required
|
|
150
153
|
id: string // "m_{timestamp36}_{counter36}"
|
|
151
|
-
type
|
|
154
|
+
type?: MessageType // optional for backward compat (inferMessageType)
|
|
152
155
|
role: 'user' | 'assistant' | 'system'
|
|
153
156
|
content: string
|
|
154
157
|
timestamp: number // Unix ms
|
|
155
|
-
agentName
|
|
158
|
+
agentName?: string
|
|
156
159
|
|
|
157
160
|
// Optional scope
|
|
158
161
|
taskId?: string // Sub-agent task ID
|
|
@@ -181,6 +184,9 @@ interface SessionMessage {
|
|
|
181
184
|
|
|
182
185
|
// Transient (not persisted)
|
|
183
186
|
streaming?: boolean
|
|
187
|
+
|
|
188
|
+
// Soft delete (deleteExchange) — greyed out in the UI, removed from raw context
|
|
189
|
+
deleted?: boolean
|
|
184
190
|
}
|
|
185
191
|
```
|
|
186
192
|
|
|
@@ -287,12 +293,15 @@ interface AgentYamlConfig {
|
|
|
287
293
|
maxTokens?: number
|
|
288
294
|
promptCaching?: boolean | string
|
|
289
295
|
thinking?: { enabled?: boolean; budget?: string; effort?: string }
|
|
296
|
+
verbosity?: string
|
|
290
297
|
}
|
|
291
298
|
system_prompt?: string
|
|
292
299
|
tools?: string[]
|
|
293
300
|
skills?: string[]
|
|
294
301
|
context?: { maxTokens?: number; compressAt?: number }
|
|
295
302
|
priority?: number
|
|
303
|
+
internal?: boolean // platform tooling — off the roster, no workspace context
|
|
304
|
+
team?: string[] // delegation whitelist; non-empty = can delegate
|
|
296
305
|
}
|
|
297
306
|
```
|
|
298
307
|
|
|
@@ -335,8 +344,8 @@ general: # built-in declarer (the server itself
|
|
|
335
344
|
max_summary_input: 15000
|
|
336
345
|
max_message_slice: 800
|
|
337
346
|
sandbox:
|
|
338
|
-
hidden_dirs: "~/.
|
|
339
|
-
hidden_files: "~/.
|
|
347
|
+
hidden_dirs: "~/.kube" # extras, appended to the built-in list
|
|
348
|
+
hidden_files: "~/.pgpass" # extras, appended to the built-in list
|
|
340
349
|
logging:
|
|
341
350
|
level: warn
|
|
342
351
|
|
|
@@ -378,21 +387,20 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
|
|
|
378
387
|
|
|
379
388
|
| Path | Default | Notes |
|
|
380
389
|
|---|---|---|
|
|
381
|
-
| `general.session.max_queue_size` | 256 | Max queued messages per session |
|
|
382
|
-
| `general.session.max_nesting_depth` | 16 | Sub-session nesting cap |
|
|
383
|
-
| `general.compact.keep_messages` | 5 | Recent messages kept intact during compaction |
|
|
384
|
-
| `general.compact.max_summary_input` | 15000 | Local truncation fallback total char cap |
|
|
385
|
-
| `general.compact.max_message_slice` | 800 | Local truncation per-message char cap |
|
|
390
|
+
| `general.session.max_queue_size` | 256 | Max queued messages per session. Restart required |
|
|
391
|
+
| `general.session.max_nesting_depth` | 16 | Sub-session nesting cap. Restart required |
|
|
392
|
+
| `general.compact.keep_messages` | 5 | Recent messages kept intact during compaction. Restart required |
|
|
393
|
+
| `general.compact.max_summary_input` | 15000 | Local truncation fallback total char cap. Restart required |
|
|
394
|
+
| `general.compact.max_message_slice` | 800 | Local truncation per-message char cap. Restart required |
|
|
386
395
|
| `general.limits.shell_output_bytes` | 5242880 | Max bytes captured from one `shell_exec` (stdout+stderr); excess truncated with a `[truncated]` marker |
|
|
387
396
|
| `general.limits.web_fetch_bytes` | 51200 | Max bytes downloaded by one `web_fetch` |
|
|
388
397
|
| `general.limits.grep_default_matches` | 50 | Default `grep` match cap when no explicit `max` is passed |
|
|
389
398
|
| `general.limits.tool_result_render_chars` | 8000 | Per-tool-result cap on the content **fed to the LLM** (truncated with a re-run marker to protect the context window / prompt cache). The UI gets a much larger slice — see `tool_result_ui_chars` and `agent-loop.ts` (`resultContent` = LLM cap, `resultTextFull` = UI cap) |
|
|
390
399
|
| `general.limits.tool_result_ui_chars` | 65536 | Per-tool-result cap on the content **stored for UI display** (admin/web chat panel). Far larger than the LLM cap so a normal command's full output stays visible, but bounded so a multi-MB `cat` can't bloat the session file / WS payload / browser render; excess truncated with a marker pointing at `file_read` |
|
|
391
|
-
| `general.limits.ws_event_buffer` | 5000 | Events buffered per detached WS session before oldest are dropped on reattach |
|
|
392
400
|
| `general.limits.terminal_scrollback_bytes` | 50000 | Off-screen scrollback bytes retained per detached persistent terminal |
|
|
393
|
-
| `general.sandbox.hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs`
|
|
394
|
-
| `general.sandbox.hidden_files` |
|
|
395
|
-
| `general.logging.level` | warn | `debug` / `info` / `warn` / `error
|
|
401
|
+
| `general.sandbox.hidden_dirs` | `''` | Extra dirs hidden from workspace/readonly sessions — bwrap tmpfs overlay (Linux) / Seatbelt deny (macOS). **Appended** to the built-in `DEFAULT_HIDDEN_DIRS` (`tools/sandbox.ts`: `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs`), which are always included. Full sessions, the admin file explorer and the terminal are not affected |
|
|
402
|
+
| `general.sandbox.hidden_files` | `''` | Extra files hidden from workspace/readonly sessions — bwrap bind of the empty `~/.halo/.sandbox-empty`, reads as empty (Linux) / Seatbelt deny (macOS). **Appended** to the built-in `DEFAULT_HIDDEN_FILES` (`~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + `-wal` / `-shm`), which are always included. Full sessions, the admin file explorer and the terminal are not affected |
|
|
403
|
+
| `general.logging.level` | warn | `debug` / `info` / `warn` / `error`. Restart required |
|
|
396
404
|
| `general.observability.endpoint` | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`); empty = off. Restart required. See [observability.md](observability.md) |
|
|
397
405
|
| `general.observability.service_name` | `halo` | OTel resource `service.name`. Restart required |
|
|
398
406
|
| `general.observability.headers` | `''` | **Secret.** Extra OTLP request headers, comma-separated `k=v`. Restart required |
|
|
@@ -402,7 +410,7 @@ Schema source: [packages/server/src/settings-schema.ts](../../../packages/server
|
|
|
402
410
|
|
|
403
411
|
### Caching
|
|
404
412
|
|
|
405
|
-
`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
|
|
413
|
+
`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.
|
|
406
414
|
|
|
407
415
|
### Orphans
|
|
408
416
|
|
|
@@ -536,6 +544,8 @@ interface SkillMeta {
|
|
|
536
544
|
name: string // from frontmatter
|
|
537
545
|
description: string // from frontmatter
|
|
538
546
|
path: string // absolute path to SKILL.md
|
|
547
|
+
command?: string // from frontmatter
|
|
548
|
+
requiresAccess?: 'full' | 'workspace' | 'readonly' // from frontmatter
|
|
539
549
|
}
|
|
540
550
|
```
|
|
541
551
|
|
|
@@ -552,16 +562,6 @@ The databases hold session metadata indexes and workspace-scoped preferences (e.
|
|
|
552
562
|
|
|
553
563
|
### Tables
|
|
554
564
|
|
|
555
|
-
**`sessions`** — frontend session index (created on first chat)
|
|
556
|
-
|
|
557
|
-
| Column | Type | Notes |
|
|
558
|
-
|---|---|---|
|
|
559
|
-
| id | TEXT PK | Session ID |
|
|
560
|
-
| title | TEXT NOT NULL DEFAULT '' | Truncated first user message |
|
|
561
|
-
| messages | TEXT | Legacy field, unused (content now in JSON) |
|
|
562
|
-
| message_count | INTEGER | Message count |
|
|
563
|
-
| created_at / updated_at | INTEGER | Unix ms |
|
|
564
|
-
|
|
565
565
|
**`agent_sessions`** — agent session index (root + sub-sessions)
|
|
566
566
|
|
|
567
567
|
| Column | Type | Notes |
|
|
@@ -602,7 +602,7 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
|
|
|
602
602
|
| Column | Type | Notes |
|
|
603
603
|
|---|---|---|
|
|
604
604
|
| account_id | TEXT PK | Channel-specific ID (e.g. `halo_agent_bot`, `abc-im-bot`, `e718bb7b`) |
|
|
605
|
-
| channel_type | TEXT NOT NULL | `'telegram'`, `'web'`, `'wechat'`, `'slack'`, or `'
|
|
605
|
+
| channel_type | TEXT NOT NULL | `'telegram'`, `'web'`, `'wechat'`, `'slack'`, `'feishu'`, or `'wecom'` |
|
|
606
606
|
| workspace_path | TEXT NOT NULL | Absolute path of the bound workspace |
|
|
607
607
|
| label | TEXT | User-chosen name |
|
|
608
608
|
| enabled | INTEGER | 1 = active, 0 = disabled |
|
|
@@ -620,6 +620,7 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
|
|
|
620
620
|
| wechat | `botToken`, `baseUrl`, `userId`, `syncBuf` |
|
|
621
621
|
| slack | `botToken`, `appToken`, `botUserId`, `teamId` |
|
|
622
622
|
| feishu | `appId`, `appSecret`, `verificationToken`, `encryptKey`, `botOpenId` |
|
|
623
|
+
| wecom | `botId`, `secret` |
|
|
623
624
|
|
|
624
625
|
### Schema change rules
|
|
625
626
|
|