Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ac6ccd20 | ||
|
|
340a054f73 | ||
|
|
810394f43b | ||
|
|
835162fb62 | ||
|
|
82883095fe | ||
|
|
49d8ad3049 | ||
|
|
1721d04405 | ||
|
|
633e0d9382 | ||
|
|
f06ce98312 | ||
|
|
b546b2a48d | ||
|
|
c11b016d22 | ||
|
|
3686bde783 | ||
|
|
9c06689569 | ||
|
|
891a2cc64a | ||
|
|
01211937fc | ||
|
|
4726580c7e | ||
|
|
e9a08dc507 | ||
|
|
f3698e360b | ||
|
|
c69947dff8 | ||
|
|
173bce34b0 | ||
|
|
6a27e385b1 | ||
|
|
5f0d9c3eb9 | ||
|
|
0e31c8153c | ||
|
|
9c0773c469 | ||
|
|
f5533baf61 | ||
|
|
60bc436e99 | ||
|
|
741b984a68 | ||
|
|
858a5f48d8 | ||
|
|
20c26eb303 | ||
|
|
f3ef609839 | ||
|
|
234fe5b5cd | ||
|
|
65710932ff | ||
|
|
e944f21ec0 | ||
|
|
ee6e534ccb | ||
|
|
11b07f4a29 | ||
|
|
676d41d415 | ||
|
|
a3a4996adb | ||
|
|
b015c7e5ad | ||
|
|
4de3c3a028 | ||
|
|
b739a3897f | ||
|
|
c5e19f5c67 | ||
|
|
9241e21114 | ||
|
|
65bed815a8 | ||
|
|
d776cfb4e1 | ||
|
|
c6e7e1821b | ||
|
|
f76ab69612 | ||
|
|
889db137b8 | ||
|
|
9db682750d | ||
|
|
9809b47d45 | ||
|
|
68d79e56c2 | ||
|
|
d3862ae30a | ||
|
|
e49a2952d9 | ||
|
|
8b57f519c3 | ||
|
|
ddcc05f5f4 | ||
|
|
8c0e290db1 | ||
|
|
7bfc77db25 | ||
|
|
8d74578ceb | ||
|
|
f7123ec30a | ||
|
|
ad4f4388f4 | ||
|
|
2a86504723 | ||
|
|
de3b68740a | ||
|
|
efaa73f543 | ||
|
|
1589c73697 | ||
|
|
359d2af8a8 | ||
|
|
fa897e5dfe | ||
|
|
7fa8ae56cb | ||
|
|
ec27c813cc | ||
|
|
3b24fe639a | ||
|
|
e5cca6e432 | ||
|
|
ae0b4c4990 | ||
|
|
49bd2d96fa | ||
|
|
ca350fc66c | ||
|
|
30338ce1a7 | ||
|
|
faa94f0168 | ||
|
|
f5c84768ff | ||
|
|
df752d4706 | ||
|
|
c9c9516206 | ||
|
|
d3b15c6afa | ||
|
|
ebfeb7a6bf | ||
|
|
b594f5130d | ||
|
|
e2bb5eecf3 |
No files matched your search
@@ -1,4 +0,0 @@
|
|||||||
[submodule "Peekaboo"]
|
|
||||||
path = Peekaboo
|
|
||||||
url = https://github.com/steipete/Peekaboo.git
|
|
||||||
branch = main
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
- Repo: https://github.com/clawdbot/clawdbot
|
- Repo: https://github.com/clawdbot/clawdbot
|
||||||
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
|
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Structure & Module Organization
|
||||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||||
@@ -84,6 +84,7 @@
|
|||||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||||
- Never update the Carbon dependency.
|
- Never update the Carbon dependency.
|
||||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||||
|
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
||||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||||
|
|||||||
+78
-20
@@ -2,25 +2,99 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
|
## 2026.1.17-7
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Exec approvals: add `clawdbot approvals` CLI for viewing and updating gateway/node allowlists.
|
||||||
|
- CLI: add `clawdbot service` gateway/node management and a `clawdbot node status` alias.
|
||||||
|
- Status: show gateway + node service summaries in `clawdbot status` and `status --all`.
|
||||||
|
- Control UI: add gateway/node target selector for exec approvals.
|
||||||
|
- Docs: add approvals/service references and refresh node/control UI docs.
|
||||||
|
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||||
|
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
|
||||||
|
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
|
||||||
|
- Swabble: use the tagged Commander Swift package release.
|
||||||
|
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
||||||
|
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
||||||
|
- Memory: add native Gemini embeddings provider for memory search. (#1151)
|
||||||
|
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
||||||
|
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
|
||||||
|
- Nodes: report core/ui versions in node list + presence; surface both in CLI + macOS UI.
|
||||||
|
<<<<<<< HEAD
|
||||||
|
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
||||||
|
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
|
||||||
|
||||||| parent of 903e9be49 (feat: surface node core/ui versions in macOS)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
||||||
|
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||||
|
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
||||||
|
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
||||||
|
- Voice call: separate TwiML fetches from status callbacks to avoid stuck calls. (#1180) — thanks @andrew-kurin.
|
||||||
|
|
||||||
|
## 2026.1.18-5
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||||
|
|
||||||
## 2026.1.18-3
|
## 2026.1.18-3
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
=======
|
||||||
|
- Nodes: report core/ui versions in node list + presence; surface both in CLI + macOS UI.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
||||||
|
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||||
|
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
||||||
|
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
||||||
|
- Voice call: separate TwiML fetches from status callbacks to avoid stuck calls. (#1180) — thanks @andrew-kurin.
|
||||||
|
|
||||||
|
## 2026.1.18-5
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||||
|
|
||||||
|
## 2026.1.18-3
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
>>>>>>> 903e9be49 (feat: surface node core/ui versions in macOS)
|
||||||
- Exec: add host/security/ask routing for gateway + node exec.
|
- Exec: add host/security/ask routing for gateway + node exec.
|
||||||
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
||||||
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
||||||
- macOS: add approvals socket UI server + node exec lifecycle events.
|
- macOS: add approvals socket UI server + node exec lifecycle events.
|
||||||
|
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
|
||||||
|
- Nodes: add node daemon service install/status/start/stop/restart.
|
||||||
|
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||||
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
||||||
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
||||||
|
- Agents: auto-inject local image references for vision models and avoid reloading history images. (#1098) — thanks @tyler6204.
|
||||||
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
||||||
|
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
|
||||||
|
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
|
||||||
|
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||||
|
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||||
|
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||||
|
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||||
|
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||||
|
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||||
|
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
||||||
|
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
|
||||||
|
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||||
|
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
||||||
|
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
||||||
|
- Skills: improve remote bin probe logging with node labels + connectivity hints.
|
||||||
|
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
||||||
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||||
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
||||||
|
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
|
||||||
## 2026.1.18-2
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
||||||
|
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||||
|
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||||
|
- Discord: only emit slow listener warnings after 30s.
|
||||||
|
|
||||||
## 2026.1.17-6
|
## 2026.1.17-6
|
||||||
|
|
||||||
@@ -48,22 +122,6 @@ Docs: https://docs.clawd.bot
|
|||||||
### Fixes
|
### Fixes
|
||||||
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
||||||
|
|
||||||
## 2026.1.18-1
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
|
||||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
|
||||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
|
||||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
|
||||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
|
||||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
|
||||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
|
||||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
|
||||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
|
||||||
- Discord: only emit slow listener warnings after 30s.
|
|
||||||
## 2026.1.17-3
|
## 2026.1.17-3
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
-1
Submodule Peekaboo deleted from 5c195f5e46.
@@ -13,7 +13,7 @@ let package = Package(
|
|||||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(path: "../Peekaboo/Commander"),
|
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
|
||||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
|
|||||||
+28
-73
@@ -1,6 +1,24 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
|
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "axorcist",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/steipete/AXorcist.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "c75d06f7f93e264a9786edc2b78c04973061cb2f",
|
||||||
|
"version" : "0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "commander",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/steipete/Commander.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
|
||||||
|
"version" : "0.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "elevenlabskit",
|
"identity" : "elevenlabskit",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -10,15 +28,6 @@
|
|||||||
"version" : "0.1.0"
|
"version" : "0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "eventsource",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/mattt/eventsource.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
|
|
||||||
"version" : "1.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "menubarextraaccess",
|
"identity" : "menubarextraaccess",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -28,6 +37,15 @@
|
|||||||
"version" : "1.2.2"
|
"version" : "1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "peekaboo",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/steipete/Peekaboo.git",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "main",
|
||||||
|
"revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "sparkle",
|
"identity" : "sparkle",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -46,33 +64,6 @@
|
|||||||
"version" : "1.2.1"
|
"version" : "1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "swift-asn1",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-asn1.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
|
|
||||||
"version" : "1.5.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-async-algorithms",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
|
|
||||||
"version" : "1.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-collections",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-collections",
|
|
||||||
"state" : {
|
|
||||||
"branch" : "main",
|
|
||||||
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "swift-concurrency-extras",
|
"identity" : "swift-concurrency-extras",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -82,24 +73,6 @@
|
|||||||
"version" : "1.3.2"
|
"version" : "1.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "swift-configuration",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-configuration",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749",
|
|
||||||
"version" : "1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-crypto",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-crypto.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
|
|
||||||
"version" : "4.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "swift-log",
|
"identity" : "swift-log",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -118,24 +91,6 @@
|
|||||||
"version" : "1.1.1"
|
"version" : "1.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "swift-sdk",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/modelcontextprotocol/swift-sdk.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "c0407a0b52677cb395d824cac2879b963075ba8c",
|
|
||||||
"version" : "0.10.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-service-lifecycle",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/swift-server/swift-service-lifecycle",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
|
|
||||||
"version" : "2.9.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "swift-subprocess",
|
"identity" : "swift-subprocess",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -20,10 +20,9 @@ let package = Package(
|
|||||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
||||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"),
|
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"),
|
||||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
||||||
|
.package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"),
|
||||||
.package(path: "../shared/ClawdbotKit"),
|
.package(path: "../shared/ClawdbotKit"),
|
||||||
.package(path: "../../Swabble"),
|
.package(path: "../../Swabble"),
|
||||||
.package(path: "../../Peekaboo/Core/PeekabooCore"),
|
|
||||||
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
|
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
@@ -61,8 +60,8 @@ let package = Package(
|
|||||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||||
.product(name: "Logging", package: "swift-log"),
|
.product(name: "Logging", package: "swift-log"),
|
||||||
.product(name: "Sparkle", package: "Sparkle"),
|
.product(name: "Sparkle", package: "Sparkle"),
|
||||||
.product(name: "PeekabooBridge", package: "PeekabooCore"),
|
.product(name: "PeekabooBridge", package: "Peekaboo"),
|
||||||
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
.product(name: "PeekabooAutomationKit", package: "Peekaboo"),
|
||||||
],
|
],
|
||||||
exclude: [
|
exclude: [
|
||||||
"Resources/Info.plist",
|
"Resources/Info.plist",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ struct BridgeNodeInfo: Sendable {
|
|||||||
var displayName: String?
|
var displayName: String?
|
||||||
var platform: String?
|
var platform: String?
|
||||||
var version: String?
|
var version: String?
|
||||||
|
var coreVersion: String?
|
||||||
|
var uiVersion: String?
|
||||||
var deviceFamily: String?
|
var deviceFamily: String?
|
||||||
var modelIdentifier: String?
|
var modelIdentifier: String?
|
||||||
var remoteAddress: String?
|
var remoteAddress: String?
|
||||||
@@ -147,6 +149,8 @@ actor BridgeConnectionHandler {
|
|||||||
displayName: hello.displayName,
|
displayName: hello.displayName,
|
||||||
platform: hello.platform,
|
platform: hello.platform,
|
||||||
version: hello.version,
|
version: hello.version,
|
||||||
|
coreVersion: hello.coreVersion,
|
||||||
|
uiVersion: hello.uiVersion,
|
||||||
deviceFamily: hello.deviceFamily,
|
deviceFamily: hello.deviceFamily,
|
||||||
modelIdentifier: hello.modelIdentifier,
|
modelIdentifier: hello.modelIdentifier,
|
||||||
remoteAddress: self.remoteAddressString(),
|
remoteAddress: self.remoteAddressString(),
|
||||||
@@ -171,6 +175,8 @@ actor BridgeConnectionHandler {
|
|||||||
displayName: req.displayName,
|
displayName: req.displayName,
|
||||||
platform: req.platform,
|
platform: req.platform,
|
||||||
version: req.version,
|
version: req.version,
|
||||||
|
coreVersion: req.coreVersion,
|
||||||
|
uiVersion: req.uiVersion,
|
||||||
deviceFamily: req.deviceFamily,
|
deviceFamily: req.deviceFamily,
|
||||||
modelIdentifier: req.modelIdentifier,
|
modelIdentifier: req.modelIdentifier,
|
||||||
caps: req.caps,
|
caps: req.caps,
|
||||||
@@ -186,6 +192,8 @@ actor BridgeConnectionHandler {
|
|||||||
displayName: enriched.displayName,
|
displayName: enriched.displayName,
|
||||||
platform: enriched.platform,
|
platform: enriched.platform,
|
||||||
version: enriched.version,
|
version: enriched.version,
|
||||||
|
coreVersion: enriched.coreVersion,
|
||||||
|
uiVersion: enriched.uiVersion,
|
||||||
deviceFamily: enriched.deviceFamily,
|
deviceFamily: enriched.deviceFamily,
|
||||||
modelIdentifier: enriched.modelIdentifier,
|
modelIdentifier: enriched.modelIdentifier,
|
||||||
remoteAddress: enriched.remoteAddress,
|
remoteAddress: enriched.remoteAddress,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import Security
|
import Security
|
||||||
@@ -121,6 +122,13 @@ struct ExecApprovalsFile: Codable {
|
|||||||
var agents: [String: ExecApprovalsAgent]?
|
var agents: [String: ExecApprovalsAgent]?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsSnapshot: Codable {
|
||||||
|
var path: String
|
||||||
|
var exists: Bool
|
||||||
|
var hash: String
|
||||||
|
var file: ExecApprovalsFile
|
||||||
|
}
|
||||||
|
|
||||||
struct ExecApprovalsResolved {
|
struct ExecApprovalsResolved {
|
||||||
let url: URL
|
let url: URL
|
||||||
let socketPath: String
|
let socketPath: String
|
||||||
@@ -153,6 +161,58 @@ enum ExecApprovalsStore {
|
|||||||
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
|
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||||
|
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return ExecApprovalsFile(
|
||||||
|
version: 1,
|
||||||
|
socket: ExecApprovalsSocketConfig(
|
||||||
|
path: socketPath.isEmpty ? nil : socketPath,
|
||||||
|
token: token.isEmpty ? nil : token),
|
||||||
|
defaults: file.defaults,
|
||||||
|
agents: file.agents)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||||
|
let url = self.fileURL()
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
return ExecApprovalsSnapshot(
|
||||||
|
path: url.path,
|
||||||
|
exists: false,
|
||||||
|
hash: self.hashRaw(nil),
|
||||||
|
file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]))
|
||||||
|
}
|
||||||
|
let raw = try? String(contentsOf: url, encoding: .utf8)
|
||||||
|
let data = raw.flatMap { $0.data(using: .utf8) }
|
||||||
|
let decoded: ExecApprovalsFile = {
|
||||||
|
if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 {
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
|
}()
|
||||||
|
return ExecApprovalsSnapshot(
|
||||||
|
path: url.path,
|
||||||
|
exists: true,
|
||||||
|
hash: self.hashRaw(raw),
|
||||||
|
file: decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||||
|
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if socketPath.isEmpty {
|
||||||
|
return ExecApprovalsFile(
|
||||||
|
version: file.version,
|
||||||
|
socket: nil,
|
||||||
|
defaults: file.defaults,
|
||||||
|
agents: file.agents)
|
||||||
|
}
|
||||||
|
return ExecApprovalsFile(
|
||||||
|
version: file.version,
|
||||||
|
socket: ExecApprovalsSocketConfig(path: socketPath, token: nil),
|
||||||
|
defaults: file.defaults,
|
||||||
|
agents: file.agents)
|
||||||
|
}
|
||||||
|
|
||||||
static func loadFile() -> ExecApprovalsFile {
|
static func loadFile() -> ExecApprovalsFile {
|
||||||
let url = self.fileURL()
|
let url = self.fileURL()
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
@@ -162,7 +222,7 @@ enum ExecApprovalsStore {
|
|||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
||||||
if decoded.version != 1 {
|
if decoded.version != 1 {
|
||||||
return ExecApprovalsFile(version: 1, socket: decoded.socket, defaults: decoded.defaults, agents: decoded.agents)
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
}
|
}
|
||||||
return decoded
|
return decoded
|
||||||
} catch {
|
} catch {
|
||||||
@@ -204,7 +264,7 @@ enum ExecApprovalsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func resolve(agentId: String?) -> ExecApprovalsResolved {
|
static func resolve(agentId: String?) -> ExecApprovalsResolved {
|
||||||
var file = self.ensureFile()
|
let file = self.ensureFile()
|
||||||
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||||
let resolvedDefaults = ExecApprovalsResolvedDefaults(
|
let resolvedDefaults = ExecApprovalsResolvedDefaults(
|
||||||
security: defaults.security ?? self.defaultSecurity,
|
security: defaults.security ?? self.defaultSecurity,
|
||||||
@@ -372,6 +432,12 @@ enum ExecApprovalsStore {
|
|||||||
return UUID().uuidString
|
return UUID().uuidString
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func hashRaw(_ raw: String?) -> String {
|
||||||
|
let data = Data((raw ?? "").utf8)
|
||||||
|
let digest = SHA256.hash(data: data)
|
||||||
|
return digest.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
private static func expandPath(_ raw: String) -> String {
|
private static func expandPath(_ raw: String) -> String {
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed == "~" {
|
if trimmed == "~" {
|
||||||
@@ -397,11 +463,32 @@ struct ExecCommandResolution: Sendable {
|
|||||||
let executableName: String
|
let executableName: String
|
||||||
let cwd: String?
|
let cwd: String?
|
||||||
|
|
||||||
|
static func resolve(
|
||||||
|
command: [String],
|
||||||
|
rawCommand: String?,
|
||||||
|
cwd: String?,
|
||||||
|
env: [String: String]?
|
||||||
|
) -> ExecCommandResolution? {
|
||||||
|
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
|
||||||
|
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
|
||||||
|
}
|
||||||
|
return self.resolve(command: command, cwd: cwd, env: env)
|
||||||
|
}
|
||||||
|
|
||||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||||
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw
|
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveExecutable(
|
||||||
|
rawExecutable: String,
|
||||||
|
cwd: String?,
|
||||||
|
env: [String: String]?
|
||||||
|
) -> ExecCommandResolution? {
|
||||||
|
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
|
||||||
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
|
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
|
||||||
let resolvedPath: String? = {
|
let resolvedPath: String? = {
|
||||||
if hasPathSeparator {
|
if hasPathSeparator {
|
||||||
@@ -419,6 +506,20 @@ struct ExecCommandResolution: Sendable {
|
|||||||
return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
|
return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func parseFirstToken(_ command: String) -> String? {
|
||||||
|
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
guard let first = trimmed.first else { return nil }
|
||||||
|
if first == "\"" || first == "'" {
|
||||||
|
let rest = trimmed.dropFirst()
|
||||||
|
if let end = rest.firstIndex(of: first) {
|
||||||
|
return String(rest[..<end])
|
||||||
|
}
|
||||||
|
return String(rest)
|
||||||
|
}
|
||||||
|
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
||||||
|
}
|
||||||
|
|
||||||
private static func searchPaths(from env: [String: String]?) -> [String] {
|
private static func searchPaths(from env: [String: String]?) -> [String] {
|
||||||
let raw = env?["PATH"]
|
let raw = env?["PATH"]
|
||||||
if let raw, !raw.isEmpty {
|
if let raw, !raw.isEmpty {
|
||||||
@@ -439,6 +540,12 @@ enum ExecCommandFormatter {
|
|||||||
return "\"\(escaped)\""
|
return "\"\(escaped)\""
|
||||||
}.joined(separator: " ")
|
}.joined(separator: " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func displayString(for argv: [String], rawCommand: String?) -> String {
|
||||||
|
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmed.isEmpty { return trimmed }
|
||||||
|
return self.displayString(for: argv)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ExecAllowlistMatcher {
|
enum ExecAllowlistMatcher {
|
||||||
@@ -522,7 +629,7 @@ struct ExecEventPayload: Codable, Sendable {
|
|||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
if trimmed.count <= maxChars { return trimmed }
|
if trimmed.count <= maxChars { return trimmed }
|
||||||
let suffix = trimmed.suffix(maxChars)
|
let suffix = trimmed.suffix(maxChars)
|
||||||
return "… (truncated) \(suffix)"
|
return "... (truncated) \(suffix)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,9 +157,10 @@ final class ExecApprovalsPromptServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum ExecApprovalsPromptPresenter {
|
enum ExecApprovalsPromptPresenter {
|
||||||
@MainActor
|
@MainActor
|
||||||
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
|
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.alertStyle = .warning
|
alert.alertStyle = .warning
|
||||||
alert.messageText = "Allow this command?"
|
alert.messageText = "Allow this command?"
|
||||||
@@ -205,7 +206,7 @@ private enum ExecApprovalsPromptPresenter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class ExecApprovalsSocketServer {
|
private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
|
||||||
private let socketPath: String
|
private let socketPath: String
|
||||||
private let token: String
|
private let token: String
|
||||||
|
|||||||
@@ -249,6 +249,13 @@ actor GatewayConnection {
|
|||||||
return trimmed.isEmpty ? nil : trimmed
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cachedGatewayVersion() -> String? {
|
||||||
|
guard let snapshot = self.lastSnapshot else { return nil }
|
||||||
|
let raw = snapshot.server["version"]?.value as? String
|
||||||
|
let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
|
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
|
||||||
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
||||||
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ enum GatewayLaunchAgentManager {
|
|||||||
|
|
||||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||||
_ = bundlePath
|
_ = bundlePath
|
||||||
|
guard !CommandResolver.connectionModeIsRemote() else {
|
||||||
|
self.logger.info("launchd change skipped (remote mode)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if enabled, self.isLaunchAgentWriteDisabled() {
|
if enabled, self.isLaunchAgentWriteDisabled() {
|
||||||
self.logger.info("launchd enable skipped (disable marker set)")
|
self.logger.info("launchd enable skipped (disable marker set)")
|
||||||
return nil
|
return nil
|
||||||
@@ -112,7 +116,9 @@ extension GatewayLaunchAgentManager {
|
|||||||
{
|
{
|
||||||
let command = CommandResolver.clawdbotCommand(
|
let command = CommandResolver.clawdbotCommand(
|
||||||
subcommand: "daemon",
|
subcommand: "daemon",
|
||||||
extraArgs: self.withJsonFlag(args))
|
extraArgs: self.withJsonFlag(args),
|
||||||
|
// Launchd management must always run locally, even if remote mode is configured.
|
||||||
|
configRoot: ["gateway": ["mode": "local"]])
|
||||||
var env = ProcessInfo.processInfo.environment
|
var env = ProcessInfo.processInfo.environment
|
||||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ final class GatewayProcessManager {
|
|||||||
self.lastFailureReason = nil
|
self.lastFailureReason = nil
|
||||||
self.status = .stopped
|
self.status = .stopped
|
||||||
self.logger.info("gateway stop requested")
|
self.logger.info("gateway stop requested")
|
||||||
|
if CommandResolver.connectionModeIsRemote() {
|
||||||
|
return
|
||||||
|
}
|
||||||
let bundlePath = Bundle.main.bundleURL.path
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
Task {
|
Task {
|
||||||
_ = await GatewayLaunchAgentManager.set(
|
_ = await GatewayLaunchAgentManager.set(
|
||||||
|
|||||||
@@ -747,8 +747,8 @@ extension MenuSessionsInjector {
|
|||||||
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let version = entry.version?.nonEmpty {
|
if let version = NodeMenuEntryFormatter.detailRightVersion(entry)?.nonEmpty {
|
||||||
menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version)))
|
menu.addItem(self.makeNodeCopyItem(label: "Version", value: version))
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
|
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ actor MacNodeBridgePairingClient {
|
|||||||
displayName: hello.displayName,
|
displayName: hello.displayName,
|
||||||
platform: hello.platform,
|
platform: hello.platform,
|
||||||
version: hello.version,
|
version: hello.version,
|
||||||
|
coreVersion: hello.coreVersion,
|
||||||
|
uiVersion: hello.uiVersion,
|
||||||
deviceFamily: hello.deviceFamily,
|
deviceFamily: hello.deviceFamily,
|
||||||
modelIdentifier: hello.modelIdentifier,
|
modelIdentifier: hello.modelIdentifier,
|
||||||
caps: hello.caps,
|
caps: hello.caps,
|
||||||
|
|||||||
@@ -114,12 +114,19 @@ final class MacNodeModeCoordinator {
|
|||||||
let caps = self.currentCaps()
|
let caps = self.currentCaps()
|
||||||
let commands = self.currentCommands(caps: caps)
|
let commands = self.currentCommands(caps: caps)
|
||||||
let permissions = await self.currentPermissions()
|
let permissions = await self.currentPermissions()
|
||||||
|
let uiVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
||||||
|
let liveGatewayVersion = await GatewayConnection.shared.cachedGatewayVersion()
|
||||||
|
let fallbackGatewayVersion = GatewayProcessManager.shared.environmentStatus.gatewayVersion
|
||||||
|
let coreVersion = (liveGatewayVersion ?? fallbackGatewayVersion)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return BridgeHello(
|
return BridgeHello(
|
||||||
nodeId: Self.nodeId(),
|
nodeId: Self.nodeId(),
|
||||||
displayName: InstanceIdentity.displayName,
|
displayName: InstanceIdentity.displayName,
|
||||||
token: token,
|
token: token,
|
||||||
platform: "macos",
|
platform: "macos",
|
||||||
version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
|
version: uiVersion,
|
||||||
|
coreVersion: coreVersion?.isEmpty == false ? coreVersion : nil,
|
||||||
|
uiVersion: uiVersion,
|
||||||
deviceFamily: "Mac",
|
deviceFamily: "Mac",
|
||||||
modelIdentifier: InstanceIdentity.modelIdentifier,
|
modelIdentifier: InstanceIdentity.modelIdentifier,
|
||||||
caps: caps,
|
caps: caps,
|
||||||
@@ -158,6 +165,8 @@ final class MacNodeModeCoordinator {
|
|||||||
ClawdbotSystemCommand.notify.rawValue,
|
ClawdbotSystemCommand.notify.rawValue,
|
||||||
ClawdbotSystemCommand.which.rawValue,
|
ClawdbotSystemCommand.which.rawValue,
|
||||||
ClawdbotSystemCommand.run.rawValue,
|
ClawdbotSystemCommand.run.rawValue,
|
||||||
|
ClawdbotSystemCommand.execApprovalsGet.rawValue,
|
||||||
|
ClawdbotSystemCommand.execApprovalsSet.rawValue,
|
||||||
]
|
]
|
||||||
|
|
||||||
let capsSet = Set(caps)
|
let capsSet = Set(caps)
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ actor MacNodeRuntime {
|
|||||||
return try await self.handleSystemWhich(req)
|
return try await self.handleSystemWhich(req)
|
||||||
case ClawdbotSystemCommand.notify.rawValue:
|
case ClawdbotSystemCommand.notify.rawValue:
|
||||||
return try await self.handleSystemNotify(req)
|
return try await self.handleSystemNotify(req)
|
||||||
|
case ClawdbotSystemCommand.execApprovalsGet.rawValue:
|
||||||
|
return try await self.handleSystemExecApprovalsGet(req)
|
||||||
|
case ClawdbotSystemCommand.execApprovalsSet.rawValue:
|
||||||
|
return try await self.handleSystemExecApprovalsSet(req)
|
||||||
default:
|
default:
|
||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
|
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
|
||||||
}
|
}
|
||||||
@@ -432,6 +436,7 @@ actor MacNodeRuntime {
|
|||||||
guard !command.isEmpty else {
|
guard !command.isEmpty else {
|
||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||||
}
|
}
|
||||||
|
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: params.rawCommand)
|
||||||
|
|
||||||
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
||||||
@@ -444,7 +449,12 @@ actor MacNodeRuntime {
|
|||||||
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
: self.mainSessionKey
|
: self.mainSessionKey
|
||||||
let runId = UUID().uuidString
|
let runId = UUID().uuidString
|
||||||
let resolution = ExecCommandResolution.resolve(command: command, cwd: params.cwd, env: params.env)
|
let env = Self.sanitizedEnv(params.env)
|
||||||
|
let resolution = ExecCommandResolution.resolve(
|
||||||
|
command: command,
|
||||||
|
rawCommand: params.rawCommand,
|
||||||
|
cwd: params.cwd,
|
||||||
|
env: env)
|
||||||
let allowlistMatch = security == .allowlist
|
let allowlistMatch = security == .allowlist
|
||||||
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
||||||
: nil
|
: nil
|
||||||
@@ -463,7 +473,7 @@ actor MacNodeRuntime {
|
|||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
command: displayCommand,
|
||||||
reason: "security=deny"))
|
reason: "security=deny"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
@@ -477,12 +487,11 @@ actor MacNodeRuntime {
|
|||||||
return false
|
return false
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var approvedByAsk = false
|
||||||
if requiresAsk {
|
if requiresAsk {
|
||||||
let decision = await ExecApprovalsSocketClient.requestDecision(
|
let decision: ExecApprovalDecision? = await ExecApprovalsPromptPresenter.prompt(
|
||||||
socketPath: approvals.socketPath,
|
ExecApprovalPromptRequest(
|
||||||
token: approvals.token,
|
command: displayCommand,
|
||||||
request: ExecApprovalPromptRequest(
|
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
host: "node",
|
host: "node",
|
||||||
security: security.rawValue,
|
security: security.rawValue,
|
||||||
@@ -498,21 +507,40 @@ actor MacNodeRuntime {
|
|||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
command: displayCommand,
|
||||||
reason: "user-denied"))
|
reason: "user-denied"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
code: .unavailable,
|
code: .unavailable,
|
||||||
message: "SYSTEM_RUN_DENIED: user denied")
|
message: "SYSTEM_RUN_DENIED: user denied")
|
||||||
case nil:
|
case nil:
|
||||||
if askFallback == .deny || (askFallback == .allowlist && allowlistMatch == nil && !skillAllow) {
|
if askFallback == .full {
|
||||||
|
approvedByAsk = true
|
||||||
|
} else if askFallback == .allowlist {
|
||||||
|
if allowlistMatch != nil || skillAllow {
|
||||||
|
approvedByAsk = true
|
||||||
|
} else {
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: displayCommand,
|
||||||
|
reason: "approval-required"))
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .unavailable,
|
||||||
|
message: "SYSTEM_RUN_DENIED: approval required")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
await self.emitExecEvent(
|
await self.emitExecEvent(
|
||||||
"exec.denied",
|
"exec.denied",
|
||||||
payload: ExecEventPayload(
|
payload: ExecEventPayload(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
command: displayCommand,
|
||||||
reason: "approval-required"))
|
reason: "approval-required"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
@@ -520,6 +548,7 @@ actor MacNodeRuntime {
|
|||||||
message: "SYSTEM_RUN_DENIED: approval required")
|
message: "SYSTEM_RUN_DENIED: approval required")
|
||||||
}
|
}
|
||||||
case .allowAlways?:
|
case .allowAlways?:
|
||||||
|
approvedByAsk = true
|
||||||
if security == .allowlist {
|
if security == .allowlist {
|
||||||
let pattern = resolution?.resolvedPath ??
|
let pattern = resolution?.resolvedPath ??
|
||||||
resolution?.rawExecutable ??
|
resolution?.rawExecutable ??
|
||||||
@@ -530,20 +559,33 @@ actor MacNodeRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .allowOnce?:
|
case .allowOnce?:
|
||||||
break
|
approvedByAsk = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: displayCommand,
|
||||||
|
reason: "allowlist-miss"))
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .unavailable,
|
||||||
|
message: "SYSTEM_RUN_DENIED: allowlist miss")
|
||||||
|
}
|
||||||
|
|
||||||
if let match = allowlistMatch {
|
if let match = allowlistMatch {
|
||||||
ExecApprovalsStore.recordAllowlistUse(
|
ExecApprovalsStore.recordAllowlistUse(
|
||||||
agentId: agentId,
|
agentId: agentId,
|
||||||
pattern: match.pattern,
|
pattern: match.pattern,
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
command: displayCommand,
|
||||||
resolvedPath: resolution?.resolvedPath)
|
resolvedPath: resolution?.resolvedPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
let env = Self.sanitizedEnv(params.env)
|
|
||||||
|
|
||||||
if params.needsScreenRecording == true {
|
if params.needsScreenRecording == true {
|
||||||
let authorized = await PermissionManager
|
let authorized = await PermissionManager
|
||||||
.status([.screenRecording])[.screenRecording] ?? false
|
.status([.screenRecording])[.screenRecording] ?? false
|
||||||
@@ -554,7 +596,7 @@ actor MacNodeRuntime {
|
|||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
command: displayCommand,
|
||||||
reason: "permission:screenRecording"))
|
reason: "permission:screenRecording"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
@@ -570,20 +612,23 @@ actor MacNodeRuntime {
|
|||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: ExecCommandFormatter.displayString(for: command)))
|
command: displayCommand))
|
||||||
let result = await ShellExecutor.runDetailed(
|
let result = await ShellExecutor.runDetailed(
|
||||||
command: command,
|
command: command,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
env: env,
|
env: env,
|
||||||
timeout: timeoutSec)
|
timeout: timeoutSec)
|
||||||
let combined = [result.stdout, result.stderr, result.errorMessage].filter { !$0.isEmpty }.joined(separator: "\n")
|
let combined = [result.stdout, result.stderr, result.errorMessage]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.joined(separator: "\n")
|
||||||
await self.emitExecEvent(
|
await self.emitExecEvent(
|
||||||
"exec.finished",
|
"exec.finished",
|
||||||
payload: ExecEventPayload(
|
payload: ExecEventPayload(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
command: displayCommand,
|
||||||
exitCode: result.exitCode,
|
exitCode: result.exitCode,
|
||||||
timedOut: result.timedOut,
|
timedOut: result.timedOut,
|
||||||
success: result.success,
|
success: result.success,
|
||||||
@@ -635,6 +680,72 @@ actor MacNodeRuntime {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
|
_ = ExecApprovalsStore.ensureFile()
|
||||||
|
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||||
|
let redacted = ExecApprovalsSnapshot(
|
||||||
|
path: snapshot.path,
|
||||||
|
exists: snapshot.exists,
|
||||||
|
hash: snapshot.hash,
|
||||||
|
file: ExecApprovalsStore.redactForSnapshot(snapshot.file))
|
||||||
|
let payload = try Self.encodePayload(redacted)
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleSystemExecApprovalsSet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
|
struct SetParams: Decodable {
|
||||||
|
var file: ExecApprovalsFile
|
||||||
|
var baseHash: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON)
|
||||||
|
let current = ExecApprovalsStore.ensureFile()
|
||||||
|
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||||
|
if snapshot.exists {
|
||||||
|
if snapshot.hash.isEmpty {
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .invalidRequest,
|
||||||
|
message: "INVALID_REQUEST: exec approvals base hash unavailable; reload and retry")
|
||||||
|
}
|
||||||
|
let baseHash = params.baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if baseHash.isEmpty {
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .invalidRequest,
|
||||||
|
message: "INVALID_REQUEST: exec approvals base hash required; reload and retry")
|
||||||
|
}
|
||||||
|
if baseHash != snapshot.hash {
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .invalidRequest,
|
||||||
|
message: "INVALID_REQUEST: exec approvals changed; reload and retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = ExecApprovalsStore.normalizeIncoming(params.file)
|
||||||
|
let socketPath = normalized.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let token = normalized.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let resolvedPath = (socketPath?.isEmpty == false)
|
||||||
|
? socketPath!
|
||||||
|
: current.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ??
|
||||||
|
ExecApprovalsStore.socketPath()
|
||||||
|
let resolvedToken = (token?.isEmpty == false)
|
||||||
|
? token!
|
||||||
|
: current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken)
|
||||||
|
|
||||||
|
ExecApprovalsStore.saveFile(normalized)
|
||||||
|
let nextSnapshot = ExecApprovalsStore.readSnapshot()
|
||||||
|
let redacted = ExecApprovalsSnapshot(
|
||||||
|
path: nextSnapshot.path,
|
||||||
|
exists: nextSnapshot.exists,
|
||||||
|
hash: nextSnapshot.hash,
|
||||||
|
file: ExecApprovalsStore.redactForSnapshot(nextSnapshot.file))
|
||||||
|
let payload = try Self.encodePayload(redacted)
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
}
|
||||||
|
|
||||||
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
|
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
|
||||||
guard let sender = self.eventSender else { return }
|
guard let sender = self.eventSender else { return }
|
||||||
guard let data = try? JSONEncoder().encode(payload),
|
guard let data = try? JSONEncoder().encode(payload),
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ struct NodeMenuEntryFormatter {
|
|||||||
if let platform = self.platformText(entry) {
|
if let platform = self.platformText(entry) {
|
||||||
parts.append("platform \(platform)")
|
parts.append("platform \(platform)")
|
||||||
}
|
}
|
||||||
if let version = entry.version?.nonEmpty {
|
let versionLabels = self.versionLabels(entry)
|
||||||
parts.append("app \(self.compactVersion(version))")
|
if !versionLabels.isEmpty {
|
||||||
|
parts.append(versionLabels.joined(separator: " · "))
|
||||||
}
|
}
|
||||||
parts.append("status \(self.roleText(entry))")
|
parts.append("status \(self.roleText(entry))")
|
||||||
return parts.joined(separator: " · ")
|
return parts.joined(separator: " · ")
|
||||||
@@ -60,8 +61,9 @@ struct NodeMenuEntryFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func detailRightVersion(_ entry: NodeInfo) -> String? {
|
static func detailRightVersion(_ entry: NodeInfo) -> String? {
|
||||||
guard let version = entry.version?.nonEmpty else { return nil }
|
let labels = self.versionLabels(entry, compact: false)
|
||||||
return self.shortVersionLabel(version)
|
if labels.isEmpty { return nil }
|
||||||
|
return labels.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func platformText(_ entry: NodeInfo) -> String? {
|
static func platformText(_ entry: NodeInfo) -> String? {
|
||||||
@@ -127,6 +129,39 @@ struct NodeMenuEntryFormatter {
|
|||||||
return compact
|
return compact
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func versionLabels(_ entry: NodeInfo, compact: Bool = true) -> [String] {
|
||||||
|
let (core, ui) = self.resolveVersions(entry)
|
||||||
|
var labels: [String] = []
|
||||||
|
if let core {
|
||||||
|
let label = compact ? self.compactVersion(core) : self.shortVersionLabel(core)
|
||||||
|
labels.append("core \(label)")
|
||||||
|
}
|
||||||
|
if let ui {
|
||||||
|
let label = compact ? self.compactVersion(ui) : self.shortVersionLabel(ui)
|
||||||
|
labels.append("ui \(label)")
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveVersions(_ entry: NodeInfo) -> (core: String?, ui: String?) {
|
||||||
|
let core = entry.coreVersion?.nonEmpty
|
||||||
|
let ui = entry.uiVersion?.nonEmpty
|
||||||
|
if core != nil || ui != nil {
|
||||||
|
return (core, ui)
|
||||||
|
}
|
||||||
|
guard let legacy = entry.version?.nonEmpty else { return (nil, nil) }
|
||||||
|
if self.isHeadlessPlatform(entry) {
|
||||||
|
return (legacy, nil)
|
||||||
|
}
|
||||||
|
return (nil, legacy)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isHeadlessPlatform(_ entry: NodeInfo) -> Bool {
|
||||||
|
let raw = entry.platform?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
||||||
|
if raw == "darwin" || raw == "linux" || raw == "win32" || raw == "windows" { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
||||||
if self.isGateway(entry) {
|
if self.isGateway(entry) {
|
||||||
return self.safeSystemSymbol(
|
return self.safeSystemSymbol(
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ struct NodeInfo: Identifiable, Codable {
|
|||||||
let displayName: String?
|
let displayName: String?
|
||||||
let platform: String?
|
let platform: String?
|
||||||
let version: String?
|
let version: String?
|
||||||
|
let coreVersion: String?
|
||||||
|
let uiVersion: String?
|
||||||
let deviceFamily: String?
|
let deviceFamily: String?
|
||||||
let modelIdentifier: String?
|
let modelIdentifier: String?
|
||||||
let remoteIp: String?
|
let remoteIp: String?
|
||||||
|
|||||||
@@ -13,18 +13,16 @@ struct SystemRunSettingsView: View {
|
|||||||
Text("Exec approvals")
|
Text("Exec approvals")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
if self.model.agentIds.count > 1 {
|
Picker("Agent", selection: Binding(
|
||||||
Picker("Agent", selection: Binding(
|
get: { self.model.selectedAgentId },
|
||||||
get: { self.model.selectedAgentId },
|
set: { self.model.selectAgent($0) }))
|
||||||
set: { self.model.selectAgent($0) }))
|
{
|
||||||
{
|
ForEach(self.model.agentPickerIds, id: \.self) { id in
|
||||||
ForEach(self.model.agentIds, id: \.self) { id in
|
Text(self.model.label(for: id)).tag(id)
|
||||||
Text(id).tag(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
|
||||||
.frame(width: 160, alignment: .trailing)
|
|
||||||
}
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.frame(width: 180, alignment: .trailing)
|
||||||
}
|
}
|
||||||
|
|
||||||
Picker("", selection: self.$tab) {
|
Picker("", selection: self.$tab) {
|
||||||
@@ -82,7 +80,9 @@ struct SystemRunSettingsView: View {
|
|||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
Text("Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.")
|
Text(self.model.isDefaultsScope
|
||||||
|
? "Defaults apply when an agent has no overrides. Ask controls prompt behavior; fallback is used when no companion UI is reachable."
|
||||||
|
: "Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -101,31 +101,37 @@ struct SystemRunSettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
if self.model.isDefaultsScope {
|
||||||
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
|
Text("Allowlists are per-agent. Select an agent to edit its allowlist.")
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
Button("Add") {
|
|
||||||
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !pattern.isEmpty else { return }
|
|
||||||
self.model.addEntry(pattern)
|
|
||||||
self.newPattern = ""
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.model.entries.isEmpty {
|
|
||||||
Text("No allowlisted commands yet.")
|
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
|
||||||
ExecAllowlistRow(
|
.textFieldStyle(.roundedBorder)
|
||||||
entry: Binding(
|
Button("Add") {
|
||||||
get: { self.model.entries[index] },
|
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
set: { self.model.updateEntry($0, at: index) }),
|
guard !pattern.isEmpty else { return }
|
||||||
onRemove: { self.model.removeEntry(at: index) })
|
self.model.addEntry(pattern)
|
||||||
|
self.newPattern = ""
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.model.entries.isEmpty {
|
||||||
|
Text("No allowlisted commands yet.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
||||||
|
ExecAllowlistRow(
|
||||||
|
entry: Binding(
|
||||||
|
get: { self.model.entries[index] },
|
||||||
|
set: { self.model.updateEntry($0, at: index) }),
|
||||||
|
onRemove: { self.model.removeEntry(at: index) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,8 +183,16 @@ struct ExecAllowlistRow: View {
|
|||||||
Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))")
|
Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
|
}
|
||||||
Text("Last used: \(lastUsedCommand)")
|
|
||||||
|
if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
|
||||||
|
Text("Last command: \(lastUsedCommand)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastResolvedPath = self.entry.lastResolvedPath, !lastResolvedPath.isEmpty {
|
||||||
|
Text("Resolved path: \(lastResolvedPath)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -201,6 +215,7 @@ struct ExecAllowlistRow: View {
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class ExecApprovalsSettingsModel {
|
final class ExecApprovalsSettingsModel {
|
||||||
|
private static let defaultsScopeId = "__defaults__"
|
||||||
var agentIds: [String] = []
|
var agentIds: [String] = []
|
||||||
var selectedAgentId: String = "main"
|
var selectedAgentId: String = "main"
|
||||||
var defaultAgentId: String = "main"
|
var defaultAgentId: String = "main"
|
||||||
@@ -211,6 +226,19 @@ final class ExecApprovalsSettingsModel {
|
|||||||
var entries: [ExecAllowlistEntry] = []
|
var entries: [ExecAllowlistEntry] = []
|
||||||
var skillBins: [String] = []
|
var skillBins: [String] = []
|
||||||
|
|
||||||
|
var agentPickerIds: [String] {
|
||||||
|
[Self.defaultsScopeId] + self.agentIds
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDefaultsScope: Bool {
|
||||||
|
self.selectedAgentId == Self.defaultsScopeId
|
||||||
|
}
|
||||||
|
|
||||||
|
func label(for id: String) -> String {
|
||||||
|
if id == Self.defaultsScopeId { return "Defaults" }
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
func refresh() async {
|
func refresh() async {
|
||||||
await self.refreshAgents()
|
await self.refreshAgents()
|
||||||
self.loadSettings(for: self.selectedAgentId)
|
self.loadSettings(for: self.selectedAgentId)
|
||||||
@@ -242,6 +270,9 @@ final class ExecApprovalsSettingsModel {
|
|||||||
}
|
}
|
||||||
self.agentIds = ids
|
self.agentIds = ids
|
||||||
self.defaultAgentId = defaultId ?? "main"
|
self.defaultAgentId = defaultId ?? "main"
|
||||||
|
if self.selectedAgentId == Self.defaultsScopeId {
|
||||||
|
return
|
||||||
|
}
|
||||||
if !self.agentIds.contains(self.selectedAgentId) {
|
if !self.agentIds.contains(self.selectedAgentId) {
|
||||||
self.selectedAgentId = self.defaultAgentId
|
self.selectedAgentId = self.defaultAgentId
|
||||||
}
|
}
|
||||||
@@ -254,6 +285,15 @@ final class ExecApprovalsSettingsModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadSettings(for agentId: String) {
|
func loadSettings(for agentId: String) {
|
||||||
|
if agentId == Self.defaultsScopeId {
|
||||||
|
let defaults = ExecApprovalsStore.resolveDefaults()
|
||||||
|
self.security = defaults.security
|
||||||
|
self.ask = defaults.ask
|
||||||
|
self.askFallback = defaults.askFallback
|
||||||
|
self.autoAllowSkills = defaults.autoAllowSkills
|
||||||
|
self.entries = []
|
||||||
|
return
|
||||||
|
}
|
||||||
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
|
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
|
||||||
self.security = resolved.agent.security
|
self.security = resolved.agent.security
|
||||||
self.ask = resolved.agent.ask
|
self.ask = resolved.agent.ask
|
||||||
@@ -265,36 +305,61 @@ final class ExecApprovalsSettingsModel {
|
|||||||
|
|
||||||
func setSecurity(_ security: ExecSecurity) {
|
func setSecurity(_ security: ExecSecurity) {
|
||||||
self.security = security
|
self.security = security
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
if self.isDefaultsScope {
|
||||||
entry.security = security
|
ExecApprovalsStore.updateDefaults { defaults in
|
||||||
|
defaults.security = security
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
|
entry.security = security
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.syncQuickMode()
|
self.syncQuickMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setAsk(_ ask: ExecAsk) {
|
func setAsk(_ ask: ExecAsk) {
|
||||||
self.ask = ask
|
self.ask = ask
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
if self.isDefaultsScope {
|
||||||
entry.ask = ask
|
ExecApprovalsStore.updateDefaults { defaults in
|
||||||
|
defaults.ask = ask
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
|
entry.ask = ask
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.syncQuickMode()
|
self.syncQuickMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setAskFallback(_ mode: ExecSecurity) {
|
func setAskFallback(_ mode: ExecSecurity) {
|
||||||
self.askFallback = mode
|
self.askFallback = mode
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
if self.isDefaultsScope {
|
||||||
entry.askFallback = mode
|
ExecApprovalsStore.updateDefaults { defaults in
|
||||||
|
defaults.askFallback = mode
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
|
entry.askFallback = mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setAutoAllowSkills(_ enabled: Bool) {
|
func setAutoAllowSkills(_ enabled: Bool) {
|
||||||
self.autoAllowSkills = enabled
|
self.autoAllowSkills = enabled
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
if self.isDefaultsScope {
|
||||||
entry.autoAllowSkills = enabled
|
ExecApprovalsStore.updateDefaults { defaults in
|
||||||
|
defaults.autoAllowSkills = enabled
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
|
entry.autoAllowSkills = enabled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Task { await self.refreshSkillBins(force: enabled) }
|
Task { await self.refreshSkillBins(force: enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func addEntry(_ pattern: String) {
|
func addEntry(_ pattern: String) {
|
||||||
|
guard !self.isDefaultsScope else { return }
|
||||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
|
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
|
||||||
@@ -302,12 +367,14 @@ final class ExecApprovalsSettingsModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
|
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
|
||||||
|
guard !self.isDefaultsScope else { return }
|
||||||
guard self.entries.indices.contains(index) else { return }
|
guard self.entries.indices.contains(index) else { return }
|
||||||
self.entries[index] = entry
|
self.entries[index] = entry
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeEntry(at index: Int) {
|
func removeEntry(at index: Int) {
|
||||||
|
guard !self.isDefaultsScope else { return }
|
||||||
guard self.entries.indices.contains(index) else { return }
|
guard self.entries.indices.contains(index) else { return }
|
||||||
self.entries.remove(at: index)
|
self.entries.remove(at: index)
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
@@ -323,6 +390,10 @@ final class ExecApprovalsSettingsModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func syncQuickMode() {
|
private func syncQuickMode() {
|
||||||
|
if self.isDefaultsScope {
|
||||||
|
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
||||||
|
return
|
||||||
|
}
|
||||||
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
|
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
|
||||||
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -530,6 +530,8 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
public let displayname: String?
|
public let displayname: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
|
public let coreversion: String?
|
||||||
|
public let uiversion: String?
|
||||||
public let devicefamily: String?
|
public let devicefamily: String?
|
||||||
public let modelidentifier: String?
|
public let modelidentifier: String?
|
||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
@@ -542,6 +544,8 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
displayname: String?,
|
displayname: String?,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
version: String?,
|
version: String?,
|
||||||
|
coreversion: String?,
|
||||||
|
uiversion: String?,
|
||||||
devicefamily: String?,
|
devicefamily: String?,
|
||||||
modelidentifier: String?,
|
modelidentifier: String?,
|
||||||
caps: [String]?,
|
caps: [String]?,
|
||||||
@@ -553,6 +557,8 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
self.displayname = displayname
|
self.displayname = displayname
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.version = version
|
self.version = version
|
||||||
|
self.coreversion = coreversion
|
||||||
|
self.uiversion = uiversion
|
||||||
self.devicefamily = devicefamily
|
self.devicefamily = devicefamily
|
||||||
self.modelidentifier = modelidentifier
|
self.modelidentifier = modelidentifier
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
@@ -565,6 +571,8 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
case displayname = "displayName"
|
case displayname = "displayName"
|
||||||
case platform
|
case platform
|
||||||
case version
|
case version
|
||||||
|
case coreversion = "coreVersion"
|
||||||
|
case uiversion = "uiVersion"
|
||||||
case devicefamily = "deviceFamily"
|
case devicefamily = "deviceFamily"
|
||||||
case modelidentifier = "modelIdentifier"
|
case modelidentifier = "modelIdentifier"
|
||||||
case caps
|
case caps
|
||||||
@@ -1632,6 +1640,51 @@ public struct LogsTailResult: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ExecApprovalsGetParams: Codable, Sendable {
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ExecApprovalsSetParams: Codable, Sendable {
|
||||||
|
public let file: [String: AnyCodable]
|
||||||
|
public let basehash: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
file: [String: AnyCodable],
|
||||||
|
basehash: String?
|
||||||
|
) {
|
||||||
|
self.file = file
|
||||||
|
self.basehash = basehash
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case file
|
||||||
|
case basehash = "baseHash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||||
|
public let path: String
|
||||||
|
public let exists: Bool
|
||||||
|
public let hash: String
|
||||||
|
public let file: [String: AnyCodable]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
path: String,
|
||||||
|
exists: Bool,
|
||||||
|
hash: String,
|
||||||
|
file: [String: AnyCodable]
|
||||||
|
) {
|
||||||
|
self.path = path
|
||||||
|
self.exists = exists
|
||||||
|
self.hash = hash
|
||||||
|
self.file = file
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case path
|
||||||
|
case exists
|
||||||
|
case hash
|
||||||
|
case file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct ChatHistoryParams: Codable, Sendable {
|
public struct ChatHistoryParams: Codable, Sendable {
|
||||||
public let sessionkey: String
|
public let sessionkey: String
|
||||||
public let limit: Int?
|
public let limit: Int?
|
||||||
|
|||||||
@@ -134,4 +134,27 @@ import Testing
|
|||||||
#expect(script.contains("CLI="))
|
#expect(script.contains("CLI="))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func configRootLocalOverridesRemoteDefaults() async throws {
|
||||||
|
let defaults = self.makeDefaults()
|
||||||
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||||
|
defaults.set("clawd@example.com:2222", forKey: remoteTargetKey)
|
||||||
|
|
||||||
|
let tmp = try makeTempDir()
|
||||||
|
CommandResolver.setProjectRoot(tmp.path)
|
||||||
|
|
||||||
|
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
|
||||||
|
try self.makeExec(at: clawdbotPath)
|
||||||
|
|
||||||
|
let cmd = CommandResolver.clawdbotCommand(
|
||||||
|
subcommand: "daemon",
|
||||||
|
defaults: defaults,
|
||||||
|
configRoot: ["gateway": ["mode": "local"]])
|
||||||
|
|
||||||
|
#expect(cmd.first == clawdbotPath.path)
|
||||||
|
#expect(cmd.count >= 2)
|
||||||
|
if cmd.count >= 2 {
|
||||||
|
#expect(cmd[1] == "daemon")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,8 @@ public struct BridgeHello: Codable, Sendable {
|
|||||||
public let token: String?
|
public let token: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
|
public let coreVersion: String?
|
||||||
|
public let uiVersion: String?
|
||||||
public let deviceFamily: String?
|
public let deviceFamily: String?
|
||||||
public let modelIdentifier: String?
|
public let modelIdentifier: String?
|
||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
@@ -76,6 +78,8 @@ public struct BridgeHello: Codable, Sendable {
|
|||||||
token: String?,
|
token: String?,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
version: String?,
|
version: String?,
|
||||||
|
coreVersion: String? = nil,
|
||||||
|
uiVersion: String? = nil,
|
||||||
deviceFamily: String? = nil,
|
deviceFamily: String? = nil,
|
||||||
modelIdentifier: String? = nil,
|
modelIdentifier: String? = nil,
|
||||||
caps: [String]? = nil,
|
caps: [String]? = nil,
|
||||||
@@ -88,6 +92,8 @@ public struct BridgeHello: Codable, Sendable {
|
|||||||
self.token = token
|
self.token = token
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.version = version
|
self.version = version
|
||||||
|
self.coreVersion = coreVersion
|
||||||
|
self.uiVersion = uiVersion
|
||||||
self.deviceFamily = deviceFamily
|
self.deviceFamily = deviceFamily
|
||||||
self.modelIdentifier = modelIdentifier
|
self.modelIdentifier = modelIdentifier
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
@@ -121,6 +127,8 @@ public struct BridgePairRequest: Codable, Sendable {
|
|||||||
public let displayName: String?
|
public let displayName: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
|
public let coreVersion: String?
|
||||||
|
public let uiVersion: String?
|
||||||
public let deviceFamily: String?
|
public let deviceFamily: String?
|
||||||
public let modelIdentifier: String?
|
public let modelIdentifier: String?
|
||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
@@ -135,6 +143,8 @@ public struct BridgePairRequest: Codable, Sendable {
|
|||||||
displayName: String?,
|
displayName: String?,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
version: String?,
|
version: String?,
|
||||||
|
coreVersion: String? = nil,
|
||||||
|
uiVersion: String? = nil,
|
||||||
deviceFamily: String? = nil,
|
deviceFamily: String? = nil,
|
||||||
modelIdentifier: String? = nil,
|
modelIdentifier: String? = nil,
|
||||||
caps: [String]? = nil,
|
caps: [String]? = nil,
|
||||||
@@ -148,6 +158,8 @@ public struct BridgePairRequest: Codable, Sendable {
|
|||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.version = version
|
self.version = version
|
||||||
|
self.coreVersion = coreVersion
|
||||||
|
self.uiVersion = uiVersion
|
||||||
self.deviceFamily = deviceFamily
|
self.deviceFamily = deviceFamily
|
||||||
self.modelIdentifier = modelIdentifier
|
self.modelIdentifier = modelIdentifier
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ public enum ClawdbotSystemCommand: String, Codable, Sendable {
|
|||||||
case run = "system.run"
|
case run = "system.run"
|
||||||
case which = "system.which"
|
case which = "system.which"
|
||||||
case notify = "system.notify"
|
case notify = "system.notify"
|
||||||
|
case execApprovalsGet = "system.execApprovals.get"
|
||||||
|
case execApprovalsSet = "system.execApprovals.set"
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ClawdbotNotificationPriority: String, Codable, Sendable {
|
public enum ClawdbotNotificationPriority: String, Codable, Sendable {
|
||||||
@@ -20,6 +22,7 @@ public enum ClawdbotNotificationDelivery: String, Codable, Sendable {
|
|||||||
|
|
||||||
public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||||
public var command: [String]
|
public var command: [String]
|
||||||
|
public var rawCommand: String?
|
||||||
public var cwd: String?
|
public var cwd: String?
|
||||||
public var env: [String: String]?
|
public var env: [String: String]?
|
||||||
public var timeoutMs: Int?
|
public var timeoutMs: Int?
|
||||||
@@ -29,6 +32,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
|
|
||||||
public init(
|
public init(
|
||||||
command: [String],
|
command: [String],
|
||||||
|
rawCommand: String? = nil,
|
||||||
cwd: String? = nil,
|
cwd: String? = nil,
|
||||||
env: [String: String]? = nil,
|
env: [String: String]? = nil,
|
||||||
timeoutMs: Int? = nil,
|
timeoutMs: Int? = nil,
|
||||||
@@ -37,6 +41,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
sessionKey: String? = nil)
|
sessionKey: String? = nil)
|
||||||
{
|
{
|
||||||
self.command = command
|
self.command = command
|
||||||
|
self.rawCommand = rawCommand
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
self.env = env
|
self.env = env
|
||||||
self.timeoutMs = timeoutMs
|
self.timeoutMs = timeoutMs
|
||||||
|
|||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
# Clawdbot ACP Bridge
|
||||||
|
|
||||||
|
This document describes how the Clawdbot ACP (Agent Client Protocol) bridge works,
|
||||||
|
how it maps ACP sessions to Gateway sessions, and how IDEs should invoke it.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`clawdbot acp` exposes an ACP agent over stdio and forwards prompts to a running
|
||||||
|
Clawdbot Gateway over WebSocket. It keeps ACP session ids mapped to Gateway
|
||||||
|
session keys so IDEs can reconnect to the same agent transcript or reset it on
|
||||||
|
request.
|
||||||
|
|
||||||
|
Key goals:
|
||||||
|
|
||||||
|
- Minimal ACP surface area (stdio, NDJSON).
|
||||||
|
- Stable session mapping across reconnects.
|
||||||
|
- Works with existing Gateway session store (list/resolve/reset).
|
||||||
|
- Safe defaults (isolated ACP session keys by default).
|
||||||
|
|
||||||
|
## How can I use this
|
||||||
|
|
||||||
|
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
|
||||||
|
drive a Clawdbot Gateway session.
|
||||||
|
|
||||||
|
Quick steps:
|
||||||
|
|
||||||
|
1. Run a Gateway (local or remote).
|
||||||
|
2. Configure the Gateway target (`gateway.remote.url` + auth) or pass flags.
|
||||||
|
3. Point the IDE to run `clawdbot acp` over stdio.
|
||||||
|
|
||||||
|
Example config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config set gateway.remote.url wss://gateway-host:18789
|
||||||
|
clawdbot config set gateway.remote.token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Selecting agents
|
||||||
|
|
||||||
|
ACP does not pick agents directly. It routes by the Gateway session key.
|
||||||
|
|
||||||
|
Use agent-scoped session keys to target a specific agent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp --session agent:main:main
|
||||||
|
clawdbot acp --session agent:design:main
|
||||||
|
clawdbot acp --session agent:qa:bug-123
|
||||||
|
```
|
||||||
|
|
||||||
|
Each ACP session maps to a single Gateway session key. One agent can have many
|
||||||
|
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
|
||||||
|
the key or label.
|
||||||
|
|
||||||
|
## Zed editor setup
|
||||||
|
|
||||||
|
Add a custom ACP agent in `~/.config/zed/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_servers": {
|
||||||
|
"Clawdbot ACP": {
|
||||||
|
"type": "custom",
|
||||||
|
"command": "clawdbot",
|
||||||
|
"args": ["acp"],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To target a specific Gateway or agent:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_servers": {
|
||||||
|
"Clawdbot ACP": {
|
||||||
|
"type": "custom",
|
||||||
|
"command": "clawdbot",
|
||||||
|
"args": [
|
||||||
|
"acp",
|
||||||
|
"--url", "wss://gateway-host:18789",
|
||||||
|
"--token", "<token>",
|
||||||
|
"--session", "agent:design:main"
|
||||||
|
],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
|
||||||
|
|
||||||
|
## Execution Model
|
||||||
|
|
||||||
|
- ACP client spawns `clawdbot acp` and speaks ACP messages over stdio.
|
||||||
|
- The bridge connects to the Gateway using existing auth config (or CLI flags).
|
||||||
|
- ACP `prompt` translates to Gateway `chat.send`.
|
||||||
|
- Gateway streaming events are translated back into ACP streaming events.
|
||||||
|
- ACP `cancel` maps to Gateway `chat.abort` for the active run.
|
||||||
|
|
||||||
|
## Session Mapping
|
||||||
|
|
||||||
|
By default each ACP session is mapped to a dedicated Gateway session key:
|
||||||
|
|
||||||
|
- `acp:<uuid>` unless overridden.
|
||||||
|
|
||||||
|
You can override or reuse sessions in two ways:
|
||||||
|
|
||||||
|
1) CLI defaults
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp --session agent:main:main
|
||||||
|
clawdbot acp --session-label "support inbox"
|
||||||
|
clawdbot acp --reset-session
|
||||||
|
```
|
||||||
|
|
||||||
|
2) ACP metadata per session
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"sessionKey": "agent:main:main",
|
||||||
|
"sessionLabel": "support inbox",
|
||||||
|
"resetSession": true,
|
||||||
|
"requireExisting": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `sessionKey`: direct Gateway session key.
|
||||||
|
- `sessionLabel`: resolve an existing session by label.
|
||||||
|
- `resetSession`: mint a new transcript for the key before first use.
|
||||||
|
- `requireExisting`: fail if the key/label does not exist.
|
||||||
|
|
||||||
|
### Session Listing
|
||||||
|
|
||||||
|
ACP `listSessions` maps to Gateway `sessions.list` and returns a filtered
|
||||||
|
summary suitable for IDE session pickers. `_meta.limit` can cap the number of
|
||||||
|
sessions returned.
|
||||||
|
|
||||||
|
## Prompt Translation
|
||||||
|
|
||||||
|
ACP prompt inputs are converted into a Gateway `chat.send`:
|
||||||
|
|
||||||
|
- `text` and `resource` blocks become prompt text.
|
||||||
|
- `resource_link` with image mime types become attachments.
|
||||||
|
- The working directory can be prefixed into the prompt (default on, can be
|
||||||
|
disabled with `--no-prefix-cwd`).
|
||||||
|
|
||||||
|
Gateway streaming events are translated into ACP `message` and `tool_call`
|
||||||
|
updates. Terminal Gateway states map to ACP `done` with stop reasons:
|
||||||
|
|
||||||
|
- `complete` -> `stop`
|
||||||
|
- `aborted` -> `cancel`
|
||||||
|
- `error` -> `error`
|
||||||
|
|
||||||
|
## Auth + Gateway Discovery
|
||||||
|
|
||||||
|
`clawdbot acp` resolves the Gateway URL and auth from CLI flags or config:
|
||||||
|
|
||||||
|
- `--url` / `--token` / `--password` take precedence.
|
||||||
|
- Otherwise use configured `gateway.remote.*` settings.
|
||||||
|
|
||||||
|
## Operational Notes
|
||||||
|
|
||||||
|
- ACP sessions are stored in memory for the bridge process lifetime.
|
||||||
|
- Gateway session state is persisted by the Gateway itself.
|
||||||
|
- `--verbose` logs ACP/Gateway bridge events to stderr (never stdout).
|
||||||
|
- ACP runs can be canceled and the active run id is tracked per session.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x).
|
||||||
|
- Works with ACP clients that implement `initialize`, `newSession`,
|
||||||
|
`loadSession`, `prompt`, `cancel`, and `listSessions`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit: `src/acp/session.test.ts` covers run id lifecycle.
|
||||||
|
- Full gate: `pnpm lint && pnpm build && pnpm test && pnpm docs:build`.
|
||||||
|
|
||||||
|
## Related Docs
|
||||||
|
|
||||||
|
- CLI usage: `docs/cli/acp.md`
|
||||||
|
- Session model: `docs/concepts/session.md`
|
||||||
|
- Session management internals: `docs/reference/session-management-compaction.md`
|
||||||
+45
-10
@@ -1,11 +1,13 @@
|
|||||||
---
|
---
|
||||||
summary: "Slack socket mode setup and Clawdbot config"
|
summary: "Slack setup for socket or HTTP webhook mode"
|
||||||
read_when: "Setting up Slack or debugging Slack socket mode"
|
read_when: "Setting up Slack or debugging Slack socket/HTTP mode"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Slack (socket mode)
|
# Slack
|
||||||
|
|
||||||
## Quick setup (beginner)
|
## Socket mode (default)
|
||||||
|
|
||||||
|
### Quick setup (beginner)
|
||||||
1) Create a Slack app and enable **Socket Mode**.
|
1) Create a Slack app and enable **Socket Mode**.
|
||||||
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
|
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
|
||||||
3) Set tokens for Clawdbot and start the gateway.
|
3) Set tokens for Clawdbot and start the gateway.
|
||||||
@@ -23,7 +25,7 @@ Minimal config:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup
|
### Setup
|
||||||
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
||||||
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
||||||
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||||
@@ -43,7 +45,7 @@ Use the manifest below so scopes and events stay in sync.
|
|||||||
|
|
||||||
Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||||
|
|
||||||
## Clawdbot config (minimal)
|
### Clawdbot config (minimal)
|
||||||
|
|
||||||
Set tokens via env vars (recommended):
|
Set tokens via env vars (recommended):
|
||||||
- `SLACK_APP_TOKEN=xapp-...`
|
- `SLACK_APP_TOKEN=xapp-...`
|
||||||
@@ -63,7 +65,7 @@ Or via config:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## User token (optional)
|
### User token (optional)
|
||||||
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
|
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
|
||||||
pins, reactions, emoji, member info). By default this stays read-only: reads
|
pins, reactions, emoji, member info). By default this stays read-only: reads
|
||||||
prefer the user token when present, and writes still use the bot token unless
|
prefer the user token when present, and writes still use the bot token unless
|
||||||
@@ -102,18 +104,51 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Token usage
|
#### Token usage
|
||||||
- Read operations (history, reactions list, pins list, emoji list, member info,
|
- Read operations (history, reactions list, pins list, emoji list, member info,
|
||||||
search) prefer the user token when configured, otherwise the bot token.
|
search) prefer the user token when configured, otherwise the bot token.
|
||||||
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
|
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
|
||||||
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
|
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
|
||||||
no bot token is available, Clawdbot falls back to the user token.
|
no bot token is available, Clawdbot falls back to the user token.
|
||||||
|
|
||||||
## History context
|
### History context
|
||||||
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
||||||
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||||
|
|
||||||
## Manifest (optional)
|
## HTTP mode (Events API)
|
||||||
|
Use HTTP webhook mode when your Gateway is reachable by Slack over HTTPS (typical for server deployments).
|
||||||
|
HTTP mode uses the Events API + Interactivity + Slash Commands with a shared request URL.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
1) Create a Slack app and **disable Socket Mode** (optional if you only use HTTP).
|
||||||
|
2) **Basic Information** → copy the **Signing Secret**.
|
||||||
|
3) **OAuth & Permissions** → install the app and copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||||
|
4) **Event Subscriptions** → enable events and set the **Request URL** to your gateway webhook path (default `/slack/events`).
|
||||||
|
5) **Interactivity & Shortcuts** → enable and set the same **Request URL**.
|
||||||
|
6) **Slash Commands** → set the same **Request URL** for your command(s).
|
||||||
|
|
||||||
|
Example request URL:
|
||||||
|
`https://gateway-host/slack/events`
|
||||||
|
|
||||||
|
### Clawdbot config (minimal)
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
enabled: true,
|
||||||
|
mode: "http",
|
||||||
|
botToken: "xoxb-...",
|
||||||
|
signingSecret: "your-signing-secret",
|
||||||
|
webhookPath: "/slack/events"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-account HTTP mode: set `channels.slack.accounts.<id>.mode = "http"` and provide a unique
|
||||||
|
`webhookPath` per account so each Slack app can point to its own URL.
|
||||||
|
|
||||||
|
### Manifest (optional)
|
||||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
|
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
|
||||||
user scopes if you plan to configure a user token.
|
user scopes if you plan to configure a user token.
|
||||||
|
|
||||||
|
|||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
---
|
||||||
|
summary: "Run the ACP bridge for IDE integrations"
|
||||||
|
read_when:
|
||||||
|
- Setting up ACP-based IDE integrations
|
||||||
|
- Debugging ACP session routing to the Gateway
|
||||||
|
---
|
||||||
|
|
||||||
|
# acp
|
||||||
|
|
||||||
|
Run the ACP (Agent Client Protocol) bridge that talks to a Clawdbot Gateway.
|
||||||
|
|
||||||
|
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
|
||||||
|
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp
|
||||||
|
|
||||||
|
# Remote Gateway
|
||||||
|
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
||||||
|
|
||||||
|
# Attach to an existing session key
|
||||||
|
clawdbot acp --session agent:main:main
|
||||||
|
|
||||||
|
# Attach by label (must already exist)
|
||||||
|
clawdbot acp --session-label "support inbox"
|
||||||
|
|
||||||
|
# Reset the session key before the first prompt
|
||||||
|
clawdbot acp --session agent:main:main --reset-session
|
||||||
|
```
|
||||||
|
|
||||||
|
## ACP client (debug)
|
||||||
|
|
||||||
|
Use the built-in ACP client to sanity-check the bridge without an IDE.
|
||||||
|
It spawns the ACP bridge and lets you type prompts interactively.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp client
|
||||||
|
|
||||||
|
# Point the spawned bridge at a remote Gateway
|
||||||
|
clawdbot acp client --server-args --url wss://gateway-host:18789 --token <token>
|
||||||
|
|
||||||
|
# Override the server command (default: clawdbot)
|
||||||
|
clawdbot acp client --server "node" --server-args dist/entry.js acp --url ws://127.0.0.1:19001
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to use this
|
||||||
|
|
||||||
|
Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want
|
||||||
|
it to drive a Clawdbot Gateway session.
|
||||||
|
|
||||||
|
1. Ensure the Gateway is running (local or remote).
|
||||||
|
2. Configure the Gateway target (config or flags).
|
||||||
|
3. Point your IDE to run `clawdbot acp` over stdio.
|
||||||
|
|
||||||
|
Example config (persisted):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config set gateway.remote.url wss://gateway-host:18789
|
||||||
|
clawdbot config set gateway.remote.token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example direct run (no config write):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Selecting agents
|
||||||
|
|
||||||
|
ACP does not pick agents directly. It routes by the Gateway session key.
|
||||||
|
|
||||||
|
Use agent-scoped session keys to target a specific agent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp --session agent:main:main
|
||||||
|
clawdbot acp --session agent:design:main
|
||||||
|
clawdbot acp --session agent:qa:bug-123
|
||||||
|
```
|
||||||
|
|
||||||
|
Each ACP session maps to a single Gateway session key. One agent can have many
|
||||||
|
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
|
||||||
|
the key or label.
|
||||||
|
|
||||||
|
## Zed editor setup
|
||||||
|
|
||||||
|
Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zed’s Settings UI):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_servers": {
|
||||||
|
"Clawdbot ACP": {
|
||||||
|
"type": "custom",
|
||||||
|
"command": "clawdbot",
|
||||||
|
"args": ["acp"],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To target a specific Gateway or agent:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_servers": {
|
||||||
|
"Clawdbot ACP": {
|
||||||
|
"type": "custom",
|
||||||
|
"command": "clawdbot",
|
||||||
|
"args": [
|
||||||
|
"acp",
|
||||||
|
"--url", "wss://gateway-host:18789",
|
||||||
|
"--token", "<token>",
|
||||||
|
"--session", "agent:design:main"
|
||||||
|
],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
|
||||||
|
|
||||||
|
## Session mapping
|
||||||
|
|
||||||
|
By default, ACP sessions get an isolated Gateway session key with an `acp:` prefix.
|
||||||
|
To reuse a known session, pass a session key or label:
|
||||||
|
|
||||||
|
- `--session <key>`: use a specific Gateway session key.
|
||||||
|
- `--session-label <label>`: resolve an existing session by label.
|
||||||
|
- `--reset-session`: mint a fresh session id for that key (same key, new transcript).
|
||||||
|
|
||||||
|
If your ACP client supports metadata, you can override per session:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"sessionKey": "agent:main:main",
|
||||||
|
"sessionLabel": "support inbox",
|
||||||
|
"resetSession": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Learn more about session keys at [/concepts/session](/concepts/session).
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
- `--url <url>`: Gateway WebSocket URL (defaults to gateway.remote.url when configured).
|
||||||
|
- `--token <token>`: Gateway auth token.
|
||||||
|
- `--password <password>`: Gateway auth password.
|
||||||
|
- `--session <key>`: default session key.
|
||||||
|
- `--session-label <label>`: default session label to resolve.
|
||||||
|
- `--require-existing`: fail if the session key/label does not exist.
|
||||||
|
- `--reset-session`: reset the session key before first use.
|
||||||
|
- `--no-prefix-cwd`: do not prefix prompts with the working directory.
|
||||||
|
- `--verbose, -v`: verbose logging to stderr.
|
||||||
|
|
||||||
|
### `acp client` options
|
||||||
|
|
||||||
|
- `--cwd <dir>`: working directory for the ACP session.
|
||||||
|
- `--server <command>`: ACP server command (default: `clawdbot`).
|
||||||
|
- `--server-args <args...>`: extra arguments passed to the ACP server.
|
||||||
|
- `--server-verbose`: enable verbose logging on the ACP server.
|
||||||
|
- `--verbose, -v`: verbose client logging.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
summary: "CLI reference for `clawdbot approvals` (exec approvals for gateway or node hosts)"
|
||||||
|
read_when:
|
||||||
|
- You want to edit exec approvals from the CLI
|
||||||
|
- You need to manage allowlists on gateway or node hosts
|
||||||
|
---
|
||||||
|
|
||||||
|
# `clawdbot approvals`
|
||||||
|
|
||||||
|
Manage exec approvals for the **gateway host** or a **node host**.
|
||||||
|
By default, commands target the gateway. Use `--node` to edit a node’s approvals.
|
||||||
|
|
||||||
|
Related:
|
||||||
|
- Exec approvals: [Exec approvals](/tools/exec-approvals)
|
||||||
|
- Nodes: [Nodes](/nodes)
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot approvals get
|
||||||
|
clawdbot approvals get --node <id|name|ip>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Replace approvals from a file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot approvals set --file ./exec-approvals.json
|
||||||
|
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Allowlist helpers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot approvals allowlist add "~/Projects/**/bin/rg"
|
||||||
|
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"
|
||||||
|
|
||||||
|
clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix).
|
||||||
|
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
|
||||||
|
- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`.
|
||||||
@@ -15,6 +15,7 @@ the configure wizard (same as `clawdbot configure`).
|
|||||||
clawdbot config get browser.executablePath
|
clawdbot config get browser.executablePath
|
||||||
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
|
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
|
||||||
clawdbot config set agents.defaults.heartbeat.every "2h"
|
clawdbot config set agents.defaults.heartbeat.every "2h"
|
||||||
|
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
|
||||||
clawdbot config unset tools.web.search.apiKey
|
clawdbot config unset tools.web.search.apiKey
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -27,6 +28,13 @@ clawdbot config get agents.defaults.workspace
|
|||||||
clawdbot config get agents.list[0].id
|
clawdbot config get agents.list[0].id
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use the agent list index to target a specific agent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config get agents.list
|
||||||
|
clawdbot config set agents.list[1].tools.exec.node "node-id-or-name"
|
||||||
|
```
|
||||||
|
|
||||||
## Values
|
## Values
|
||||||
|
|
||||||
Values are parsed as JSON5 when possible; otherwise they are treated as strings.
|
Values are parsed as JSON5 when possible; otherwise they are treated as strings.
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ read_when:
|
|||||||
|
|
||||||
Manage the Gateway daemon (background service).
|
Manage the Gateway daemon (background service).
|
||||||
|
|
||||||
|
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains
|
||||||
|
as a legacy alias for compatibility.
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Gateway CLI: [Gateway](/cli/gateway)
|
- Gateway CLI: [Gateway](/cli/gateway)
|
||||||
- macOS platform notes: [macOS](/platforms/macos)
|
- macOS platform notes: [macOS](/platforms/macos)
|
||||||
|
|||||||
+19
-4
@@ -7,7 +7,7 @@ read_when:
|
|||||||
|
|
||||||
# `clawdbot hooks`
|
# `clawdbot hooks`
|
||||||
|
|
||||||
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
|
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, and gateway startup).
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Hooks: [Hooks](/hooks)
|
- Hooks: [Hooks](/hooks)
|
||||||
@@ -29,9 +29,10 @@ List all discovered hooks from workspace, managed, and bundled directories.
|
|||||||
**Example output:**
|
**Example output:**
|
||||||
|
|
||||||
```
|
```
|
||||||
Hooks (3/3 ready)
|
Hooks (4/4 ready)
|
||||||
|
|
||||||
Ready:
|
Ready:
|
||||||
|
🚀 boot-md ✓ - Run BOOT.md on gateway startup
|
||||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
📝 command-logger ✓ - Log all command events to a centralized audit file
|
||||||
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
||||||
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
|
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
|
||||||
@@ -107,8 +108,8 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
|
|||||||
```
|
```
|
||||||
Hooks Status
|
Hooks Status
|
||||||
|
|
||||||
Total hooks: 2
|
Total hooks: 4
|
||||||
Ready: 2
|
Ready: 4
|
||||||
Not ready: 0
|
Not ready: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -273,3 +274,17 @@ clawdbot hooks enable soul-evil
|
|||||||
```
|
```
|
||||||
|
|
||||||
**See:** [SOUL Evil Hook](/hooks/soul-evil)
|
**See:** [SOUL Evil Hook](/hooks/soul-evil)
|
||||||
|
|
||||||
|
### boot-md
|
||||||
|
|
||||||
|
Runs `BOOT.md` when the gateway starts (after channels start).
|
||||||
|
|
||||||
|
**Events**: `gateway:startup`
|
||||||
|
|
||||||
|
**Enable**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot hooks enable boot-md
|
||||||
|
```
|
||||||
|
|
||||||
|
**See:** [boot-md documentation](/hooks#boot-md)
|
||||||
+59
-16
@@ -23,15 +23,19 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`message`](/cli/message)
|
- [`message`](/cli/message)
|
||||||
- [`agent`](/cli/agent)
|
- [`agent`](/cli/agent)
|
||||||
- [`agents`](/cli/agents)
|
- [`agents`](/cli/agents)
|
||||||
|
- [`acp`](/cli/acp)
|
||||||
- [`status`](/cli/status)
|
- [`status`](/cli/status)
|
||||||
- [`health`](/cli/health)
|
- [`health`](/cli/health)
|
||||||
- [`sessions`](/cli/sessions)
|
- [`sessions`](/cli/sessions)
|
||||||
- [`gateway`](/cli/gateway)
|
- [`gateway`](/cli/gateway)
|
||||||
- [`daemon`](/cli/daemon)
|
- [`daemon`](/cli/daemon)
|
||||||
|
- [`service`](/cli/service)
|
||||||
- [`logs`](/cli/logs)
|
- [`logs`](/cli/logs)
|
||||||
- [`models`](/cli/models)
|
- [`models`](/cli/models)
|
||||||
- [`memory`](/cli/memory)
|
- [`memory`](/cli/memory)
|
||||||
- [`nodes`](/cli/nodes)
|
- [`nodes`](/cli/nodes)
|
||||||
|
- [`node`](/cli/node)
|
||||||
|
- [`approvals`](/cli/approvals)
|
||||||
- [`sandbox`](/cli/sandbox)
|
- [`sandbox`](/cli/sandbox)
|
||||||
- [`tui`](/cli/tui)
|
- [`tui`](/cli/tui)
|
||||||
- [`browser`](/cli/browser)
|
- [`browser`](/cli/browser)
|
||||||
@@ -125,6 +129,7 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
list
|
list
|
||||||
add
|
add
|
||||||
delete
|
delete
|
||||||
|
acp
|
||||||
status
|
status
|
||||||
health
|
health
|
||||||
sessions
|
sessions
|
||||||
@@ -140,6 +145,21 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
start
|
start
|
||||||
stop
|
stop
|
||||||
restart
|
restart
|
||||||
|
service
|
||||||
|
gateway
|
||||||
|
status
|
||||||
|
install
|
||||||
|
uninstall
|
||||||
|
start
|
||||||
|
stop
|
||||||
|
restart
|
||||||
|
node
|
||||||
|
status
|
||||||
|
install
|
||||||
|
uninstall
|
||||||
|
start
|
||||||
|
stop
|
||||||
|
restart
|
||||||
logs
|
logs
|
||||||
models
|
models
|
||||||
list
|
list
|
||||||
@@ -168,21 +188,19 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
runs
|
runs
|
||||||
run
|
run
|
||||||
nodes
|
nodes
|
||||||
status
|
node
|
||||||
describe
|
start
|
||||||
list
|
daemon
|
||||||
pending
|
status
|
||||||
approve
|
install
|
||||||
reject
|
uninstall
|
||||||
rename
|
start
|
||||||
invoke
|
stop
|
||||||
run
|
restart
|
||||||
notify
|
approvals
|
||||||
camera list|snap|clip
|
get
|
||||||
canvas snapshot|present|hide|navigate|eval
|
set
|
||||||
canvas a2ui push|reset
|
allowlist add|remove
|
||||||
screen record
|
|
||||||
location get
|
|
||||||
browser
|
browser
|
||||||
status
|
status
|
||||||
start
|
start
|
||||||
@@ -506,6 +524,11 @@ Options:
|
|||||||
- `--force`
|
- `--force`
|
||||||
- `--json`
|
- `--json`
|
||||||
|
|
||||||
|
### `acp`
|
||||||
|
Run the ACP bridge that connects IDEs to the Gateway.
|
||||||
|
|
||||||
|
See [`acp`](/cli/acp) for full options and examples.
|
||||||
|
|
||||||
### `status`
|
### `status`
|
||||||
Show linked session health and recent recipients.
|
Show linked session health and recent recipients.
|
||||||
|
|
||||||
@@ -518,6 +541,9 @@ Options:
|
|||||||
- `--verbose`
|
- `--verbose`
|
||||||
- `--debug` (alias for `--verbose`)
|
- `--debug` (alias for `--verbose`)
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Overview includes Gateway + Node service status when available.
|
||||||
|
|
||||||
### Usage tracking
|
### Usage tracking
|
||||||
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
||||||
|
|
||||||
@@ -772,6 +798,23 @@ Subcommands:
|
|||||||
|
|
||||||
All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
|
All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
|
||||||
|
|
||||||
|
## Node host
|
||||||
|
|
||||||
|
`node` runs a **headless node host** or manages it as a background service. See
|
||||||
|
[`clawdbot node`](/cli/node).
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
- `node start --host <gateway-host> --port 18790`
|
||||||
|
- `node service status`
|
||||||
|
- `node service install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
||||||
|
- `node service uninstall`
|
||||||
|
- `node service start`
|
||||||
|
- `node service stop`
|
||||||
|
- `node service restart`
|
||||||
|
|
||||||
|
Legacy alias:
|
||||||
|
- `node daemon …` (same as `node service …`)
|
||||||
|
|
||||||
## Nodes
|
## Nodes
|
||||||
|
|
||||||
`nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).
|
`nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).
|
||||||
@@ -788,7 +831,7 @@ Subcommands:
|
|||||||
- `nodes reject <requestId>`
|
- `nodes reject <requestId>`
|
||||||
- `nodes rename --node <id|name|ip> --name <displayName>`
|
- `nodes rename --node <id|name|ip> --name <displayName>`
|
||||||
- `nodes invoke --node <id|name|ip> --command <command> [--params <json>] [--invoke-timeout <ms>] [--idempotency-key <key>]`
|
- `nodes invoke --node <id|name|ip> --command <command> [--params <json>] [--invoke-timeout <ms>] [--idempotency-key <key>]`
|
||||||
- `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac only)
|
- `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac node or headless node host)
|
||||||
- `nodes notify --node <id|name|ip> [--title <text>] [--body <text>] [--sound <name>] [--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>] [--invoke-timeout <ms>]` (mac only)
|
- `nodes notify --node <id|name|ip> [--title <text>] [--body <text>] [--sound <name>] [--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>] [--invoke-timeout <ms>]` (mac only)
|
||||||
|
|
||||||
Camera:
|
Camera:
|
||||||
|
|||||||
@@ -28,3 +28,5 @@ clawdbot memory search "release checklist"
|
|||||||
## Options
|
## Options
|
||||||
|
|
||||||
- `--verbose`: emit debug logs during memory probes and indexing.
|
- `--verbose`: emit debug logs during memory probes and indexing.
|
||||||
|
- `--index-mode auto|batch|direct`: override batch usage when indexing (`direct` favors speed; `batch` favors OpenAI Batch pricing).
|
||||||
|
- `--progress auto|line|log|none`: progress output mode (`log` prints updates even without a TTY).
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
summary: "CLI reference for `clawdbot node` (headless node host)"
|
||||||
|
read_when:
|
||||||
|
- Running the headless node host
|
||||||
|
- Pairing a non-macOS node for system.run
|
||||||
|
---
|
||||||
|
|
||||||
|
# `clawdbot node`
|
||||||
|
|
||||||
|
Run a **headless node host** that connects to the Gateway bridge and exposes
|
||||||
|
`system.run` / `system.which` on this machine.
|
||||||
|
|
||||||
|
## Why use a node host?
|
||||||
|
|
||||||
|
Use a node host when you want agents to **run commands on other machines** in your
|
||||||
|
network without installing a full macOS companion app there.
|
||||||
|
|
||||||
|
Common use cases:
|
||||||
|
- Run commands on remote Linux/Windows boxes (build servers, lab machines, NAS).
|
||||||
|
- Keep exec **sandboxed** on the gateway, but delegate approved runs to other hosts.
|
||||||
|
- Provide a lightweight, headless execution target for automation or CI nodes.
|
||||||
|
|
||||||
|
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
||||||
|
node host, so you can keep command access scoped and explicit.
|
||||||
|
|
||||||
|
## Start (foreground)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node start --host <gateway-host> --port 18790
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
||||||
|
- `--port <port>`: Gateway bridge port (default: `18790`)
|
||||||
|
- `--tls`: Use TLS for the bridge connection
|
||||||
|
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
||||||
|
- `--node-id <id>`: Override node id (clears pairing token)
|
||||||
|
- `--display-name <name>`: Override the node display name
|
||||||
|
|
||||||
|
## Service (background)
|
||||||
|
|
||||||
|
Install a headless node host as a user service.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node service install --host <gateway-host> --port 18790
|
||||||
|
# or
|
||||||
|
clawdbot service node install --host <gateway-host> --port 18790
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
||||||
|
- `--port <port>`: Gateway bridge port (default: `18790`)
|
||||||
|
- `--tls`: Use TLS for the bridge connection
|
||||||
|
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
||||||
|
- `--node-id <id>`: Override node id (clears pairing token)
|
||||||
|
- `--display-name <name>`: Override the node display name
|
||||||
|
- `--runtime <runtime>`: Service runtime (`node` or `bun`)
|
||||||
|
- `--force`: Reinstall/overwrite if already installed
|
||||||
|
|
||||||
|
Manage the service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node status
|
||||||
|
clawdbot service node status
|
||||||
|
clawdbot node service status
|
||||||
|
clawdbot node service start
|
||||||
|
clawdbot node service stop
|
||||||
|
clawdbot node service restart
|
||||||
|
clawdbot node service uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy alias:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node daemon status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pairing
|
||||||
|
|
||||||
|
The first connection creates a pending node pair request on the Gateway.
|
||||||
|
Approve it via:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot nodes pending
|
||||||
|
clawdbot nodes approve <requestId>
|
||||||
|
```
|
||||||
|
|
||||||
|
The node host stores its node id + token in `~/.clawdbot/node.json`.
|
||||||
|
|
||||||
|
## Exec approvals
|
||||||
|
|
||||||
|
`system.run` is gated by local exec approvals:
|
||||||
|
|
||||||
|
- `~/.clawdbot/exec-approvals.json`
|
||||||
|
- [Exec approvals](/tools/exec-approvals)
|
||||||
|
- `clawdbot approvals --node <id|name|ip>` (edit from the Gateway)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
summary: "CLI reference for `clawdbot service` (manage gateway + node services)"
|
||||||
|
read_when:
|
||||||
|
- You want to manage Gateway or node services cross-platform
|
||||||
|
- You want a single surface for start/stop/install/uninstall
|
||||||
|
---
|
||||||
|
|
||||||
|
# `clawdbot service`
|
||||||
|
|
||||||
|
Manage the **Gateway** service and **node host** services.
|
||||||
|
|
||||||
|
Related:
|
||||||
|
- Gateway daemon (legacy alias): [Daemon](/cli/daemon)
|
||||||
|
- Node host: [Node](/cli/node)
|
||||||
|
|
||||||
|
## Gateway service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot service gateway status
|
||||||
|
clawdbot service gateway install --port 18789
|
||||||
|
clawdbot service gateway start
|
||||||
|
clawdbot service gateway stop
|
||||||
|
clawdbot service gateway restart
|
||||||
|
clawdbot service gateway uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `service gateway status` supports `--json` and `--deep` for system checks.
|
||||||
|
- `service gateway install` supports `--runtime node|bun` and `--token`.
|
||||||
|
|
||||||
|
## Node host service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot service node status
|
||||||
|
clawdbot service node install --host <gateway-host> --port 18790
|
||||||
|
clawdbot service node start
|
||||||
|
clawdbot service node stop
|
||||||
|
clawdbot service node restart
|
||||||
|
clawdbot service node uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `service node install` supports `--runtime node|bun`, `--node-id`, `--display-name`,
|
||||||
|
and TLS options (`--tls`, `--tls-fingerprint`).
|
||||||
|
|
||||||
|
## Aliases
|
||||||
|
|
||||||
|
- `clawdbot daemon …` → `clawdbot service gateway …`
|
||||||
|
- `clawdbot node service …` → `clawdbot service node …`
|
||||||
|
- `clawdbot node status` → `clawdbot service node status`
|
||||||
|
- `clawdbot node daemon …` → `clawdbot service node …` (legacy)
|
||||||
@@ -19,4 +19,5 @@ clawdbot status --usage
|
|||||||
Notes:
|
Notes:
|
||||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||||
- Output includes per-agent session stores when multiple agents are configured.
|
- Output includes per-agent session stores when multiple agents are configured.
|
||||||
|
- Overview includes Gateway + Node service install/runtime status when available.
|
||||||
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
||||||
@@ -5,13 +5,19 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Agent Loop (Clawdbot)
|
# Agent Loop (Clawdbot)
|
||||||
|
|
||||||
Short, exact flow of one agent run.
|
An agentic loop is the full “real” run of an agent: intake → context assembly → model inference →
|
||||||
|
tool execution → streaming replies → persistence. It’s the authoritative path that turns a message
|
||||||
|
into actions and a final reply, while keeping session state consistent.
|
||||||
|
|
||||||
|
In Clawdbot, a loop is a single, serialized run per session that emits lifecycle and stream events
|
||||||
|
as the model thinks, calls tools, and streams output. This doc explains how that authentic loop is
|
||||||
|
wired end-to-end.
|
||||||
|
|
||||||
## Entry points
|
## Entry points
|
||||||
- Gateway RPC: `agent` and `agent.wait`.
|
- Gateway RPC: `agent` and `agent.wait`.
|
||||||
- CLI: `agent` command.
|
- CLI: `agent` command.
|
||||||
|
|
||||||
## High-level flow
|
## How it works (high-level)
|
||||||
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
|
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
|
||||||
2) `agentCommand` runs the agent:
|
2) `agentCommand` runs the agent:
|
||||||
- resolves model + thinking/verbose defaults
|
- resolves model + thinking/verbose defaults
|
||||||
@@ -19,8 +25,9 @@ Short, exact flow of one agent run.
|
|||||||
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
|
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
|
||||||
- emits **lifecycle end/error** if the embedded loop does not emit one
|
- emits **lifecycle end/error** if the embedded loop does not emit one
|
||||||
3) `runEmbeddedPiAgent`:
|
3) `runEmbeddedPiAgent`:
|
||||||
- builds `AgentSession` and subscribes to pi events
|
- serializes runs via per-session + global queues
|
||||||
- streams assistant deltas + tool events
|
- resolves model + auth profile and builds the pi session
|
||||||
|
- subscribes to pi events and streams assistant/tool deltas
|
||||||
- enforces timeout -> aborts run if exceeded
|
- enforces timeout -> aborts run if exceeded
|
||||||
- returns payloads + usage metadata
|
- returns payloads + usage metadata
|
||||||
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream:
|
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream:
|
||||||
@@ -31,6 +38,73 @@ Short, exact flow of one agent run.
|
|||||||
- waits for **lifecycle end/error** for `runId`
|
- waits for **lifecycle end/error** for `runId`
|
||||||
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
|
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
|
||||||
|
|
||||||
|
## Queueing + concurrency
|
||||||
|
- Runs are serialized per session key (session lane) and optionally through a global lane.
|
||||||
|
- This prevents tool/session races and keeps session history consistent.
|
||||||
|
- Messaging channels can choose queue modes (collect/steer/followup) that feed this lane system.
|
||||||
|
See [Command Queue](/concepts/queue).
|
||||||
|
|
||||||
|
## Session + workspace preparation
|
||||||
|
- Workspace is resolved and created; sandboxed runs may redirect to a sandbox workspace root.
|
||||||
|
- Skills are loaded (or reused from a snapshot) and injected into env and prompt.
|
||||||
|
- Bootstrap/context files are resolved and injected into the system prompt report.
|
||||||
|
- A session write lock is acquired; `SessionManager` is opened and prepared before streaming.
|
||||||
|
|
||||||
|
## Prompt assembly + system prompt
|
||||||
|
- System prompt is built from Clawdbot’s base prompt, skills prompt, bootstrap context, and per-run overrides.
|
||||||
|
- Model-specific limits and compaction reserve tokens are enforced.
|
||||||
|
- See [System prompt](/concepts/system-prompt) for what the model sees.
|
||||||
|
|
||||||
|
## Hook points (where you can intercept)
|
||||||
|
Clawdbot has two hook systems:
|
||||||
|
- **Internal hooks** (Gateway hooks): event-driven scripts for commands and lifecycle events.
|
||||||
|
- **Plugin hooks**: extension points inside the agent/tool lifecycle and gateway pipeline.
|
||||||
|
|
||||||
|
### Internal hooks (Gateway hooks)
|
||||||
|
- **`agent:bootstrap`**: runs while building bootstrap files before the system prompt is finalized.
|
||||||
|
Use this to add/remove bootstrap context files.
|
||||||
|
- **Command hooks**: `/new`, `/reset`, `/stop`, and other command events (see Hooks doc).
|
||||||
|
|
||||||
|
See [Hooks](/hooks) for setup and examples.
|
||||||
|
|
||||||
|
### Plugin hooks (agent + gateway lifecycle)
|
||||||
|
These run inside the agent loop or gateway pipeline:
|
||||||
|
- **`before_agent_start`**: inject context or override system prompt before the run starts.
|
||||||
|
- **`agent_end`**: inspect the final message list and run metadata after completion.
|
||||||
|
- **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles.
|
||||||
|
- **`before_tool_call` / `after_tool_call`**: intercept tool params/results.
|
||||||
|
- **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks.
|
||||||
|
- **`session_start` / `session_end`**: session lifecycle boundaries.
|
||||||
|
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
|
||||||
|
|
||||||
|
See [Plugins](/plugin#plugin-hooks) for the hook API and registration details.
|
||||||
|
|
||||||
|
## Streaming + partial replies
|
||||||
|
- Assistant deltas are streamed from pi-agent-core and emitted as `assistant` events.
|
||||||
|
- Block streaming can emit partial replies either on `text_end` or `message_end`.
|
||||||
|
- Reasoning streaming can be emitted as a separate stream or as block replies.
|
||||||
|
- See [Streaming](/concepts/streaming) for chunking and block reply behavior.
|
||||||
|
|
||||||
|
## Tool execution + messaging tools
|
||||||
|
- Tool start/update/end events are emitted on the `tool` stream.
|
||||||
|
- Tool results are sanitized for size and image payloads before logging/emitting.
|
||||||
|
- Messaging tool sends are tracked to suppress duplicate assistant confirmations.
|
||||||
|
|
||||||
|
## Reply shaping + suppression
|
||||||
|
- Final payloads are assembled from:
|
||||||
|
- assistant text (and optional reasoning)
|
||||||
|
- inline tool summaries (when verbose + allowed)
|
||||||
|
- assistant error text when the model errors
|
||||||
|
- `NO_REPLY` is treated as a silent token and filtered from outgoing payloads.
|
||||||
|
- Messaging tool duplicates are removed from the final payload list.
|
||||||
|
- If no renderable payloads remain and a tool errored, a fallback tool error reply is emitted
|
||||||
|
(unless a messaging tool already sent a user-visible reply).
|
||||||
|
|
||||||
|
## Compaction + retries
|
||||||
|
- Auto-compaction emits `compaction` stream events and can trigger a retry.
|
||||||
|
- On retry, in-memory buffers and tool summaries are reset to avoid duplicate output.
|
||||||
|
- See [Compaction](/concepts/compaction) for the compaction pipeline.
|
||||||
|
|
||||||
## Event streams (today)
|
## Event streams (today)
|
||||||
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
|
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
|
||||||
- `assistant`: streamed deltas from pi-agent-core
|
- `assistant`: streamed deltas from pi-agent-core
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ These are the standard files Clawdbot expects inside the workspace:
|
|||||||
- Optional tiny checklist for heartbeat runs.
|
- Optional tiny checklist for heartbeat runs.
|
||||||
- Keep it short to avoid token burn.
|
- Keep it short to avoid token burn.
|
||||||
|
|
||||||
|
- `BOOT.md`
|
||||||
|
- Optional startup checklist executed on gateway restart when internal hooks are enabled.
|
||||||
|
- Keep it short; use the message tool for outbound sends.
|
||||||
|
|
||||||
- `BOOTSTRAP.md`
|
- `BOOTSTRAP.md`
|
||||||
- One-time first-run ritual.
|
- One-time first-run ritual.
|
||||||
- Only created for a brand-new workspace.
|
- Only created for a brand-new workspace.
|
||||||
|
|||||||
+22
-3
@@ -89,7 +89,26 @@ OAuth only covers chat/completions and does **not** satisfy embeddings for
|
|||||||
memory search. When using a custom OpenAI-compatible endpoint, set
|
memory search. When using a custom OpenAI-compatible endpoint, set
|
||||||
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
||||||
|
|
||||||
If you want to use a **custom OpenAI-compatible endpoint** (like Gemini, OpenRouter, or a proxy),
|
If you want to use **Gemini embeddings** directly, set the provider to `gemini`:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
memorySearch: {
|
||||||
|
provider: "gemini",
|
||||||
|
model: "gemini-embedding-001", // default
|
||||||
|
remote: {
|
||||||
|
apiKey: "${GEMINI_API_KEY}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemini uses `GEMINI_API_KEY` (or `models.providers.google.apiKey`). Override
|
||||||
|
`memorySearch.remote.baseUrl` to point at a custom Gemini-compatible endpoint.
|
||||||
|
|
||||||
|
If you want to use a **custom OpenAI-compatible endpoint** (like OpenRouter or a proxy),
|
||||||
you can use the `remote` configuration:
|
you can use the `remote` configuration:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@@ -99,8 +118,8 @@ agents: {
|
|||||||
provider: "openai",
|
provider: "openai",
|
||||||
model: "text-embedding-3-small",
|
model: "text-embedding-3-small",
|
||||||
remote: {
|
remote: {
|
||||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
baseUrl: "https://proxy.example/v1",
|
||||||
apiKey: "YOUR_GEMINI_API_KEY",
|
apiKey: "YOUR_PROXY_KEY",
|
||||||
headers: { "X-Custom-Header": "value" }
|
headers: { "X-Custom-Header": "value" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ It does **not** rotate on every request. The pinned profile is reused until:
|
|||||||
Manual selection via `/model …@<profileId>` sets a **user override** for that session
|
Manual selection via `/model …@<profileId>` sets a **user override** for that session
|
||||||
and is not auto‑rotated until a new session starts.
|
and is not auto‑rotated until a new session starts.
|
||||||
|
|
||||||
|
Auto‑pinned profiles (selected by the session router) are treated as a **preference**:
|
||||||
|
they are tried first, but Clawdbot may rotate to another profile on rate limits/timeouts.
|
||||||
|
User‑pinned profiles stay locked to that profile; if it fails and model fallbacks
|
||||||
|
are configured, Clawdbot moves to the next model instead of switching profiles.
|
||||||
|
|
||||||
### Why OAuth can “look lost”
|
### Why OAuth can “look lost”
|
||||||
|
|
||||||
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
|
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ The prompt is intentionally compact and uses fixed sections:
|
|||||||
- **Skills** (when available): tells the model how to load skill instructions on demand.
|
- **Skills** (when available): tells the model how to load skill instructions on demand.
|
||||||
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
|
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
|
||||||
- **Workspace**: working directory (`agents.defaults.workspace`).
|
- **Workspace**: working directory (`agents.defaults.workspace`).
|
||||||
|
- **Documentation**: local path to Clawdbot docs (repo or npm package) and when to read them.
|
||||||
- **Workspace Files (injected)**: indicates bootstrap files are included below.
|
- **Workspace Files (injected)**: indicates bootstrap files are included below.
|
||||||
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
|
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
|
||||||
- **Current Date & Time**: user-local time, timezone, and time format.
|
- **Current Date & Time**: user-local time, timezone, and time format.
|
||||||
@@ -98,3 +99,12 @@ Skills section is omitted.
|
|||||||
```
|
```
|
||||||
|
|
||||||
This keeps the base prompt small while still enabling targeted skill usage.
|
This keeps the base prompt small while still enabling targeted skill usage.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
When available, the system prompt includes a **Documentation** section that points to the
|
||||||
|
local Clawdbot docs directory (either `docs/` in the repo workspace or the bundled npm
|
||||||
|
package docs) and also notes the public mirror, source repo, community Discord, and
|
||||||
|
ClawdHub (https://clawdhub.com) for skills discovery. The prompt instructs the model to consult local docs first
|
||||||
|
for Clawdbot behavior, commands, configuration, or architecture, and to run
|
||||||
|
`clawdbot status` itself when possible (asking the user only when it lacks access).
|
||||||
@@ -77,6 +77,7 @@ What this does:
|
|||||||
- Seeds the workspace files if missing:
|
- Seeds the workspace files if missing:
|
||||||
`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`.
|
`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`.
|
||||||
- Default identity: **C3‑PO** (protocol droid).
|
- Default identity: **C3‑PO** (protocol droid).
|
||||||
|
- Skips channel providers in dev mode (`CLAWDBOT_SKIP_CHANNELS=1`).
|
||||||
|
|
||||||
Reset flow (fresh start):
|
Reset flow (fresh start):
|
||||||
|
|
||||||
|
|||||||
@@ -657,6 +657,10 @@
|
|||||||
"source": "/templates/AGENTS",
|
"source": "/templates/AGENTS",
|
||||||
"destination": "/reference/templates/AGENTS"
|
"destination": "/reference/templates/AGENTS"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"source": "/templates/BOOT",
|
||||||
|
"destination": "/reference/templates/BOOT"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"source": "/templates/BOOTSTRAP",
|
"source": "/templates/BOOTSTRAP",
|
||||||
"destination": "/reference/templates/BOOTSTRAP"
|
"destination": "/reference/templates/BOOTSTRAP"
|
||||||
@@ -822,8 +826,10 @@
|
|||||||
"cli/models",
|
"cli/models",
|
||||||
"cli/logs",
|
"cli/logs",
|
||||||
"cli/nodes",
|
"cli/nodes",
|
||||||
|
"cli/approvals",
|
||||||
"cli/gateway",
|
"cli/gateway",
|
||||||
"cli/daemon",
|
"cli/daemon",
|
||||||
|
"cli/service",
|
||||||
"cli/tui",
|
"cli/tui",
|
||||||
"cli/voicecall",
|
"cli/voicecall",
|
||||||
"cli/wake",
|
"cli/wake",
|
||||||
@@ -1051,6 +1057,7 @@
|
|||||||
"reference/RELEASING",
|
"reference/RELEASING",
|
||||||
"reference/AGENTS.default",
|
"reference/AGENTS.default",
|
||||||
"reference/templates/AGENTS",
|
"reference/templates/AGENTS",
|
||||||
|
"reference/templates/BOOT",
|
||||||
"reference/templates/BOOTSTRAP",
|
"reference/templates/BOOTSTRAP",
|
||||||
"reference/templates/HEARTBEAT",
|
"reference/templates/HEARTBEAT",
|
||||||
"reference/templates/IDENTITY",
|
"reference/templates/IDENTITY",
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
|
|||||||
## Frames
|
## Frames
|
||||||
|
|
||||||
Client → Gateway:
|
Client → Gateway:
|
||||||
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake)
|
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake, skills.bins)
|
||||||
- `event`: node signals (voice transcript, agent request, chat subscribe)
|
- `event`: node signals (voice transcript, agent request, chat subscribe, exec lifecycle)
|
||||||
|
|
||||||
Gateway → Client:
|
Gateway → Client:
|
||||||
- `invoke` / `invoke-res`: node commands (`canvas.*`, `camera.*`, `screen.record`,
|
- `invoke` / `invoke-res`: node commands (`canvas.*`, `camera.*`, `screen.record`,
|
||||||
@@ -57,6 +57,18 @@ Gateway → Client:
|
|||||||
|
|
||||||
Exact allowlist is enforced in `src/gateway/server-bridge.ts`.
|
Exact allowlist is enforced in `src/gateway/server-bridge.ts`.
|
||||||
|
|
||||||
|
## Exec lifecycle events
|
||||||
|
|
||||||
|
Nodes can emit `exec.started`, `exec.finished`, or `exec.denied` events to surface
|
||||||
|
system.run activity. These are mapped to system events in the gateway.
|
||||||
|
|
||||||
|
Payload fields (all optional unless noted):
|
||||||
|
- `sessionKey` (required): agent session to receive the system event.
|
||||||
|
- `runId`: unique exec id for grouping.
|
||||||
|
- `command`: raw or formatted command string.
|
||||||
|
- `exitCode`, `timedOut`, `success`, `output`: completion details (finished only).
|
||||||
|
- `reason`: denial reason (denied only).
|
||||||
|
|
||||||
## Tailnet usage
|
## Tailnet usage
|
||||||
|
|
||||||
- Bind the bridge to a tailnet IP: `bridge.bind: "tailnet"` in
|
- Bind the bridge to a tailnet IP: `bridge.bind: "tailnet"` in
|
||||||
|
|||||||
@@ -261,10 +261,9 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
|||||||
ackMaxChars: 300
|
ackMaxChars: 300
|
||||||
},
|
},
|
||||||
memorySearch: {
|
memorySearch: {
|
||||||
provider: "openai",
|
provider: "gemini",
|
||||||
model: "text-embedding-004",
|
model: "gemini-embedding-001",
|
||||||
remote: {
|
remote: {
|
||||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
||||||
apiKey: "${GEMINI_API_KEY}"
|
apiKey: "${GEMINI_API_KEY}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ stronger isolation between agents, run them under separate OS users or separate
|
|||||||
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
|
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
|
||||||
|
|
||||||
- Requires node pairing (approval + token).
|
- Requires node pairing (approval + token).
|
||||||
- Controlled on the Mac via **Settings → "Node Run Commands"**: "Always Ask" (default), "Always Allow", or "Never".
|
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
|
||||||
- If you don’t want remote execution, set the policy to "Never" and remove node pairing for that Mac.
|
- If you don’t want remote execution, set security to **deny** and remove node pairing for that Mac.
|
||||||
|
|
||||||
## Dynamic skills (watcher / remote nodes)
|
## Dynamic skills (watcher / remote nodes)
|
||||||
|
|
||||||
|
|||||||
+30
-2
@@ -37,10 +37,11 @@ The hooks system allows you to:
|
|||||||
|
|
||||||
### Bundled Hooks
|
### Bundled Hooks
|
||||||
|
|
||||||
Clawdbot ships with three bundled hooks that are automatically discovered:
|
Clawdbot ships with four bundled hooks that are automatically discovered:
|
||||||
|
|
||||||
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
|
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
|
||||||
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
|
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
|
||||||
|
- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled)
|
||||||
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
|
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
|
||||||
|
|
||||||
List available hooks:
|
List available hooks:
|
||||||
@@ -195,7 +196,7 @@ Each event includes:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
type: 'command' | 'session' | 'agent',
|
type: 'command' | 'session' | 'agent' | 'gateway',
|
||||||
action: string, // e.g., 'new', 'reset', 'stop'
|
action: string, // e.g., 'new', 'reset', 'stop'
|
||||||
sessionKey: string, // Session identifier
|
sessionKey: string, // Session identifier
|
||||||
timestamp: Date, // When the event occurred
|
timestamp: Date, // When the event occurred
|
||||||
@@ -228,6 +229,12 @@ Triggered when agent commands are issued:
|
|||||||
|
|
||||||
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
|
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
|
||||||
|
|
||||||
|
### Gateway Events
|
||||||
|
|
||||||
|
Triggered when the gateway starts:
|
||||||
|
|
||||||
|
- **`gateway:startup`**: After channels start and hooks are loaded
|
||||||
|
|
||||||
### Future Events
|
### Future Events
|
||||||
|
|
||||||
Planned event types:
|
Planned event types:
|
||||||
@@ -542,6 +549,26 @@ clawdbot hooks enable soul-evil
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### boot-md
|
||||||
|
|
||||||
|
Runs `BOOT.md` when the gateway starts (after channels start).
|
||||||
|
Internal hooks must be enabled for this to run.
|
||||||
|
|
||||||
|
**Events**: `gateway:startup`
|
||||||
|
|
||||||
|
**Requirements**: `workspace.dir` must be configured
|
||||||
|
|
||||||
|
**What it does**:
|
||||||
|
1. Reads `BOOT.md` from your workspace
|
||||||
|
2. Runs the instructions via the agent runner
|
||||||
|
3. Sends any requested outbound messages via the message tool
|
||||||
|
|
||||||
|
**Enable**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot hooks enable boot-md
|
||||||
|
```
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
### Keep Handlers Fast
|
### Keep Handlers Fast
|
||||||
@@ -614,6 +641,7 @@ The gateway logs hook loading at startup:
|
|||||||
```
|
```
|
||||||
Registered hook: session-memory -> command:new
|
Registered hook: session-memory -> command:new
|
||||||
Registered hook: command-logger -> command
|
Registered hook: command-logger -> command
|
||||||
|
Registered hook: boot-md -> gateway:startup
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Discovery
|
### Check Discovery
|
||||||
|
|||||||
+50
-3
@@ -147,9 +147,10 @@ Notes:
|
|||||||
- The permission prompt must be accepted on the Android device before the capability is advertised.
|
- The permission prompt must be accepted on the Android device before the capability is advertised.
|
||||||
- Wi-Fi-only devices without telephony will not advertise `sms.send`.
|
- Wi-Fi-only devices without telephony will not advertise `sms.send`.
|
||||||
|
|
||||||
## System commands (mac node)
|
## System commands (node host / mac node)
|
||||||
|
|
||||||
The macOS node exposes `system.run` and `system.notify`.
|
The macOS node exposes `system.run`, `system.notify`, and `system.execApprovals.get/set`.
|
||||||
|
The headless node host exposes `system.run`, `system.which`, and `system.execApprovals.get/set`.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
@@ -163,12 +164,58 @@ Notes:
|
|||||||
- `system.notify` respects notification permission state on the macOS app.
|
- `system.notify` respects notification permission state on the macOS app.
|
||||||
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
|
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
|
||||||
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
|
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
|
||||||
- `system.run` is gated by the macOS app policy (Settings → "Node Run Commands"): "Always Ask" prompts per command, "Always Allow" runs without prompts, and "Never" disables the tool. Denied prompts return `SYSTEM_RUN_DENIED`; disabled returns `SYSTEM_RUN_DISABLED`.
|
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
|
||||||
|
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
|
||||||
|
- On headless node host, `system.run` is gated by exec approvals (`~/.clawdbot/exec-approvals.json`).
|
||||||
|
|
||||||
|
## Exec node binding
|
||||||
|
|
||||||
|
When multiple nodes are available, you can bind exec to a specific node.
|
||||||
|
This sets the default node for `exec host=node` (and can be overridden per agent).
|
||||||
|
|
||||||
|
Global default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config set tools.exec.node "node-id-or-name"
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-agent override:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config get agents.list
|
||||||
|
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
|
||||||
|
```
|
||||||
|
|
||||||
|
Unset to allow any node:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config unset tools.exec.node
|
||||||
|
clawdbot config unset agents.list[0].tools.exec.node
|
||||||
|
```
|
||||||
|
|
||||||
## Permissions map
|
## Permissions map
|
||||||
|
|
||||||
Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted).
|
Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted).
|
||||||
|
|
||||||
|
## Headless node host (cross-platform)
|
||||||
|
|
||||||
|
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
|
||||||
|
bridge and exposes `system.run` / `system.which`. This is useful on Linux/Windows
|
||||||
|
or for running a minimal node alongside a server.
|
||||||
|
|
||||||
|
Start it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node start --host <gateway-host> --port 18790
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Pairing is still required (the Gateway will show a node approval prompt).
|
||||||
|
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
|
||||||
|
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
|
||||||
|
(see [Exec approvals](/tools/exec-approvals)).
|
||||||
|
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
|
||||||
|
|
||||||
## Mac node mode
|
## Mac node mode
|
||||||
|
|
||||||
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
|
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
|
||||||
|
|||||||
@@ -104,6 +104,29 @@ Rules:
|
|||||||
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
||||||
**active reply model** when its provider supports the capability.
|
**active reply model** when its provider supports the capability.
|
||||||
|
|
||||||
|
### Auto-enable audio (when keys exist)
|
||||||
|
If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
|
||||||
|
audio provider keys configured, Clawdbot will **auto-enable audio transcription**
|
||||||
|
even when you haven’t listed models explicitly.
|
||||||
|
|
||||||
|
Providers checked (in order):
|
||||||
|
1) OpenAI
|
||||||
|
2) Groq
|
||||||
|
3) Deepgram
|
||||||
|
|
||||||
|
To disable this behavior, set:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
tools: {
|
||||||
|
media: {
|
||||||
|
audio: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Capabilities (optional)
|
## Capabilities (optional)
|
||||||
If you set `capabilities`, the entry only runs for those media types. For shared
|
If you set `capabilities`, the entry only runs for those media types. For shared
|
||||||
lists, Clawdbot can infer defaults:
|
lists, Clawdbot can infer defaults:
|
||||||
|
|||||||
@@ -14,15 +14,7 @@ Before building the app, ensure you have the following installed:
|
|||||||
1. **Xcode 26.2+**: Required for Swift development.
|
1. **Xcode 26.2+**: Required for Swift development.
|
||||||
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
|
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
|
||||||
|
|
||||||
## 1. Initialize Submodules
|
## 1. Install Dependencies
|
||||||
|
|
||||||
Clawdbot depends on several submodules (like `Peekaboo`). You must initialize these recursively:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git submodule update --init --recursive
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Install Dependencies
|
|
||||||
|
|
||||||
Install the project-wide dependencies:
|
Install the project-wide dependencies:
|
||||||
|
|
||||||
@@ -30,7 +22,7 @@ Install the project-wide dependencies:
|
|||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Build and Package the App
|
## 2. Build and Package the App
|
||||||
|
|
||||||
To build the macOS app and package it into `dist/Clawdbot.app`, run:
|
To build the macOS app and package it into `dist/Clawdbot.app`, run:
|
||||||
|
|
||||||
@@ -42,7 +34,7 @@ If you don't have an Apple Developer ID certificate, the script will automatical
|
|||||||
|
|
||||||
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
||||||
|
|
||||||
## 4. Install the CLI
|
## 3. Install the CLI
|
||||||
|
|
||||||
The macOS app expects a global `clawdbot` CLI install to manage background tasks.
|
The macOS app expects a global `clawdbot` CLI install to manage background tasks.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
summary: "PeekabooBridge integration for macOS UI automation"
|
summary: "PeekabooBridge integration for macOS UI automation"
|
||||||
read_when:
|
read_when:
|
||||||
- Hosting PeekabooBridge in Clawdbot.app
|
- Hosting PeekabooBridge in Clawdbot.app
|
||||||
- Integrating Peekaboo as a submodule
|
- Integrating Peekaboo via Swift Package Manager
|
||||||
- Changing PeekabooBridge protocol/paths
|
- Changing PeekabooBridge protocol/paths
|
||||||
---
|
---
|
||||||
# Peekaboo Bridge (macOS UI automation)
|
# Peekaboo Bridge (macOS UI automation)
|
||||||
|
|||||||
+18
-15
@@ -54,29 +54,32 @@ The macOS app presents itself as a node. Common commands:
|
|||||||
|
|
||||||
The node reports a `permissions` map so agents can decide what’s allowed.
|
The node reports a `permissions` map so agents can decide what’s allowed.
|
||||||
|
|
||||||
## Node run policy + allowlist
|
## Exec approvals (system.run)
|
||||||
|
|
||||||
`system.run` is controlled by the macOS app **Node Run Commands** policy:
|
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
|
||||||
|
Security + ask + allowlist are stored locally on the Mac in:
|
||||||
- `Always Ask`: prompt per command (default).
|
|
||||||
- `Always Allow`: run without prompts.
|
|
||||||
- `Never`: disable `system.run` (tool not advertised).
|
|
||||||
|
|
||||||
The policy + allowlist live on the Mac in:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.clawdbot/macos-node.json
|
~/.clawdbot/exec-approvals.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema:
|
Example:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"systemRun": {
|
"version": 1,
|
||||||
"policy": "ask",
|
"defaults": {
|
||||||
"allowlist": [
|
"security": "deny",
|
||||||
"[\"/bin/echo\",\"hello\"]"
|
"ask": "on-miss"
|
||||||
]
|
},
|
||||||
|
"agents": {
|
||||||
|
"main": {
|
||||||
|
"security": "allowlist",
|
||||||
|
"ask": "on-miss",
|
||||||
|
"allowlist": [
|
||||||
|
{ "pattern": "/opt/homebrew/bin/rg" }
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -37,6 +37,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
|||||||
|
|
||||||
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
||||||
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
|
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
|
||||||
|
- Memory (LanceDB) — bundled long-term memory plugin (auto-recall/capture; set `plugins.slots.memory = "memory-lancedb"`)
|
||||||
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||||
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||||
@@ -45,7 +46,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
|||||||
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
||||||
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
|
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
|
||||||
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
||||||
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
|
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
|
||||||
|
|
||||||
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
||||||
register:
|
register:
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Github Copilot
|
# Github Copilot
|
||||||
|
|
||||||
|
## What is GitHub Copilot?
|
||||||
|
|
||||||
|
GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot
|
||||||
|
models for your GitHub account and plan. Clawdbot can use Copilot as a model
|
||||||
|
provider in two different ways.
|
||||||
|
|
||||||
|
## Two ways to use Copilot in Clawdbot
|
||||||
|
|
||||||
|
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
||||||
|
|
||||||
|
Use the native device-login flow to obtain a GitHub token, then exchange it for
|
||||||
|
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
|
||||||
|
because it does not require VS Code.
|
||||||
|
|
||||||
|
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
||||||
|
|
||||||
|
Use the **Copilot Proxy** VS Code extension as a local bridge. Clawdbot talks to
|
||||||
|
the proxy’s `/v1` endpoint and uses the model list you configure there. Choose
|
||||||
|
this when you already run Copilot Proxy in VS Code or need to route through it.
|
||||||
|
You must enable the plugin and keep the VS Code extension running.
|
||||||
|
|
||||||
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
|
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
|
||||||
the GitHub device flow, saves an auth profile, and updates your config to use that
|
the GitHub device flow, saves an auth profile, and updates your config to use that
|
||||||
profile.
|
profile.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ read_when:
|
|||||||
- **Runner:** headless system service; UI app hosts a Unix socket for approvals.
|
- **Runner:** headless system service; UI app hosts a Unix socket for approvals.
|
||||||
- **Node identity:** use existing `nodeId`.
|
- **Node identity:** use existing `nodeId`.
|
||||||
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
||||||
|
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
|
||||||
|
|
||||||
## Key concepts
|
## Key concepts
|
||||||
### Host
|
### Host
|
||||||
@@ -168,9 +169,9 @@ If UI missing:
|
|||||||
- Stored in the gateway in-memory queue (`enqueueSystemEvent`).
|
- Stored in the gateway in-memory queue (`enqueueSystemEvent`).
|
||||||
|
|
||||||
### Event text
|
### Event text
|
||||||
- `Exec started (host=node, node=<id>, id=<runId>)`
|
- `Exec started (node=<id>, id=<runId>)`
|
||||||
- `Exec finished (exit=<code>, tail=<...>)`
|
- `Exec finished (node=<id>, id=<runId>, code=<code>)` + optional output tail
|
||||||
- `Exec denied (policy=<...>, reason=<...>)`
|
- `Exec denied (node=<id>, id=<runId>, <reason>)`
|
||||||
|
|
||||||
### Transport
|
### Transport
|
||||||
Option A (recommended):
|
Option A (recommended):
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
summary: "Audit what can spend money, which keys are used, and how to view usage"
|
||||||
|
read_when:
|
||||||
|
- You want to understand which features may call paid APIs
|
||||||
|
- You need to audit keys, costs, and usage visibility
|
||||||
|
- You’re explaining /status or /usage cost reporting
|
||||||
|
---
|
||||||
|
# API usage & costs
|
||||||
|
|
||||||
|
This doc lists **features that can invoke API keys** and where their costs show up. It focuses on
|
||||||
|
Clawdbot features that can generate provider usage or paid API calls.
|
||||||
|
|
||||||
|
## Where costs show up (chat + CLI)
|
||||||
|
|
||||||
|
**Per-session cost snapshot**
|
||||||
|
- `/status` shows the current session model, context usage, and last response tokens.
|
||||||
|
- If the model uses **API-key auth**, `/status` also shows **estimated cost** for the last reply.
|
||||||
|
|
||||||
|
**Per-message cost footer**
|
||||||
|
- `/usage full` appends a usage footer to every reply, including **estimated cost** (API-key only).
|
||||||
|
- `/usage tokens` shows tokens only; OAuth flows hide dollar cost.
|
||||||
|
|
||||||
|
**CLI usage windows (provider quotas)**
|
||||||
|
- `clawdbot status --usage` and `clawdbot channels list` show provider **usage windows**
|
||||||
|
(quota snapshots, not per-message costs).
|
||||||
|
|
||||||
|
See [Token use & costs](/token-use) for details and examples.
|
||||||
|
|
||||||
|
## How keys are discovered
|
||||||
|
|
||||||
|
Clawdbot can pick up credentials from:
|
||||||
|
- **Auth profiles** (per-agent, stored in `auth-profiles.json`).
|
||||||
|
- **Environment variables** (e.g. `OPENAI_API_KEY`, `BRAVE_API_KEY`, `FIRECRAWL_API_KEY`).
|
||||||
|
- **Config** (`models.providers.*.apiKey`, `tools.web.search.*`, `tools.web.fetch.firecrawl.*`,
|
||||||
|
`memorySearch.*`, `talk.apiKey`).
|
||||||
|
- **Skills** (`skills.entries.<name>.apiKey`) which may export keys to the skill process env.
|
||||||
|
|
||||||
|
## Features that can spend keys
|
||||||
|
|
||||||
|
### 1) Core model responses (chat + tools)
|
||||||
|
Every reply or tool call uses the **current model provider** (OpenAI, Anthropic, etc). This is the
|
||||||
|
primary source of usage and cost.
|
||||||
|
|
||||||
|
See [Models](/providers/models) for pricing config and [Token use & costs](/token-use) for display.
|
||||||
|
|
||||||
|
### 2) Media understanding (audio/image/video)
|
||||||
|
Inbound media can be summarized/transcribed before the reply runs. This uses model/provider APIs.
|
||||||
|
|
||||||
|
- Audio: OpenAI / Groq / Deepgram (now **auto-enabled** when keys exist).
|
||||||
|
- Image: OpenAI / Anthropic / Google.
|
||||||
|
- Video: Google.
|
||||||
|
|
||||||
|
See [Media understanding](/nodes/media-understanding).
|
||||||
|
|
||||||
|
### 3) Memory embeddings + semantic search
|
||||||
|
Semantic memory search uses **embedding APIs** when configured for remote providers:
|
||||||
|
- `memorySearch.provider = "openai"` → OpenAI embeddings
|
||||||
|
- `memorySearch.provider = "gemini"` → Gemini embeddings
|
||||||
|
- Optional fallback to OpenAI if local embeddings fail
|
||||||
|
|
||||||
|
You can keep it local with `memorySearch.provider = "local"` (no API usage).
|
||||||
|
|
||||||
|
See [Memory](/concepts/memory).
|
||||||
|
|
||||||
|
### 4) Web search tool (Brave / Perplexity via OpenRouter)
|
||||||
|
`web_search` uses API keys and may incur usage charges:
|
||||||
|
|
||||||
|
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||||
|
- **Perplexity** (via OpenRouter): `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||||
|
|
||||||
|
**Brave free tier (generous):**
|
||||||
|
- **2,000 requests/month**
|
||||||
|
- **1 request/second**
|
||||||
|
- **Credit card required** for verification (no charge unless you upgrade)
|
||||||
|
|
||||||
|
See [Web tools](/tools/web).
|
||||||
|
|
||||||
|
### 5) Web fetch tool (Firecrawl)
|
||||||
|
`web_fetch` can call **Firecrawl** when an API key is present:
|
||||||
|
- `FIRECRAWL_API_KEY` or `tools.web.fetch.firecrawl.apiKey`
|
||||||
|
|
||||||
|
If Firecrawl isn’t configured, the tool falls back to direct fetch + readability (no paid API).
|
||||||
|
|
||||||
|
See [Web tools](/tools/web).
|
||||||
|
|
||||||
|
### 6) Provider usage snapshots (status/health)
|
||||||
|
Some status commands call **provider usage endpoints** to display quota windows or auth health.
|
||||||
|
These are typically low-volume calls but still hit provider APIs:
|
||||||
|
- `clawdbot status --usage`
|
||||||
|
- `clawdbot models status --json`
|
||||||
|
|
||||||
|
See [Models CLI](/cli/models).
|
||||||
|
|
||||||
|
### 7) Compaction safeguard summarization
|
||||||
|
The compaction safeguard can summarize session history using the **current model**, which
|
||||||
|
invokes provider APIs when it runs.
|
||||||
|
|
||||||
|
See [Session management + compaction](/reference/session-management-compaction).
|
||||||
|
|
||||||
|
### 8) Model scan / probe
|
||||||
|
`clawdbot models scan` can probe OpenRouter models and uses `OPENROUTER_API_KEY` when
|
||||||
|
probing is enabled.
|
||||||
|
|
||||||
|
See [Models CLI](/cli/models).
|
||||||
|
|
||||||
|
### 9) Talk (speech)
|
||||||
|
Talk mode can invoke **ElevenLabs** when configured:
|
||||||
|
- `ELEVENLABS_API_KEY` or `talk.apiKey`
|
||||||
|
|
||||||
|
See [Talk mode](/nodes/talk).
|
||||||
|
|
||||||
|
### 10) Skills (third-party APIs)
|
||||||
|
Skills can store `apiKey` in `skills.entries.<name>.apiKey`. If a skill uses that key for external
|
||||||
|
APIs, it can incur costs according to the skill’s provider.
|
||||||
|
|
||||||
|
See [Skills](/tools/skills).
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
summary: "Workspace template for BOOT.md"
|
||||||
|
read_when:
|
||||||
|
- Adding a BOOT.md checklist
|
||||||
|
---
|
||||||
|
# BOOT.md
|
||||||
|
|
||||||
|
Add short, explicit instructions for what Clawdbot should do on startup (enable `hooks.internal.enabled`).
|
||||||
|
If the task sends a message, use the message tool and then reply with NO_REPLY.
|
||||||
+19
-1
@@ -60,6 +60,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [Remote gateways + nodes](#remote-gateways-nodes)
|
- [Remote gateways + nodes](#remote-gateways-nodes)
|
||||||
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
||||||
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
|
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
|
||||||
|
- [Can I run a headless node host without the macOS app?](#can-i-run-a-headless-node-host-without-the-macos-app)
|
||||||
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
|
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
|
||||||
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
|
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
|
||||||
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
|
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
|
||||||
@@ -405,7 +406,7 @@ You have three supported patterns:
|
|||||||
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
|
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
|
||||||
|
|
||||||
**Option B - use a macOS node (no SSH).**
|
**Option B - use a macOS node (no SSH).**
|
||||||
Run the Gateway on Linux, pair a macOS node (menubar app), and set **Node Run Commands** to "Always Ask" or "Always Allow" on the Mac. Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Always Ask", approving "Always Allow" in the prompt adds that command to the allowlist.
|
Run the Gateway on Linux, pair a macOS node (menubar app), and configure **Exec approvals** (Settings → Exec approvals) to "Ask" or "Always Allow". Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Ask", selecting "Always Allow" in the prompt adds that command to the allowlist.
|
||||||
|
|
||||||
**Option C - proxy macOS binaries over SSH (advanced).**
|
**Option C - proxy macOS binaries over SSH (advanced).**
|
||||||
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
|
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
|
||||||
@@ -742,6 +743,23 @@ to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
|
|||||||
|
|
||||||
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
|
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
|
||||||
|
|
||||||
|
### Can I run a headless node host without the macOS app?
|
||||||
|
|
||||||
|
Yes. The headless node host is a **command-only** node that exposes `system.run` / `system.which`
|
||||||
|
without any UI. It has no screen/camera/notify support (use the macOS app for those).
|
||||||
|
|
||||||
|
Start it:
|
||||||
|
```bash
|
||||||
|
clawdbot node start --host <gateway-host> --port 18790
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Pairing is still required (`clawdbot nodes pending` → `clawdbot nodes approve <requestId>`).
|
||||||
|
- Exec approvals still apply via `~/.clawdbot/exec-approvals.json`.
|
||||||
|
- If prompts are enabled but no companion UI is reachable, `askFallback` decides (default: deny).
|
||||||
|
|
||||||
|
Docs: [Node CLI](/cli/node), [Nodes](/nodes), [Exec approvals](/tools/exec-approvals).
|
||||||
|
|
||||||
### Is there an API / RPC way to apply config?
|
### Is there an API / RPC way to apply config?
|
||||||
|
|
||||||
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
|
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ read_when:
|
|||||||
|
|
||||||
# Exec approvals
|
# Exec approvals
|
||||||
|
|
||||||
Exec approvals are the **companion app guardrail** for letting a sandboxed agent run
|
Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run
|
||||||
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
|
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
|
||||||
commands are allowed only when policy + allowlist + (optional) user approval all agree.
|
commands are allowed only when policy + allowlist + (optional) user approval all agree.
|
||||||
Exec approvals are **in addition** to tool policy and elevated gating.
|
Exec approvals are **in addition** to tool policy and elevated gating.
|
||||||
@@ -20,11 +20,11 @@ resolved by the **ask fallback** (default: deny).
|
|||||||
|
|
||||||
Exec approvals are enforced locally on the execution host:
|
Exec approvals are enforced locally on the execution host:
|
||||||
- **gateway host** → `clawdbot` process on the gateway machine
|
- **gateway host** → `clawdbot` process on the gateway machine
|
||||||
- **node host** → node runner (macOS companion app or headless node)
|
- **node host** → node runner (macOS companion app or headless node host)
|
||||||
|
|
||||||
## Settings and storage
|
## Settings and storage
|
||||||
|
|
||||||
Approvals live in a local JSON file:
|
Approvals live in a local JSON file on the execution host:
|
||||||
|
|
||||||
`~/.clawdbot/exec-approvals.json`
|
`~/.clawdbot/exec-approvals.json`
|
||||||
|
|
||||||
@@ -97,8 +97,22 @@ Each allowlist entry tracks:
|
|||||||
## Auto-allow skill CLIs
|
## Auto-allow skill CLIs
|
||||||
|
|
||||||
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
|
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
|
||||||
are treated as allowlisted (node hosts only). Disable this if you want strict
|
are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
|
||||||
manual allowlists.
|
gateway for the skill bin list. Disable this if you want strict manual allowlists.
|
||||||
|
|
||||||
|
## Control UI editing
|
||||||
|
|
||||||
|
Use the **Control UI → Nodes → Exec approvals** card to edit defaults, per‑agent
|
||||||
|
overrides, and allowlists. Pick a scope (Defaults or an agent), tweak the policy,
|
||||||
|
add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata
|
||||||
|
per pattern so you can keep the list tidy.
|
||||||
|
|
||||||
|
The target selector chooses **Gateway** (local approvals) or a **Node**. Nodes
|
||||||
|
must advertise `system.execApprovals.get/set` (macOS app or headless node host).
|
||||||
|
If a node does not advertise exec approvals yet, edit its local
|
||||||
|
`~/.clawdbot/exec-approvals.json` directly.
|
||||||
|
|
||||||
|
CLI: `clawdbot approvals` supports gateway or node editing (see [Approvals CLI](/cli/approvals)).
|
||||||
|
|
||||||
## Approval flow
|
## Approval flow
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -30,7 +30,7 @@ Notes:
|
|||||||
- `host` defaults to `sandbox`.
|
- `host` defaults to `sandbox`.
|
||||||
- `elevated` is ignored when sandboxing is off (exec already runs on the host).
|
- `elevated` is ignored when sandboxing is off (exec already runs on the host).
|
||||||
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
|
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
|
||||||
- `node` requires a paired node (macOS companion app).
|
- `node` requires a paired node (companion app or headless node host).
|
||||||
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
|
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
@@ -41,6 +41,15 @@ Notes:
|
|||||||
- `tools.exec.ask` (default: `on-miss`)
|
- `tools.exec.ask` (default: `on-miss`)
|
||||||
- `tools.exec.node` (default: unset)
|
- `tools.exec.node` (default: unset)
|
||||||
|
|
||||||
|
Per-agent node binding (use the agent list index in config):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config get agents.list
|
||||||
|
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
|
||||||
|
```
|
||||||
|
|
||||||
|
Control UI: the Nodes tab includes a small “Exec node binding” panel for the same settings.
|
||||||
|
|
||||||
## Session overrides (`/exec`)
|
## Session overrides (`/exec`)
|
||||||
|
|
||||||
Use `/exec` to set **per-session** defaults for `host`, `security`, `ask`, and `node`.
|
Use `/exec` to set **per-session** defaults for `host`, `security`, `ask`, and `node`.
|
||||||
@@ -51,7 +60,7 @@ Example:
|
|||||||
/exec host=gateway security=allowlist ask=on-miss node=mac-1
|
/exec host=gateway security=allowlist ask=on-miss node=mac-1
|
||||||
```
|
```
|
||||||
|
|
||||||
## Exec approvals (macOS app)
|
## Exec approvals (companion app / node host)
|
||||||
|
|
||||||
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
|
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
|
||||||
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ Notes:
|
|||||||
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
||||||
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
|
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
|
||||||
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
||||||
|
- `host=node` can target a macOS companion app or a headless node host (`clawdbot node start`).
|
||||||
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
|
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
|
||||||
|
|
||||||
### `process`
|
### `process`
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ Skills can also refresh mid-session when the skills watcher is enabled or when a
|
|||||||
|
|
||||||
## Remote macOS nodes (Linux gateway)
|
## Remote macOS nodes (Linux gateway)
|
||||||
|
|
||||||
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Node Run Commands policy not set to "Never"), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`).
|
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Exec approvals security not set to `deny`), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`).
|
||||||
|
|
||||||
This relies on the node reporting its command support and on a bin probe via `system.run`. If the macOS node goes offline later, the skills remain visible; invocations may fail until the node reconnects.
|
This relies on the node reporting its command support and on a bin probe via `system.run`. If the macOS node goes offline later, the skills remain visible; invocations may fail until the node reconnects.
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
|||||||
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
||||||
- Skills: status, enable/disable, install, API key updates (`skills.*`)
|
- Skills: status, enable/disable, install, API key updates (`skills.*`)
|
||||||
- Nodes: list + caps (`node.list`)
|
- Nodes: list + caps (`node.list`)
|
||||||
|
- Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
|
||||||
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
|
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
|
||||||
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
||||||
- Config writes include a base-hash guard to prevent clobbering concurrent edits
|
- Config writes include a base-hash guard to prevent clobbering concurrent edits
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
import { enqueueSystemEvent, formatAgentEnvelope, type ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||||
@@ -836,7 +836,7 @@ async function processMessage(
|
|||||||
const fromLabel = message.isGroup
|
const fromLabel = message.isGroup
|
||||||
? `group:${peerId}`
|
? `group:${peerId}`
|
||||||
: message.senderName || `user:${message.senderId}`;
|
: message.senderName || `user:${message.senderId}`;
|
||||||
const body = formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "BlueBubbles",
|
channel: "BlueBubbles",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
@@ -1058,7 +1058,7 @@ async function processReaction(
|
|||||||
const senderLabel = reaction.senderName || reaction.senderId;
|
const senderLabel = reaction.senderName || reaction.senderId;
|
||||||
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
||||||
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`;
|
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`;
|
||||||
enqueueSystemEvent(text, {
|
core.system.enqueueSystemEvent(text, {
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { discordPlugin } from "./src/channel.js";
|
||||||
|
import { setDiscordRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "discord",
|
||||||
|
name: "Discord",
|
||||||
|
description: "Discord channel plugin",
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setDiscordRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: discordPlugin });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/discord",
|
||||||
|
"version": "2026.1.17-1",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot Discord channel plugin",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": ["./index.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1,43 @@
|
|||||||
import {
|
|
||||||
listDiscordAccountIds,
|
|
||||||
type ResolvedDiscordAccount,
|
|
||||||
resolveDefaultDiscordAccountId,
|
|
||||||
resolveDiscordAccount,
|
|
||||||
} from "../../discord/accounts.js";
|
|
||||||
import {
|
|
||||||
auditDiscordChannelPermissions,
|
|
||||||
collectDiscordAuditChannelIds,
|
|
||||||
} from "../../discord/audit.js";
|
|
||||||
import { probeDiscord } from "../../discord/probe.js";
|
|
||||||
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
|
|
||||||
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
|
|
||||||
import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js";
|
|
||||||
import { shouldLogVerbose } from "../../globals.js";
|
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
|
||||||
import { getChatChannelMeta } from "../registry.js";
|
|
||||||
import { DiscordConfigSchema } from "../../config/zod-schema.providers-core.js";
|
|
||||||
import { discordMessageActions } from "./actions/discord.js";
|
|
||||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
|
||||||
import {
|
|
||||||
deleteAccountFromConfigSection,
|
|
||||||
setAccountEnabledInConfigSection,
|
|
||||||
} from "./config-helpers.js";
|
|
||||||
import { resolveDiscordGroupRequireMention } from "./group-mentions.js";
|
|
||||||
import { formatPairingApproveHint } from "./helpers.js";
|
|
||||||
import { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget } from "./normalize/discord.js";
|
|
||||||
import { discordOnboardingAdapter } from "./onboarding/discord.js";
|
|
||||||
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
|
|
||||||
import {
|
import {
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
migrateBaseNameToDefaultAccount,
|
buildChannelConfigSchema,
|
||||||
} from "./setup-helpers.js";
|
collectDiscordAuditChannelIds,
|
||||||
import { collectDiscordStatusIssues } from "./status-issues/discord.js";
|
collectDiscordStatusIssues,
|
||||||
import type { ChannelPlugin } from "./types.js";
|
DEFAULT_ACCOUNT_ID,
|
||||||
import {
|
deleteAccountFromConfigSection,
|
||||||
|
discordOnboardingAdapter,
|
||||||
|
DiscordConfigSchema,
|
||||||
|
formatPairingApproveHint,
|
||||||
|
getChatChannelMeta,
|
||||||
|
listDiscordAccountIds,
|
||||||
listDiscordDirectoryGroupsFromConfig,
|
listDiscordDirectoryGroupsFromConfig,
|
||||||
listDiscordDirectoryPeersFromConfig,
|
listDiscordDirectoryPeersFromConfig,
|
||||||
} from "./directory-config.js";
|
looksLikeDiscordTargetId,
|
||||||
import {
|
migrateBaseNameToDefaultAccount,
|
||||||
listDiscordDirectoryGroupsLive,
|
normalizeAccountId,
|
||||||
listDiscordDirectoryPeersLive,
|
normalizeDiscordMessagingTarget,
|
||||||
} from "../../discord/directory-live.js";
|
PAIRING_APPROVED_MESSAGE,
|
||||||
|
resolveDiscordAccount,
|
||||||
|
resolveDefaultDiscordAccountId,
|
||||||
|
resolveDiscordGroupRequireMention,
|
||||||
|
setAccountEnabledInConfigSection,
|
||||||
|
type ChannelMessageActionAdapter,
|
||||||
|
type ChannelPlugin,
|
||||||
|
type ResolvedDiscordAccount,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { getDiscordRuntime } from "./runtime.js";
|
||||||
|
|
||||||
const meta = getChatChannelMeta("discord");
|
const meta = getChatChannelMeta("discord");
|
||||||
|
|
||||||
|
const discordMessageActions: ChannelMessageActionAdapter = {
|
||||||
|
listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions.listActions(ctx),
|
||||||
|
extractToolSend: (ctx) =>
|
||||||
|
getDiscordRuntime().channel.discord.messageActions.extractToolSend(ctx),
|
||||||
|
handleAction: async (ctx) =>
|
||||||
|
await getDiscordRuntime().channel.discord.messageActions.handleAction(ctx),
|
||||||
|
};
|
||||||
|
|
||||||
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||||
id: "discord",
|
id: "discord",
|
||||||
meta: {
|
meta: {
|
||||||
@@ -54,7 +48,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
idLabel: "discordUserId",
|
idLabel: "discordUserId",
|
||||||
normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""),
|
normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""),
|
||||||
notifyApproval: async ({ id }) => {
|
notifyApproval: async ({ id }) => {
|
||||||
await sendMessageDiscord(`user:${id}`, PAIRING_APPROVED_MESSAGE);
|
await getDiscordRuntime().channel.discord.sendMessageDiscord(
|
||||||
|
`user:${id}`,
|
||||||
|
PAIRING_APPROVED_MESSAGE,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
@@ -165,8 +162,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
self: async () => null,
|
self: async () => null,
|
||||||
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
|
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
|
||||||
listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),
|
listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),
|
||||||
listPeersLive: async (params) => listDiscordDirectoryPeersLive(params),
|
listPeersLive: async (params) =>
|
||||||
listGroupsLive: async (params) => listDiscordDirectoryGroupsLive(params),
|
getDiscordRuntime().channel.discord.listDirectoryPeersLive(params),
|
||||||
|
listGroupsLive: async (params) =>
|
||||||
|
getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params),
|
||||||
},
|
},
|
||||||
resolver: {
|
resolver: {
|
||||||
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
|
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
|
||||||
@@ -180,7 +179,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (kind === "group") {
|
if (kind === "group") {
|
||||||
const resolved = await resolveDiscordChannelAllowlist({ token, entries: inputs });
|
const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({
|
||||||
|
token,
|
||||||
|
entries: inputs,
|
||||||
|
});
|
||||||
return resolved.map((entry) => ({
|
return resolved.map((entry) => ({
|
||||||
input: entry.input,
|
input: entry.input,
|
||||||
resolved: entry.resolved,
|
resolved: entry.resolved,
|
||||||
@@ -192,7 +194,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
note: entry.note,
|
note: entry.note,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
const resolved = await resolveDiscordUserAllowlist({ token, entries: inputs });
|
const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({
|
||||||
|
token,
|
||||||
|
entries: inputs,
|
||||||
|
});
|
||||||
return resolved.map((entry) => ({
|
return resolved.map((entry) => ({
|
||||||
input: entry.input,
|
input: entry.input,
|
||||||
resolved: entry.resolved,
|
resolved: entry.resolved,
|
||||||
@@ -274,7 +279,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
textChunkLimit: 2000,
|
textChunkLimit: 2000,
|
||||||
pollMaxOptions: 10,
|
pollMaxOptions: 10,
|
||||||
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
||||||
const send = deps?.sendDiscord ?? sendMessageDiscord;
|
const send =
|
||||||
|
deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
replyTo: replyToId ?? undefined,
|
replyTo: replyToId ?? undefined,
|
||||||
@@ -283,7 +289,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
return { channel: "discord", ...result };
|
return { channel: "discord", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
||||||
const send = deps?.sendDiscord ?? sendMessageDiscord;
|
const send =
|
||||||
|
deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
@@ -293,7 +300,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
return { channel: "discord", ...result };
|
return { channel: "discord", ...result };
|
||||||
},
|
},
|
||||||
sendPoll: async ({ to, poll, accountId }) =>
|
sendPoll: async ({ to, poll, accountId }) =>
|
||||||
await sendPollDiscord(to, poll, {
|
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -317,7 +324,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
}),
|
}),
|
||||||
probeAccount: async ({ account, timeoutMs }) =>
|
probeAccount: async ({ account, timeoutMs }) =>
|
||||||
probeDiscord(account.token, timeoutMs, { includeApplication: true }),
|
getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
|
||||||
|
includeApplication: true,
|
||||||
|
}),
|
||||||
auditAccount: async ({ account, timeoutMs, cfg }) => {
|
auditAccount: async ({ account, timeoutMs, cfg }) => {
|
||||||
const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
|
const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -334,7 +343,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
elapsedMs: 0,
|
elapsedMs: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const audit = await auditDiscordChannelPermissions({
|
const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({
|
||||||
token: botToken,
|
token: botToken,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
channelIds,
|
channelIds,
|
||||||
@@ -371,7 +380,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
const token = account.token.trim();
|
const token = account.token.trim();
|
||||||
let discordBotLabel = "";
|
let discordBotLabel = "";
|
||||||
try {
|
try {
|
||||||
const probe = await probeDiscord(token, 2500, {
|
const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, {
|
||||||
includeApplication: true,
|
includeApplication: true,
|
||||||
});
|
});
|
||||||
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
||||||
@@ -392,14 +401,12 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (shouldLogVerbose()) {
|
if (getDiscordRuntime().logging.shouldLogVerbose()) {
|
||||||
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
|
ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
|
||||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
return getDiscordRuntime().channel.discord.monitorDiscordProvider({
|
||||||
const { monitorDiscordProvider } = await import("../../discord/index.js");
|
|
||||||
return monitorDiscordProvider({
|
|
||||||
token,
|
token,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
config: ctx.cfg,
|
config: ctx.cfg,
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
export function setDiscordRuntime(next: PluginRuntime) {
|
||||||
|
runtime = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDiscordRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("Discord runtime not initialized");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { imessagePlugin } from "./src/channel.js";
|
||||||
|
import { setIMessageRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "imessage",
|
||||||
|
name: "iMessage",
|
||||||
|
description: "iMessage channel plugin",
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setIMessageRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: imessagePlugin });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/imessage",
|
||||||
|
"version": "2026.1.17-1",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot iMessage channel plugin",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": ["./index.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,26 @@
|
|||||||
import { chunkText } from "../../auto-reply/chunk.js";
|
|
||||||
import {
|
|
||||||
listIMessageAccountIds,
|
|
||||||
type ResolvedIMessageAccount,
|
|
||||||
resolveDefaultIMessageAccountId,
|
|
||||||
resolveIMessageAccount,
|
|
||||||
} from "../../imessage/accounts.js";
|
|
||||||
import { probeIMessage } from "../../imessage/probe.js";
|
|
||||||
import { sendMessageIMessage } from "../../imessage/send.js";
|
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
|
||||||
import { getChatChannelMeta } from "../registry.js";
|
|
||||||
import { IMessageConfigSchema } from "../../config/zod-schema.providers-core.js";
|
|
||||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
|
||||||
import {
|
|
||||||
deleteAccountFromConfigSection,
|
|
||||||
setAccountEnabledInConfigSection,
|
|
||||||
} from "./config-helpers.js";
|
|
||||||
import { resolveIMessageGroupRequireMention } from "./group-mentions.js";
|
|
||||||
import { formatPairingApproveHint } from "./helpers.js";
|
|
||||||
import { resolveChannelMediaMaxBytes } from "./media-limits.js";
|
|
||||||
import { imessageOnboardingAdapter } from "./onboarding/imessage.js";
|
|
||||||
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
|
|
||||||
import {
|
import {
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
|
buildChannelConfigSchema,
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
deleteAccountFromConfigSection,
|
||||||
|
formatPairingApproveHint,
|
||||||
|
getChatChannelMeta,
|
||||||
|
imessageOnboardingAdapter,
|
||||||
|
IMessageConfigSchema,
|
||||||
|
listIMessageAccountIds,
|
||||||
migrateBaseNameToDefaultAccount,
|
migrateBaseNameToDefaultAccount,
|
||||||
} from "./setup-helpers.js";
|
normalizeAccountId,
|
||||||
import type { ChannelPlugin } from "./types.js";
|
PAIRING_APPROVED_MESSAGE,
|
||||||
|
resolveChannelMediaMaxBytes,
|
||||||
|
resolveDefaultIMessageAccountId,
|
||||||
|
resolveIMessageAccount,
|
||||||
|
resolveIMessageGroupRequireMention,
|
||||||
|
setAccountEnabledInConfigSection,
|
||||||
|
type ChannelPlugin,
|
||||||
|
type ResolvedIMessageAccount,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { getIMessageRuntime } from "./runtime.js";
|
||||||
|
|
||||||
const meta = getChatChannelMeta("imessage");
|
const meta = getChatChannelMeta("imessage");
|
||||||
|
|
||||||
@@ -32,13 +28,17 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
id: "imessage",
|
id: "imessage",
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
...meta,
|
||||||
|
aliases: ["imsg"],
|
||||||
showConfigured: false,
|
showConfigured: false,
|
||||||
},
|
},
|
||||||
onboarding: imessageOnboardingAdapter,
|
onboarding: imessageOnboardingAdapter,
|
||||||
pairing: {
|
pairing: {
|
||||||
idLabel: "imessageSenderId",
|
idLabel: "imessageSenderId",
|
||||||
notifyApproval: async ({ id }) => {
|
notifyApproval: async ({ id }) => {
|
||||||
await sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE);
|
await getIMessageRuntime().channel.imessage.sendMessageIMessage(
|
||||||
|
id,
|
||||||
|
PAIRING_APPROVED_MESSAGE,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
@@ -183,10 +183,10 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
},
|
},
|
||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: chunkText,
|
chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||||
const send = deps?.sendIMessage ?? sendMessageIMessage;
|
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
|
||||||
const maxBytes = resolveChannelMediaMaxBytes({
|
const maxBytes = resolveChannelMediaMaxBytes({
|
||||||
cfg,
|
cfg,
|
||||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||||
@@ -201,7 +201,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
return { channel: "imessage", ...result };
|
return { channel: "imessage", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
|
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
|
||||||
const send = deps?.sendIMessage ?? sendMessageIMessage;
|
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
|
||||||
const maxBytes = resolveChannelMediaMaxBytes({
|
const maxBytes = resolveChannelMediaMaxBytes({
|
||||||
cfg,
|
cfg,
|
||||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||||
@@ -251,7 +251,8 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
probe: snapshot.probe,
|
probe: snapshot.probe,
|
||||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
}),
|
}),
|
||||||
probeAccount: async ({ timeoutMs }) => probeIMessage(timeoutMs),
|
probeAccount: async ({ timeoutMs }) =>
|
||||||
|
getIMessageRuntime().channel.imessage.probeIMessage(timeoutMs),
|
||||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
@@ -282,9 +283,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
ctx.log?.info(
|
ctx.log?.info(
|
||||||
`[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`,
|
`[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`,
|
||||||
);
|
);
|
||||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
return getIMessageRuntime().channel.imessage.monitorIMessageProvider({
|
||||||
const { monitorIMessageProvider } = await import("../../imessage/index.js");
|
|
||||||
return monitorIMessageProvider({
|
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
config: ctx.cfg,
|
config: ctx.cfg,
|
||||||
runtime: ctx.runtime,
|
runtime: ctx.runtime,
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
export function setIMessageRuntime(next: PluginRuntime) {
|
||||||
|
runtime = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIMessageRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("iMessage runtime not initialized");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import os from "node:os";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
import type { CoreConfig } from "./types.js";
|
import type { CoreConfig } from "./types.js";
|
||||||
|
|
||||||
import { matrixPlugin } from "./channel.js";
|
import { matrixPlugin } from "./channel.js";
|
||||||
|
import { setMatrixRuntime } from "./runtime.js";
|
||||||
|
|
||||||
describe("matrix directory", () => {
|
describe("matrix directory", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setMatrixRuntime({
|
||||||
|
state: {
|
||||||
|
resolveStateDir: () => os.tmpdir(),
|
||||||
|
},
|
||||||
|
} as PluginRuntime);
|
||||||
|
});
|
||||||
|
|
||||||
it("lists peers and groups from config", async () => {
|
it("lists peers and groups from config", async () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
RoomTopicEventContent,
|
RoomTopicEventContent,
|
||||||
} from "matrix-js-sdk/lib/@types/state_events.js";
|
} from "matrix-js-sdk/lib/@types/state_events.js";
|
||||||
|
|
||||||
import { loadConfig } from "clawdbot/plugin-sdk";
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
import type { CoreConfig } from "../types.js";
|
import type { CoreConfig } from "../types.js";
|
||||||
import { getActiveMatrixClient } from "./active-client.js";
|
import { getActiveMatrixClient } from "./active-client.js";
|
||||||
import {
|
import {
|
||||||
@@ -74,12 +74,14 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<M
|
|||||||
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
|
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
|
||||||
if (shouldShareClient) {
|
if (shouldShareClient) {
|
||||||
const client = await resolveSharedMatrixClient({
|
const client = await resolveSharedMatrixClient({
|
||||||
cfg: loadConfig() as CoreConfig,
|
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
});
|
});
|
||||||
return { client, stopOnDone: false };
|
return { client, stopOnDone: false };
|
||||||
}
|
}
|
||||||
const auth = await resolveMatrixAuth({ cfg: loadConfig() as CoreConfig });
|
const auth = await resolveMatrixAuth({
|
||||||
|
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||||
|
});
|
||||||
const client = await createMatrixClient({
|
const client = await createMatrixClient({
|
||||||
homeserver: auth.homeserver,
|
homeserver: auth.homeserver,
|
||||||
userId: auth.userId,
|
userId: auth.userId,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
||||||
|
|
||||||
import { loadConfig } from "clawdbot/plugin-sdk";
|
|
||||||
import type { CoreConfig } from "../types.js";
|
import type { CoreConfig } from "../types.js";
|
||||||
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
|
|
||||||
export type MatrixResolvedConfig = {
|
export type MatrixResolvedConfig = {
|
||||||
homeserver: string;
|
homeserver: string;
|
||||||
@@ -46,7 +46,7 @@ function clean(value?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveMatrixConfig(
|
export function resolveMatrixConfig(
|
||||||
cfg: CoreConfig = loadConfig() as CoreConfig,
|
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
): MatrixResolvedConfig {
|
): MatrixResolvedConfig {
|
||||||
const matrix = cfg.channels?.matrix ?? {};
|
const matrix = cfg.channels?.matrix ?? {};
|
||||||
@@ -75,7 +75,7 @@ export async function resolveMatrixAuth(params?: {
|
|||||||
cfg?: CoreConfig;
|
cfg?: CoreConfig;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
}): Promise<MatrixAuth> {
|
}): Promise<MatrixAuth> {
|
||||||
const cfg = params?.cfg ?? (loadConfig() as CoreConfig);
|
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||||
const env = params?.env ?? process.env;
|
const env = params?.env ?? process.env;
|
||||||
const resolved = resolveMatrixConfig(cfg, env);
|
const resolved = resolveMatrixConfig(cfg, env);
|
||||||
if (!resolved.homeserver) {
|
if (!resolved.homeserver) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { resolveStateDir } from "clawdbot/plugin-sdk";
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
|
|
||||||
export type MatrixStoredCredentials = {
|
export type MatrixStoredCredentials = {
|
||||||
homeserver: string;
|
homeserver: string;
|
||||||
@@ -16,9 +16,11 @@ const CREDENTIALS_FILENAME = "credentials.json";
|
|||||||
|
|
||||||
export function resolveMatrixCredentialsDir(
|
export function resolveMatrixCredentialsDir(
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
stateDir: string = resolveStateDir(env, os.homedir),
|
stateDir?: string,
|
||||||
): string {
|
): string {
|
||||||
return path.join(stateDir, "credentials", "matrix");
|
const resolvedStateDir =
|
||||||
|
stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||||
|
return path.join(resolvedStateDir, "credentials", "matrix");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string {
|
export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import path from "node:path";
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { runCommandWithTimeout, type RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
|
|
||||||
const MATRIX_SDK_PACKAGE = "matrix-js-sdk";
|
const MATRIX_SDK_PACKAGE = "matrix-js-sdk";
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ export async function ensureMatrixSdkInstalled(params: {
|
|||||||
? ["pnpm", "install"]
|
? ["pnpm", "install"]
|
||||||
: ["npm", "install", "--omit=dev", "--silent"];
|
: ["npm", "install", "--omit=dev", "--silent"];
|
||||||
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
|
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
|
||||||
const result = await runCommandWithTimeout(command, {
|
const result = await getMatrixRuntime().system.runCommandWithTimeout(command, {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
timeoutMs: 300_000,
|
timeoutMs: 300_000,
|
||||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk";
|
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk";
|
||||||
import { RoomMemberEvent } from "matrix-js-sdk";
|
import { RoomMemberEvent } from "matrix-js-sdk";
|
||||||
|
|
||||||
import { danger, logVerbose, type RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
import type { CoreConfig } from "../../types.js";
|
import type { CoreConfig } from "../../types.js";
|
||||||
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
export function registerMatrixAutoJoin(params: {
|
export function registerMatrixAutoJoin(params: {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -10,6 +11,11 @@ export function registerMatrixAutoJoin(params: {
|
|||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
}) {
|
}) {
|
||||||
const { client, cfg, runtime } = params;
|
const { client, cfg, runtime } = params;
|
||||||
|
const core = getMatrixRuntime();
|
||||||
|
const logVerbose = (message: string) => {
|
||||||
|
if (!core.logging.shouldLogVerbose()) return;
|
||||||
|
runtime.log?.(message);
|
||||||
|
};
|
||||||
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
|
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
|
||||||
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
|
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
|
||||||
|
|
||||||
@@ -36,7 +42,7 @@ export function registerMatrixAutoJoin(params: {
|
|||||||
await client.joinRoom(roomId);
|
await client.joinRoom(roomId);
|
||||||
logVerbose(`matrix: joined room ${roomId}`);
|
logVerbose(`matrix: joined room ${roomId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error?.(danger(`matrix: failed to join room ${roomId}: ${String(err)}`));
|
runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3,34 +3,9 @@ import { EventType, RelationType, RoomEvent } from "matrix-js-sdk";
|
|||||||
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildMentionRegexes,
|
|
||||||
chunkMarkdownText,
|
|
||||||
createReplyDispatcherWithTyping,
|
|
||||||
danger,
|
|
||||||
dispatchReplyFromConfig,
|
|
||||||
enqueueSystemEvent,
|
|
||||||
finalizeInboundContext,
|
|
||||||
formatAgentEnvelope,
|
|
||||||
formatAllowlistMatchMeta,
|
formatAllowlistMatchMeta,
|
||||||
getChildLogger,
|
|
||||||
hasControlCommand,
|
|
||||||
loadConfig,
|
|
||||||
logVerbose,
|
|
||||||
mergeAllowlist,
|
mergeAllowlist,
|
||||||
matchesMentionPatterns,
|
|
||||||
readChannelAllowFromStore,
|
|
||||||
recordSessionMetaFromInbound,
|
|
||||||
resolveAgentRoute,
|
|
||||||
resolveCommandAuthorizedFromAuthorizers,
|
|
||||||
resolveEffectiveMessagesConfig,
|
|
||||||
resolveHumanDelayConfig,
|
|
||||||
resolveStorePath,
|
|
||||||
resolveTextChunkLimit,
|
|
||||||
shouldHandleTextCommands,
|
|
||||||
shouldLogVerbose,
|
|
||||||
summarizeMapping,
|
summarizeMapping,
|
||||||
updateLastRoute,
|
|
||||||
upsertChannelPairingRequest,
|
|
||||||
type ReplyPayload,
|
type ReplyPayload,
|
||||||
type RuntimeEnv,
|
type RuntimeEnv,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
@@ -61,6 +36,7 @@ import { deliverMatrixReplies } from "./replies.js";
|
|||||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
import { resolveMatrixRoomConfig } from "./rooms.js";
|
||||||
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
||||||
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
||||||
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
export type MonitorMatrixOpts = {
|
export type MonitorMatrixOpts = {
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
@@ -76,7 +52,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
if (isBunRuntime()) {
|
if (isBunRuntime()) {
|
||||||
throw new Error("Matrix provider requires Node (bun runtime not supported)");
|
throw new Error("Matrix provider requires Node (bun runtime not supported)");
|
||||||
}
|
}
|
||||||
let cfg = loadConfig() as CoreConfig;
|
const core = getMatrixRuntime();
|
||||||
|
let cfg = core.config.loadConfig() as CoreConfig;
|
||||||
if (cfg.channels?.matrix?.enabled === false) return;
|
if (cfg.channels?.matrix?.enabled === false) return;
|
||||||
|
|
||||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||||
@@ -207,8 +184,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
});
|
});
|
||||||
setActiveMatrixClient(client);
|
setActiveMatrixClient(client);
|
||||||
|
|
||||||
const mentionRegexes = buildMentionRegexes(cfg);
|
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
|
||||||
const logger = getChildLogger({ module: "matrix-auto-reply" });
|
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
|
||||||
|
const logVerboseMessage = (message: string) => {
|
||||||
|
if (core.logging.shouldLogVerbose()) {
|
||||||
|
logger.debug(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
|
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
|
||||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||||
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
@@ -219,8 +201,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
const dmEnabled = dmConfig?.enabled ?? true;
|
const dmEnabled = dmConfig?.enabled ?? true;
|
||||||
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
|
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
|
||||||
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
|
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
|
||||||
const allowFrom = dmConfig?.allowFrom ?? [];
|
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "matrix");
|
|
||||||
const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
||||||
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
||||||
const startupMs = Date.now();
|
const startupMs = Date.now();
|
||||||
@@ -306,22 +287,22 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
}`;
|
}`;
|
||||||
|
|
||||||
if (roomConfigInfo.config && !roomConfigInfo.allowed) {
|
if (roomConfigInfo.config && !roomConfigInfo.allowed) {
|
||||||
logVerbose(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (groupPolicy === "allowlist") {
|
if (groupPolicy === "allowlist") {
|
||||||
if (!roomConfigInfo.allowlistConfigured) {
|
if (!roomConfigInfo.allowlistConfigured) {
|
||||||
logVerbose(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!roomConfigInfo.config) {
|
if (!roomConfigInfo.config) {
|
||||||
logVerbose(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const senderName = room.getMember(senderId)?.name ?? senderId;
|
const senderName = room.getMember(senderId)?.name ?? senderId;
|
||||||
const storeAllowFrom = await readChannelAllowFromStore("matrix").catch(() => []);
|
const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
|
||||||
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
|
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
|
||||||
|
|
||||||
if (isDirectMessage) {
|
if (isDirectMessage) {
|
||||||
@@ -335,13 +316,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
||||||
if (!allowMatch.allowed) {
|
if (!allowMatch.allowed) {
|
||||||
if (dmPolicy === "pairing") {
|
if (dmPolicy === "pairing") {
|
||||||
const { code, created } = await upsertChannelPairingRequest({
|
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
id: senderId,
|
id: senderId,
|
||||||
meta: { name: senderName },
|
meta: { name: senderName },
|
||||||
});
|
});
|
||||||
if (created) {
|
if (created) {
|
||||||
logVerbose(
|
logVerboseMessage(
|
||||||
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
|
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
@@ -358,12 +339,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
{ client },
|
{ client },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
|
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (dmPolicy !== "pairing") {
|
if (dmPolicy !== "pairing") {
|
||||||
logVerbose(
|
logVerboseMessage(
|
||||||
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -379,7 +360,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
userName: senderName,
|
userName: senderName,
|
||||||
});
|
});
|
||||||
if (!userMatch.allowed) {
|
if (!userMatch.allowed) {
|
||||||
logVerbose(
|
logVerboseMessage(
|
||||||
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
||||||
userMatch,
|
userMatch,
|
||||||
)})`,
|
)})`,
|
||||||
@@ -388,7 +369,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isRoom) {
|
if (isRoom) {
|
||||||
logVerbose(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawBody = content.body.trim();
|
const rawBody = content.body.trim();
|
||||||
@@ -416,7 +397,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(`matrix: media download failed: ${String(err)}`);
|
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +410,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
text: bodyText,
|
text: bodyText,
|
||||||
mentionRegexes,
|
mentionRegexes,
|
||||||
});
|
});
|
||||||
const allowTextCommands = shouldHandleTextCommands({
|
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||||
cfg,
|
cfg,
|
||||||
surface: "matrix",
|
surface: "matrix",
|
||||||
});
|
});
|
||||||
@@ -439,14 +420,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
userId: senderId,
|
userId: senderId,
|
||||||
userName: senderName,
|
userName: senderName,
|
||||||
});
|
});
|
||||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
authorizers: [
|
authorizers: [
|
||||||
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (isRoom && allowTextCommands && hasControlCommand(bodyText, cfg) && !commandAuthorized) {
|
if (
|
||||||
logVerbose(`matrix: drop control command from unauthorized sender ${senderId}`);
|
isRoom &&
|
||||||
|
allowTextCommands &&
|
||||||
|
core.channel.text.hasControlCommand(bodyText, cfg) &&
|
||||||
|
!commandAuthorized
|
||||||
|
) {
|
||||||
|
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const shouldRequireMention = isRoom
|
const shouldRequireMention = isRoom
|
||||||
@@ -465,7 +451,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
!wasMentioned &&
|
!wasMentioned &&
|
||||||
!hasExplicitMention &&
|
!hasExplicitMention &&
|
||||||
commandAuthorized &&
|
commandAuthorized &&
|
||||||
hasControlCommand(bodyText);
|
core.channel.text.hasControlCommand(bodyText);
|
||||||
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
|
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
|
||||||
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
|
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
|
||||||
return;
|
return;
|
||||||
@@ -482,14 +468,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
|
|
||||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||||
const body = formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Matrix",
|
channel: "Matrix",
|
||||||
from: envelopeFrom,
|
from: envelopeFrom,
|
||||||
timestamp: event.getTs() ?? undefined,
|
timestamp: event.getTs() ?? undefined,
|
||||||
body: textWithId,
|
body: textWithId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const route = resolveAgentRoute({
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
peer: {
|
peer: {
|
||||||
@@ -499,7 +485,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
});
|
});
|
||||||
|
|
||||||
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
||||||
const ctxPayload = finalizeInboundContext({
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
Body: body,
|
Body: body,
|
||||||
RawBody: bodyText,
|
RawBody: bodyText,
|
||||||
CommandBody: bodyText,
|
CommandBody: bodyText,
|
||||||
@@ -531,10 +517,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
OriginatingTo: `room:${roomId}`,
|
OriginatingTo: `room:${roomId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = resolveStorePath(cfg.session?.store, {
|
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
});
|
||||||
void recordSessionMetaFromInbound({
|
void core.channel.session.recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
@@ -546,7 +532,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isDirectMessage) {
|
if (isDirectMessage) {
|
||||||
await updateLastRoute({
|
await core.channel.session.updateLastRoute({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: route.mainSessionKey,
|
sessionKey: route.mainSessionKey,
|
||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
@@ -556,10 +542,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldLogVerbose()) {
|
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||||
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
||||||
logVerbose(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||||
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||||
@@ -577,20 +561,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
};
|
};
|
||||||
if (shouldAckReaction() && messageId) {
|
if (shouldAckReaction() && messageId) {
|
||||||
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
|
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
|
||||||
logVerbose(`matrix react failed for room ${roomId}: ${String(err)}`);
|
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const replyTarget = ctxPayload.To;
|
const replyTarget = ctxPayload.To;
|
||||||
if (!replyTarget) {
|
if (!replyTarget) {
|
||||||
runtime.error?.(danger("matrix: missing reply target"));
|
runtime.error?.("matrix: missing reply target");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let didSendReply = false;
|
let didSendReply = false;
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
await deliverMatrixReplies({
|
await deliverMatrixReplies({
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
@@ -604,13 +588,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
didSendReply = true;
|
didSendReply = true;
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(danger(`matrix ${info.kind} reply failed: ${String(err)}`));
|
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
|
||||||
},
|
},
|
||||||
onReplyStart: () => sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
|
onReplyStart: () => sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
|
||||||
onIdle: () => sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
|
onIdle: () => sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { queuedFinal, counts } = await dispatchReplyFromConfig({
|
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
@@ -622,19 +606,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
markDispatchIdle();
|
markDispatchIdle();
|
||||||
if (!queuedFinal) return;
|
if (!queuedFinal) return;
|
||||||
didSendReply = true;
|
didSendReply = true;
|
||||||
if (shouldLogVerbose()) {
|
const finalCount = counts.final;
|
||||||
const finalCount = counts.final;
|
logVerboseMessage(
|
||||||
logVerbose(`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`);
|
`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||||
}
|
);
|
||||||
if (didSendReply) {
|
if (didSendReply) {
|
||||||
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
||||||
enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, {
|
core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, {
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
|
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error?.(danger(`matrix handler failed: ${String(err)}`));
|
runtime.error?.(`matrix handler failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { MatrixClient } from "matrix-js-sdk";
|
import type { MatrixClient } from "matrix-js-sdk";
|
||||||
|
|
||||||
import { saveMediaBuffer } from "clawdbot/plugin-sdk";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
async function fetchMatrixMediaBuffer(params: {
|
async function fetchMatrixMediaBuffer(params: {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -49,7 +49,12 @@ export async function downloadMatrixMedia(params: {
|
|||||||
});
|
});
|
||||||
if (!fetched) return null;
|
if (!fetched) return null;
|
||||||
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
||||||
const saved = await saveMediaBuffer(fetched.buffer, headerType, "inbound", params.maxBytes);
|
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
||||||
|
fetched.buffer,
|
||||||
|
headerType,
|
||||||
|
"inbound",
|
||||||
|
params.maxBytes,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
contentType: saved.contentType,
|
contentType: saved.contentType,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
||||||
|
|
||||||
import { matchesMentionPatterns } from "clawdbot/plugin-sdk";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
export function resolveMentions(params: {
|
export function resolveMentions(params: {
|
||||||
content: RoomMessageEventContent;
|
content: RoomMessageEventContent;
|
||||||
@@ -17,6 +17,9 @@ export function resolveMentions(params: {
|
|||||||
const wasMentioned =
|
const wasMentioned =
|
||||||
Boolean(mentions?.room) ||
|
Boolean(mentions?.room) ||
|
||||||
(params.userId ? mentionedUsers.has(params.userId) : false) ||
|
(params.userId ? mentionedUsers.has(params.userId) : false) ||
|
||||||
matchesMentionPatterns(params.text ?? "", params.mentionRegexes);
|
getMatrixRuntime().channel.mentions.matchesMentionPatterns(
|
||||||
|
params.text ?? "",
|
||||||
|
params.mentionRegexes,
|
||||||
|
);
|
||||||
return { wasMentioned, hasExplicitMention: Boolean(mentions) };
|
return { wasMentioned, hasExplicitMention: Boolean(mentions) };
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
import type { MatrixClient } from "matrix-js-sdk";
|
import type { MatrixClient } from "matrix-js-sdk";
|
||||||
|
|
||||||
import {
|
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
chunkMarkdownText,
|
|
||||||
danger,
|
|
||||||
logVerbose,
|
|
||||||
type ReplyPayload,
|
|
||||||
type RuntimeEnv,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
|
||||||
import { sendMessageMatrix } from "../send.js";
|
import { sendMessageMatrix } from "../send.js";
|
||||||
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
export async function deliverMatrixReplies(params: {
|
export async function deliverMatrixReplies(params: {
|
||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
@@ -18,6 +13,12 @@ export async function deliverMatrixReplies(params: {
|
|||||||
replyToMode: "off" | "first" | "all";
|
replyToMode: "off" | "first" | "all";
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
const core = getMatrixRuntime();
|
||||||
|
const logVerbose = (message: string) => {
|
||||||
|
if (core.logging.shouldLogVerbose()) {
|
||||||
|
params.runtime.log?.(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||||
let hasReplied = false;
|
let hasReplied = false;
|
||||||
for (const reply of params.replies) {
|
for (const reply of params.replies) {
|
||||||
@@ -27,7 +28,7 @@ export async function deliverMatrixReplies(params: {
|
|||||||
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
|
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
params.runtime.error?.(danger("matrix reply missing text/media"));
|
params.runtime.error?.("matrix reply missing text/media");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const replyToIdRaw = reply.replyToId?.trim();
|
const replyToIdRaw = reply.replyToId?.trim();
|
||||||
@@ -42,7 +43,7 @@ export async function deliverMatrixReplies(params: {
|
|||||||
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
||||||
|
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
for (const chunk of chunkMarkdownText(reply.text ?? "", chunkLimit)) {
|
for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
await sendMessageMatrix(params.roomId, trimmed, {
|
await sendMessageMatrix(params.roomId, trimmed, {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
import { setMatrixRuntime } from "../runtime.js";
|
||||||
|
|
||||||
vi.mock("matrix-js-sdk", () => ({
|
vi.mock("matrix-js-sdk", () => ({
|
||||||
EventType: {
|
EventType: {
|
||||||
Direct: "m.direct",
|
Direct: "m.direct",
|
||||||
@@ -18,21 +21,33 @@ vi.mock("matrix-js-sdk", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("clawdbot/plugin-sdk", () => ({
|
const loadWebMediaMock = vi.fn().mockResolvedValue({
|
||||||
loadConfig: () => ({}),
|
buffer: Buffer.from("media"),
|
||||||
resolveTextChunkLimit: () => 4000,
|
fileName: "photo.png",
|
||||||
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
contentType: "image/png",
|
||||||
loadWebMedia: vi.fn().mockResolvedValue({
|
kind: "image",
|
||||||
buffer: Buffer.from("media"),
|
});
|
||||||
fileName: "photo.png",
|
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
|
||||||
contentType: "image/png",
|
const resizeToJpegMock = vi.fn();
|
||||||
kind: "image",
|
|
||||||
}),
|
const runtimeStub = {
|
||||||
mediaKindFromMime: () => "image",
|
config: {
|
||||||
isVoiceCompatibleAudio: () => false,
|
loadConfig: () => ({}),
|
||||||
getImageMetadata: vi.fn().mockResolvedValue(null),
|
},
|
||||||
resizeToJpeg: vi.fn(),
|
media: {
|
||||||
}));
|
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
||||||
|
mediaKindFromMime: () => "image",
|
||||||
|
isVoiceCompatibleAudio: () => false,
|
||||||
|
getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args),
|
||||||
|
resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args),
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
text: {
|
||||||
|
resolveTextChunkLimit: () => 4000,
|
||||||
|
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime;
|
||||||
|
|
||||||
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
||||||
|
|
||||||
@@ -50,11 +65,13 @@ const makeClient = () => {
|
|||||||
|
|
||||||
describe("sendMessageMatrix media", () => {
|
describe("sendMessageMatrix media", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
setMatrixRuntime(runtimeStub);
|
||||||
({ sendMessageMatrix } = await import("./send.js"));
|
({ sendMessageMatrix } = await import("./send.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
setMatrixRuntime(runtimeStub);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uploads media with url payloads", async () => {
|
it("uploads media with url payloads", async () => {
|
||||||
|
|||||||
@@ -5,17 +5,8 @@ import type {
|
|||||||
ReactionEventContent,
|
ReactionEventContent,
|
||||||
} from "matrix-js-sdk/lib/@types/events.js";
|
} from "matrix-js-sdk/lib/@types/events.js";
|
||||||
|
|
||||||
import {
|
import type { PollInput } from "clawdbot/plugin-sdk";
|
||||||
chunkMarkdownText,
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
getImageMetadata,
|
|
||||||
isVoiceCompatibleAudio,
|
|
||||||
loadConfig,
|
|
||||||
loadWebMedia,
|
|
||||||
mediaKindFromMime,
|
|
||||||
type PollInput,
|
|
||||||
resolveTextChunkLimit,
|
|
||||||
resizeToJpeg,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
|
||||||
import { getActiveMatrixClient } from "./active-client.js";
|
import { getActiveMatrixClient } from "./active-client.js";
|
||||||
import {
|
import {
|
||||||
createMatrixClient,
|
createMatrixClient,
|
||||||
@@ -29,6 +20,7 @@ import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
|
|||||||
import type { CoreConfig } from "../types.js";
|
import type { CoreConfig } from "../types.js";
|
||||||
|
|
||||||
const MATRIX_TEXT_LIMIT = 4000;
|
const MATRIX_TEXT_LIMIT = 4000;
|
||||||
|
const getCore = () => getMatrixRuntime();
|
||||||
|
|
||||||
type MatrixDirectAccountData = AccountDataEvents[EventType.Direct];
|
type MatrixDirectAccountData = AccountDataEvents[EventType.Direct];
|
||||||
|
|
||||||
@@ -65,7 +57,7 @@ function ensureNodeRuntime() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveMediaMaxBytes(): number | undefined {
|
function resolveMediaMaxBytes(): number | undefined {
|
||||||
const cfg = loadConfig() as CoreConfig;
|
const cfg = getCore().config.loadConfig() as CoreConfig;
|
||||||
if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
|
if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
|
||||||
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
|
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
|
||||||
}
|
}
|
||||||
@@ -224,7 +216,7 @@ function resolveMatrixMsgType(
|
|||||||
contentType?: string,
|
contentType?: string,
|
||||||
fileName?: string,
|
fileName?: string,
|
||||||
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
|
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
|
||||||
const kind = mediaKindFromMime(contentType ?? "");
|
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "image":
|
case "image":
|
||||||
return MsgType.Image;
|
return MsgType.Image;
|
||||||
@@ -243,7 +235,7 @@ function resolveMatrixVoiceDecision(opts: {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
}): { useVoice: boolean } {
|
}): { useVoice: boolean } {
|
||||||
if (!opts.wantsVoice) return { useVoice: false };
|
if (!opts.wantsVoice) return { useVoice: false };
|
||||||
if (isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
|
if (getCore().media.isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
|
||||||
return { useVoice: true };
|
return { useVoice: true };
|
||||||
}
|
}
|
||||||
return { useVoice: false };
|
return { useVoice: false };
|
||||||
@@ -256,19 +248,19 @@ async function prepareImageInfo(params: {
|
|||||||
buffer: Buffer;
|
buffer: Buffer;
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
}): Promise<MatrixImageInfo | undefined> {
|
}): Promise<MatrixImageInfo | undefined> {
|
||||||
const meta = await getImageMetadata(params.buffer).catch(() => null);
|
const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null);
|
||||||
if (!meta) return undefined;
|
if (!meta) return undefined;
|
||||||
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
|
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
|
||||||
const maxDim = Math.max(meta.width, meta.height);
|
const maxDim = Math.max(meta.width, meta.height);
|
||||||
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
||||||
try {
|
try {
|
||||||
const thumbBuffer = await resizeToJpeg({
|
const thumbBuffer = await getCore().media.resizeToJpeg({
|
||||||
buffer: params.buffer,
|
buffer: params.buffer,
|
||||||
maxSide: THUMBNAIL_MAX_SIDE,
|
maxSide: THUMBNAIL_MAX_SIDE,
|
||||||
quality: THUMBNAIL_QUALITY,
|
quality: THUMBNAIL_QUALITY,
|
||||||
withoutEnlargement: true,
|
withoutEnlargement: true,
|
||||||
});
|
});
|
||||||
const thumbMeta = await getImageMetadata(thumbBuffer).catch(() => null);
|
const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null);
|
||||||
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
|
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
|
||||||
type: "image/jpeg",
|
type: "image/jpeg",
|
||||||
name: "thumbnail.jpg",
|
name: "thumbnail.jpg",
|
||||||
@@ -352,10 +344,10 @@ export async function sendMessageMatrix(
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const roomId = await resolveMatrixRoomId(client, to);
|
const roomId = await resolveMatrixRoomId(client, to);
|
||||||
const cfg = loadConfig();
|
const cfg = getCore().config.loadConfig();
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "matrix");
|
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||||
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
||||||
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
|
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
|
||||||
const threadId = normalizeThreadId(opts.threadId);
|
const threadId = normalizeThreadId(opts.threadId);
|
||||||
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
|
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
|
||||||
const sendContent = (content: RoomMessageEventContent) =>
|
const sendContent = (content: RoomMessageEventContent) =>
|
||||||
@@ -364,7 +356,7 @@ export async function sendMessageMatrix(
|
|||||||
let lastMessageId = "";
|
let lastMessageId = "";
|
||||||
if (opts.mediaUrl) {
|
if (opts.mediaUrl) {
|
||||||
const maxBytes = resolveMediaMaxBytes();
|
const maxBytes = resolveMediaMaxBytes();
|
||||||
const media = await loadWebMedia(opts.mediaUrl, maxBytes);
|
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
|
||||||
const contentUri = await uploadFile(client, media.buffer, {
|
const contentUri = await uploadFile(client, media.buffer, {
|
||||||
contentType: media.contentType,
|
contentType: media.contentType,
|
||||||
filename: media.fileName,
|
filename: media.fileName,
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import {
|
|
||||||
createMemoryGetTool,
|
|
||||||
createMemorySearchTool,
|
|
||||||
registerMemoryCli,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
const memoryCorePlugin = {
|
const memoryCorePlugin = {
|
||||||
id: "memory-core",
|
id: "memory-core",
|
||||||
name: "Memory (Core)",
|
name: "Memory (Core)",
|
||||||
@@ -14,11 +8,11 @@ const memoryCorePlugin = {
|
|||||||
register(api: ClawdbotPluginApi) {
|
register(api: ClawdbotPluginApi) {
|
||||||
api.registerTool(
|
api.registerTool(
|
||||||
(ctx) => {
|
(ctx) => {
|
||||||
const memorySearchTool = createMemorySearchTool({
|
const memorySearchTool = api.runtime.tools.createMemorySearchTool({
|
||||||
config: ctx.config,
|
config: ctx.config,
|
||||||
agentSessionKey: ctx.sessionKey,
|
agentSessionKey: ctx.sessionKey,
|
||||||
});
|
});
|
||||||
const memoryGetTool = createMemoryGetTool({
|
const memoryGetTool = api.runtime.tools.createMemoryGetTool({
|
||||||
config: ctx.config,
|
config: ctx.config,
|
||||||
agentSessionKey: ctx.sessionKey,
|
agentSessionKey: ctx.sessionKey,
|
||||||
});
|
});
|
||||||
@@ -30,7 +24,7 @@ const memoryCorePlugin = {
|
|||||||
|
|
||||||
api.registerCli(
|
api.registerCli(
|
||||||
({ program }) => {
|
({ program }) => {
|
||||||
registerMemoryCli(program);
|
api.runtime.tools.registerMemoryCli(program);
|
||||||
},
|
},
|
||||||
{ commands: ["memory"] },
|
{ commands: ["memory"] },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export type MemoryConfig = {
|
||||||
|
embedding: {
|
||||||
|
provider: "openai";
|
||||||
|
model?: string;
|
||||||
|
apiKey: string;
|
||||||
|
};
|
||||||
|
dbPath?: string;
|
||||||
|
autoCapture?: boolean;
|
||||||
|
autoRecall?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MEMORY_CATEGORIES = ["preference", "fact", "decision", "entity", "other"] as const;
|
||||||
|
export type MemoryCategory = (typeof MEMORY_CATEGORIES)[number];
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = "text-embedding-3-small";
|
||||||
|
const DEFAULT_DB_PATH = join(homedir(), ".clawdbot", "memory", "lancedb");
|
||||||
|
|
||||||
|
const EMBEDDING_DIMENSIONS: Record<string, number> = {
|
||||||
|
"text-embedding-3-small": 1536,
|
||||||
|
"text-embedding-3-large": 3072,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function vectorDimsForModel(model: string): number {
|
||||||
|
const dims = EMBEDDING_DIMENSIONS[model];
|
||||||
|
if (!dims) {
|
||||||
|
throw new Error(`Unsupported embedding model: ${model}`);
|
||||||
|
}
|
||||||
|
return dims;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEnvVars(value: string): string {
|
||||||
|
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
|
||||||
|
const envValue = process.env[envVar];
|
||||||
|
if (!envValue) {
|
||||||
|
throw new Error(`Environment variable ${envVar} is not set`);
|
||||||
|
}
|
||||||
|
return envValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEmbeddingModel(embedding: Record<string, unknown>): string {
|
||||||
|
const model = typeof embedding.model === "string" ? embedding.model : DEFAULT_MODEL;
|
||||||
|
vectorDimsForModel(model);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const memoryConfigSchema = {
|
||||||
|
parse(value: unknown): MemoryConfig {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new Error("memory config required");
|
||||||
|
}
|
||||||
|
const cfg = value as Record<string, unknown>;
|
||||||
|
|
||||||
|
const embedding = cfg.embedding as Record<string, unknown> | undefined;
|
||||||
|
if (!embedding || typeof embedding.apiKey !== "string") {
|
||||||
|
throw new Error("embedding.apiKey is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = resolveEmbeddingModel(embedding);
|
||||||
|
|
||||||
|
return {
|
||||||
|
embedding: {
|
||||||
|
provider: "openai",
|
||||||
|
model,
|
||||||
|
apiKey: resolveEnvVars(embedding.apiKey),
|
||||||
|
},
|
||||||
|
dbPath: typeof cfg.dbPath === "string" ? cfg.dbPath : DEFAULT_DB_PATH,
|
||||||
|
autoCapture: cfg.autoCapture !== false,
|
||||||
|
autoRecall: cfg.autoRecall !== false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
uiHints: {
|
||||||
|
"embedding.apiKey": {
|
||||||
|
label: "OpenAI API Key",
|
||||||
|
sensitive: true,
|
||||||
|
placeholder: "sk-proj-...",
|
||||||
|
help: "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})",
|
||||||
|
},
|
||||||
|
"embedding.model": {
|
||||||
|
label: "Embedding Model",
|
||||||
|
placeholder: DEFAULT_MODEL,
|
||||||
|
help: "OpenAI embedding model to use",
|
||||||
|
},
|
||||||
|
dbPath: {
|
||||||
|
label: "Database Path",
|
||||||
|
placeholder: "~/.clawdbot/memory/lancedb",
|
||||||
|
advanced: true,
|
||||||
|
},
|
||||||
|
autoCapture: {
|
||||||
|
label: "Auto-Capture",
|
||||||
|
help: "Automatically capture important information from conversations",
|
||||||
|
},
|
||||||
|
autoRecall: {
|
||||||
|
label: "Auto-Recall",
|
||||||
|
help: "Automatically inject relevant memories into context",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* Memory Plugin E2E Tests
|
||||||
|
*
|
||||||
|
* Tests the memory plugin functionality including:
|
||||||
|
* - Plugin registration and configuration
|
||||||
|
* - Memory storage and retrieval
|
||||||
|
* - Auto-recall via hooks
|
||||||
|
* - Auto-capture filtering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
|
||||||
|
// Skip if no OpenAI API key
|
||||||
|
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||||
|
const describeWithKey = OPENAI_API_KEY ? describe : describe.skip;
|
||||||
|
|
||||||
|
describeWithKey("memory plugin e2e", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let dbPath: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-memory-test-"));
|
||||||
|
dbPath = path.join(tmpDir, "lancedb");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (tmpDir) {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("memory plugin registers and initializes correctly", async () => {
|
||||||
|
// Dynamic import to avoid loading LanceDB when not testing
|
||||||
|
const { default: memoryPlugin } = await import("./index.js");
|
||||||
|
|
||||||
|
expect(memoryPlugin.id).toBe("memory-lancedb");
|
||||||
|
expect(memoryPlugin.name).toBe("Memory (LanceDB)");
|
||||||
|
expect(memoryPlugin.kind).toBe("memory");
|
||||||
|
expect(memoryPlugin.configSchema).toBeDefined();
|
||||||
|
expect(memoryPlugin.register).toBeInstanceOf(Function);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config schema parses valid config", async () => {
|
||||||
|
const { default: memoryPlugin } = await import("./index.js");
|
||||||
|
|
||||||
|
const config = memoryPlugin.configSchema?.parse?.({
|
||||||
|
embedding: {
|
||||||
|
apiKey: OPENAI_API_KEY,
|
||||||
|
model: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
dbPath,
|
||||||
|
autoCapture: true,
|
||||||
|
autoRecall: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config).toBeDefined();
|
||||||
|
expect(config?.embedding?.apiKey).toBe(OPENAI_API_KEY);
|
||||||
|
expect(config?.dbPath).toBe(dbPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config schema resolves env vars", async () => {
|
||||||
|
const { default: memoryPlugin } = await import("./index.js");
|
||||||
|
|
||||||
|
// Set a test env var
|
||||||
|
process.env.TEST_MEMORY_API_KEY = "test-key-123";
|
||||||
|
|
||||||
|
const config = memoryPlugin.configSchema?.parse?.({
|
||||||
|
embedding: {
|
||||||
|
apiKey: "${TEST_MEMORY_API_KEY}",
|
||||||
|
},
|
||||||
|
dbPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config?.embedding?.apiKey).toBe("test-key-123");
|
||||||
|
|
||||||
|
delete process.env.TEST_MEMORY_API_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config schema rejects missing apiKey", async () => {
|
||||||
|
const { default: memoryPlugin } = await import("./index.js");
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
memoryPlugin.configSchema?.parse?.({
|
||||||
|
embedding: {},
|
||||||
|
dbPath,
|
||||||
|
});
|
||||||
|
}).toThrow("embedding.apiKey is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shouldCapture filters correctly", async () => {
|
||||||
|
// Test the capture filtering logic by checking the rules
|
||||||
|
const triggers = [
|
||||||
|
{ text: "I prefer dark mode", shouldMatch: true },
|
||||||
|
{ text: "Remember that my name is John", shouldMatch: true },
|
||||||
|
{ text: "My email is test@example.com", shouldMatch: true },
|
||||||
|
{ text: "Call me at +1234567890123", shouldMatch: true },
|
||||||
|
{ text: "We decided to use TypeScript", shouldMatch: true },
|
||||||
|
{ text: "I always want verbose output", shouldMatch: true },
|
||||||
|
{ text: "Just a random short message", shouldMatch: false },
|
||||||
|
{ text: "x", shouldMatch: false }, // Too short
|
||||||
|
{ text: "<relevant-memories>injected</relevant-memories>", shouldMatch: false }, // Skip injected
|
||||||
|
];
|
||||||
|
|
||||||
|
// The shouldCapture function is internal, but we can test via the capture behavior
|
||||||
|
// For now, just verify the patterns we expect to match
|
||||||
|
for (const { text, shouldMatch } of triggers) {
|
||||||
|
const hasPreference = /prefer|radši|like|love|hate|want/i.test(text);
|
||||||
|
const hasRemember = /zapamatuj|pamatuj|remember/i.test(text);
|
||||||
|
const hasEmail = /[\w.-]+@[\w.-]+\.\w+/.test(text);
|
||||||
|
const hasPhone = /\+\d{10,}/.test(text);
|
||||||
|
const hasDecision = /rozhodli|decided|will use|budeme/i.test(text);
|
||||||
|
const hasAlways = /always|never|important/i.test(text);
|
||||||
|
const isInjected = text.includes("<relevant-memories>");
|
||||||
|
const isTooShort = text.length < 10;
|
||||||
|
|
||||||
|
const wouldCapture =
|
||||||
|
!isTooShort &&
|
||||||
|
!isInjected &&
|
||||||
|
(hasPreference || hasRemember || hasEmail || hasPhone || hasDecision || hasAlways);
|
||||||
|
|
||||||
|
if (shouldMatch) {
|
||||||
|
expect(wouldCapture).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectCategory classifies correctly", async () => {
|
||||||
|
// Test category detection patterns
|
||||||
|
const cases = [
|
||||||
|
{ text: "I prefer dark mode", expected: "preference" },
|
||||||
|
{ text: "We decided to use React", expected: "decision" },
|
||||||
|
{ text: "My email is test@example.com", expected: "entity" },
|
||||||
|
{ text: "The server is running on port 3000", expected: "fact" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { text, expected } of cases) {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
let category: string;
|
||||||
|
|
||||||
|
if (/prefer|radši|like|love|hate|want/i.test(lower)) {
|
||||||
|
category = "preference";
|
||||||
|
} else if (/rozhodli|decided|will use|budeme/i.test(lower)) {
|
||||||
|
category = "decision";
|
||||||
|
} else if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) {
|
||||||
|
category = "entity";
|
||||||
|
} else if (/is|are|has|have|je|má|jsou/i.test(lower)) {
|
||||||
|
category = "fact";
|
||||||
|
} else {
|
||||||
|
category = "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(category).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live tests that require OpenAI API key and actually use LanceDB
|
||||||
|
describeWithKey("memory plugin live tests", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let dbPath: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-memory-live-"));
|
||||||
|
dbPath = path.join(tmpDir, "lancedb");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (tmpDir) {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("memory tools work end-to-end", async () => {
|
||||||
|
const { default: memoryPlugin } = await import("./index.js");
|
||||||
|
|
||||||
|
// Mock plugin API
|
||||||
|
const registeredTools: any[] = [];
|
||||||
|
const registeredClis: any[] = [];
|
||||||
|
const registeredServices: any[] = [];
|
||||||
|
const registeredHooks: Record<string, any[]> = {};
|
||||||
|
const logs: string[] = [];
|
||||||
|
|
||||||
|
const mockApi = {
|
||||||
|
id: "memory-lancedb",
|
||||||
|
name: "Memory (LanceDB)",
|
||||||
|
source: "test",
|
||||||
|
config: {},
|
||||||
|
pluginConfig: {
|
||||||
|
embedding: {
|
||||||
|
apiKey: OPENAI_API_KEY,
|
||||||
|
model: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
dbPath,
|
||||||
|
autoCapture: false,
|
||||||
|
autoRecall: false,
|
||||||
|
},
|
||||||
|
runtime: {},
|
||||||
|
logger: {
|
||||||
|
info: (msg: string) => logs.push(`[info] ${msg}`),
|
||||||
|
warn: (msg: string) => logs.push(`[warn] ${msg}`),
|
||||||
|
error: (msg: string) => logs.push(`[error] ${msg}`),
|
||||||
|
debug: (msg: string) => logs.push(`[debug] ${msg}`),
|
||||||
|
},
|
||||||
|
registerTool: (tool: any, opts: any) => {
|
||||||
|
registeredTools.push({ tool, opts });
|
||||||
|
},
|
||||||
|
registerCli: (registrar: any, opts: any) => {
|
||||||
|
registeredClis.push({ registrar, opts });
|
||||||
|
},
|
||||||
|
registerService: (service: any) => {
|
||||||
|
registeredServices.push(service);
|
||||||
|
},
|
||||||
|
on: (hookName: string, handler: any) => {
|
||||||
|
if (!registeredHooks[hookName]) registeredHooks[hookName] = [];
|
||||||
|
registeredHooks[hookName].push(handler);
|
||||||
|
},
|
||||||
|
resolvePath: (p: string) => p,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register plugin
|
||||||
|
await memoryPlugin.register(mockApi as any);
|
||||||
|
|
||||||
|
// Check registration
|
||||||
|
expect(registeredTools.length).toBe(3);
|
||||||
|
expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_recall");
|
||||||
|
expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_store");
|
||||||
|
expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_forget");
|
||||||
|
expect(registeredClis.length).toBe(1);
|
||||||
|
expect(registeredServices.length).toBe(1);
|
||||||
|
|
||||||
|
// Get tool functions
|
||||||
|
const storeTool = registeredTools.find((t) => t.opts?.name === "memory_store")?.tool;
|
||||||
|
const recallTool = registeredTools.find((t) => t.opts?.name === "memory_recall")?.tool;
|
||||||
|
const forgetTool = registeredTools.find((t) => t.opts?.name === "memory_forget")?.tool;
|
||||||
|
|
||||||
|
// Test store
|
||||||
|
const storeResult = await storeTool.execute("test-call-1", {
|
||||||
|
text: "The user prefers dark mode for all applications",
|
||||||
|
importance: 0.8,
|
||||||
|
category: "preference",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(storeResult.details?.action).toBe("created");
|
||||||
|
expect(storeResult.details?.id).toBeDefined();
|
||||||
|
const storedId = storeResult.details?.id;
|
||||||
|
|
||||||
|
// Test recall
|
||||||
|
const recallResult = await recallTool.execute("test-call-2", {
|
||||||
|
query: "dark mode preference",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recallResult.details?.count).toBeGreaterThan(0);
|
||||||
|
expect(recallResult.details?.memories?.[0]?.text).toContain("dark mode");
|
||||||
|
|
||||||
|
// Test duplicate detection
|
||||||
|
const duplicateResult = await storeTool.execute("test-call-3", {
|
||||||
|
text: "The user prefers dark mode for all applications",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(duplicateResult.details?.action).toBe("duplicate");
|
||||||
|
|
||||||
|
// Test forget
|
||||||
|
const forgetResult = await forgetTool.execute("test-call-4", {
|
||||||
|
memoryId: storedId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(forgetResult.details?.action).toBe("deleted");
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
const recallAfterForget = await recallTool.execute("test-call-5", {
|
||||||
|
query: "dark mode preference",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recallAfterForget.details?.count).toBe(0);
|
||||||
|
}, 60000); // 60s timeout for live API calls
|
||||||
|
});
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
/**
|
||||||
|
* Clawdbot Memory (LanceDB) Plugin
|
||||||
|
*
|
||||||
|
* Long-term memory with vector search for AI conversations.
|
||||||
|
* Uses LanceDB for storage and OpenAI for embeddings.
|
||||||
|
* Provides seamless auto-recall and auto-capture via lifecycle hooks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import * as lancedb from "@lancedb/lancedb";
|
||||||
|
import OpenAI from "openai";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
import { stringEnum } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MEMORY_CATEGORIES,
|
||||||
|
type MemoryCategory,
|
||||||
|
memoryConfigSchema,
|
||||||
|
vectorDimsForModel,
|
||||||
|
} from "./config.js";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type MemoryEntry = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
vector: number[];
|
||||||
|
importance: number;
|
||||||
|
category: MemoryCategory;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MemorySearchResult = {
|
||||||
|
entry: MemoryEntry;
|
||||||
|
score: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LanceDB Provider
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const TABLE_NAME = "memories";
|
||||||
|
|
||||||
|
class MemoryDB {
|
||||||
|
private db: lancedb.Connection | null = null;
|
||||||
|
private table: lancedb.Table | null = null;
|
||||||
|
private initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dbPath: string,
|
||||||
|
private readonly vectorDim: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async ensureInitialized(): Promise<void> {
|
||||||
|
if (this.table) return;
|
||||||
|
if (this.initPromise) return this.initPromise;
|
||||||
|
|
||||||
|
this.initPromise = this.doInitialize();
|
||||||
|
return this.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doInitialize(): Promise<void> {
|
||||||
|
this.db = await lancedb.connect(this.dbPath);
|
||||||
|
const tables = await this.db.tableNames();
|
||||||
|
|
||||||
|
if (tables.includes(TABLE_NAME)) {
|
||||||
|
this.table = await this.db.openTable(TABLE_NAME);
|
||||||
|
} else {
|
||||||
|
this.table = await this.db.createTable(TABLE_NAME, [
|
||||||
|
{
|
||||||
|
id: "__schema__",
|
||||||
|
text: "",
|
||||||
|
vector: new Array(this.vectorDim).fill(0),
|
||||||
|
importance: 0,
|
||||||
|
category: "other",
|
||||||
|
createdAt: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await this.table.delete('id = "__schema__"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async store(
|
||||||
|
entry: Omit<MemoryEntry, "id" | "createdAt">,
|
||||||
|
): Promise<MemoryEntry> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
|
const fullEntry: MemoryEntry = {
|
||||||
|
...entry,
|
||||||
|
id: randomUUID(),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.table!.add([fullEntry]);
|
||||||
|
return fullEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(
|
||||||
|
vector: number[],
|
||||||
|
limit = 5,
|
||||||
|
minScore = 0.5,
|
||||||
|
): Promise<MemorySearchResult[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
|
const results = await this.table!.vectorSearch(vector).limit(limit).toArray();
|
||||||
|
|
||||||
|
// LanceDB uses L2 distance by default; convert to similarity score
|
||||||
|
const mapped = results.map((row) => {
|
||||||
|
const distance = row._distance ?? 0;
|
||||||
|
// Use inverse for a 0-1 range: sim = 1 / (1 + d)
|
||||||
|
const score = 1 / (1 + distance);
|
||||||
|
return {
|
||||||
|
entry: {
|
||||||
|
id: row.id as string,
|
||||||
|
text: row.text as string,
|
||||||
|
vector: row.vector as number[],
|
||||||
|
importance: row.importance as number,
|
||||||
|
category: row.category as MemoryEntry["category"],
|
||||||
|
createdAt: row.createdAt as number,
|
||||||
|
},
|
||||||
|
score,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapped.filter((r) => r.score >= minScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<boolean> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
// Validate UUID format to prevent injection
|
||||||
|
const uuidRegex =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
if (!uuidRegex.test(id)) {
|
||||||
|
throw new Error(`Invalid memory ID format: ${id}`);
|
||||||
|
}
|
||||||
|
await this.table!.delete(`id = '${id}'`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(): Promise<number> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
return this.table!.countRows();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OpenAI Embeddings
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class Embeddings {
|
||||||
|
private client: OpenAI;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
apiKey: string,
|
||||||
|
private model: string,
|
||||||
|
) {
|
||||||
|
this.client = new OpenAI({ apiKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
async embed(text: string): Promise<number[]> {
|
||||||
|
const response = await this.client.embeddings.create({
|
||||||
|
model: this.model,
|
||||||
|
input: text,
|
||||||
|
});
|
||||||
|
return response.data[0].embedding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Rule-based capture filter
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const MEMORY_TRIGGERS = [
|
||||||
|
/zapamatuj si|pamatuj|remember/i,
|
||||||
|
/preferuji|radši|nechci|prefer/i,
|
||||||
|
/rozhodli jsme|budeme používat/i,
|
||||||
|
/\+\d{10,}/,
|
||||||
|
/[\w.-]+@[\w.-]+\.\w+/,
|
||||||
|
/můj\s+\w+\s+je|je\s+můj/i,
|
||||||
|
/my\s+\w+\s+is|is\s+my/i,
|
||||||
|
/i (like|prefer|hate|love|want|need)/i,
|
||||||
|
/always|never|important/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
function shouldCapture(text: string): boolean {
|
||||||
|
if (text.length < 10 || text.length > 500) return false;
|
||||||
|
// Skip injected context from memory recall
|
||||||
|
if (text.includes("<relevant-memories>")) return false;
|
||||||
|
// Skip system-generated content
|
||||||
|
if (text.startsWith("<") && text.includes("</")) return false;
|
||||||
|
// Skip agent summary responses (contain markdown formatting)
|
||||||
|
if (text.includes("**") && text.includes("\n-")) return false;
|
||||||
|
// Skip emoji-heavy responses (likely agent output)
|
||||||
|
const emojiCount = (text.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length;
|
||||||
|
if (emojiCount > 3) return false;
|
||||||
|
return MEMORY_TRIGGERS.some((r) => r.test(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectCategory(text: string): MemoryCategory {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
if (/prefer|radši|like|love|hate|want/i.test(lower)) return "preference";
|
||||||
|
if (/rozhodli|decided|will use|budeme/i.test(lower)) return "decision";
|
||||||
|
if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower))
|
||||||
|
return "entity";
|
||||||
|
if (/is|are|has|have|je|má|jsou/i.test(lower)) return "fact";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Plugin Definition
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const memoryPlugin = {
|
||||||
|
id: "memory-lancedb",
|
||||||
|
name: "Memory (LanceDB)",
|
||||||
|
description: "LanceDB-backed long-term memory with auto-recall/capture",
|
||||||
|
kind: "memory" as const,
|
||||||
|
configSchema: memoryConfigSchema,
|
||||||
|
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
const cfg = memoryConfigSchema.parse(api.pluginConfig);
|
||||||
|
const resolvedDbPath = api.resolvePath(cfg.dbPath!);
|
||||||
|
const vectorDim = vectorDimsForModel(cfg.embedding.model ?? "text-embedding-3-small");
|
||||||
|
const db = new MemoryDB(resolvedDbPath, vectorDim);
|
||||||
|
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!);
|
||||||
|
|
||||||
|
api.logger.info(
|
||||||
|
`memory-lancedb: plugin registered (db: ${resolvedDbPath}, lazy init)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Tools
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
api.registerTool(
|
||||||
|
{
|
||||||
|
name: "memory_recall",
|
||||||
|
label: "Memory Recall",
|
||||||
|
description:
|
||||||
|
"Search through long-term memories. Use when you need context about user preferences, past decisions, or previously discussed topics.",
|
||||||
|
parameters: Type.Object({
|
||||||
|
query: Type.String({ description: "Search query" }),
|
||||||
|
limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
const { query, limit = 5 } = params as { query: string; limit?: number };
|
||||||
|
|
||||||
|
const vector = await embeddings.embed(query);
|
||||||
|
const results = await db.search(vector, limit, 0.1);
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "No relevant memories found." }],
|
||||||
|
details: { count: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = results
|
||||||
|
.map(
|
||||||
|
(r, i) =>
|
||||||
|
`${i + 1}. [${r.entry.category}] ${r.entry.text} (${(r.score * 100).toFixed(0)}%)`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// Strip vector data for serialization (typed arrays can't be cloned)
|
||||||
|
const sanitizedResults = results.map((r) => ({
|
||||||
|
id: r.entry.id,
|
||||||
|
text: r.entry.text,
|
||||||
|
category: r.entry.category,
|
||||||
|
importance: r.entry.importance,
|
||||||
|
score: r.score,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: `Found ${results.length} memories:\n\n${text}` },
|
||||||
|
],
|
||||||
|
details: { count: results.length, memories: sanitizedResults },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: "memory_recall" },
|
||||||
|
);
|
||||||
|
|
||||||
|
api.registerTool(
|
||||||
|
{
|
||||||
|
name: "memory_store",
|
||||||
|
label: "Memory Store",
|
||||||
|
description:
|
||||||
|
"Save important information in long-term memory. Use for preferences, facts, decisions.",
|
||||||
|
parameters: Type.Object({
|
||||||
|
text: Type.String({ description: "Information to remember" }),
|
||||||
|
importance: Type.Optional(
|
||||||
|
Type.Number({ description: "Importance 0-1 (default: 0.7)" }),
|
||||||
|
),
|
||||||
|
category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
const {
|
||||||
|
text,
|
||||||
|
importance = 0.7,
|
||||||
|
category = "other",
|
||||||
|
} = params as {
|
||||||
|
text: string;
|
||||||
|
importance?: number;
|
||||||
|
category?: MemoryEntry["category"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const vector = await embeddings.embed(text);
|
||||||
|
|
||||||
|
// Check for duplicates
|
||||||
|
const existing = await db.search(vector, 1, 0.95);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: `Similar memory already exists: "${existing[0].entry.text}"` },
|
||||||
|
],
|
||||||
|
details: { action: "duplicate", existingId: existing[0].entry.id, existingText: existing[0].entry.text },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await db.store({
|
||||||
|
text,
|
||||||
|
vector,
|
||||||
|
importance,
|
||||||
|
category,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Stored: "${text.slice(0, 100)}..."` }],
|
||||||
|
details: { action: "created", id: entry.id },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: "memory_store" },
|
||||||
|
);
|
||||||
|
|
||||||
|
api.registerTool(
|
||||||
|
{
|
||||||
|
name: "memory_forget",
|
||||||
|
label: "Memory Forget",
|
||||||
|
description: "Delete specific memories. GDPR-compliant.",
|
||||||
|
parameters: Type.Object({
|
||||||
|
query: Type.Optional(Type.String({ description: "Search to find memory" })),
|
||||||
|
memoryId: Type.Optional(Type.String({ description: "Specific memory ID" })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
const { query, memoryId } = params as { query?: string; memoryId?: string };
|
||||||
|
|
||||||
|
if (memoryId) {
|
||||||
|
await db.delete(memoryId);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Memory ${memoryId} forgotten.` }],
|
||||||
|
details: { action: "deleted", id: memoryId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
const vector = await embeddings.embed(query);
|
||||||
|
const results = await db.search(vector, 5, 0.7);
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "No matching memories found." }],
|
||||||
|
details: { found: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 1 && results[0].score > 0.9) {
|
||||||
|
await db.delete(results[0].entry.id);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: `Forgotten: "${results[0].entry.text}"` },
|
||||||
|
],
|
||||||
|
details: { action: "deleted", id: results[0].entry.id },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = results
|
||||||
|
.map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// Strip vector data for serialization
|
||||||
|
const sanitizedCandidates = results.map((r) => ({
|
||||||
|
id: r.entry.id,
|
||||||
|
text: r.entry.text,
|
||||||
|
category: r.entry.category,
|
||||||
|
score: r.score,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Found ${results.length} candidates. Specify memoryId:\n${list}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { action: "candidates", candidates: sanitizedCandidates },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Provide query or memoryId." }],
|
||||||
|
details: { error: "missing_param" },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: "memory_forget" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// CLI Commands
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
api.registerCli(
|
||||||
|
({ program }) => {
|
||||||
|
const memory = program
|
||||||
|
.command("ltm")
|
||||||
|
.description("LanceDB memory plugin commands");
|
||||||
|
|
||||||
|
memory
|
||||||
|
.command("list")
|
||||||
|
.description("List memories")
|
||||||
|
.action(async () => {
|
||||||
|
const count = await db.count();
|
||||||
|
console.log(`Total memories: ${count}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
memory
|
||||||
|
.command("search")
|
||||||
|
.description("Search memories")
|
||||||
|
.argument("<query>", "Search query")
|
||||||
|
.option("--limit <n>", "Max results", "5")
|
||||||
|
.action(async (query, opts) => {
|
||||||
|
const vector = await embeddings.embed(query);
|
||||||
|
const results = await db.search(vector, parseInt(opts.limit), 0.3);
|
||||||
|
// Strip vectors for output
|
||||||
|
const output = results.map((r) => ({
|
||||||
|
id: r.entry.id,
|
||||||
|
text: r.entry.text,
|
||||||
|
category: r.entry.category,
|
||||||
|
importance: r.entry.importance,
|
||||||
|
score: r.score,
|
||||||
|
}));
|
||||||
|
console.log(JSON.stringify(output, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
memory
|
||||||
|
.command("stats")
|
||||||
|
.description("Show memory statistics")
|
||||||
|
.action(async () => {
|
||||||
|
const count = await db.count();
|
||||||
|
console.log(`Total memories: ${count}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ commands: ["ltm"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Lifecycle Hooks
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
// Auto-recall: inject relevant memories before agent starts
|
||||||
|
if (cfg.autoRecall) {
|
||||||
|
api.on("before_agent_start", async (event) => {
|
||||||
|
if (!event.prompt || event.prompt.length < 5) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vector = await embeddings.embed(event.prompt);
|
||||||
|
const results = await db.search(vector, 3, 0.3);
|
||||||
|
|
||||||
|
if (results.length === 0) return;
|
||||||
|
|
||||||
|
const memoryContext = results
|
||||||
|
.map((r) => `- [${r.entry.category}] ${r.entry.text}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
api.logger.info?.(
|
||||||
|
`memory-lancedb: injecting ${results.length} memories into context`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
api.logger.warn(`memory-lancedb: recall failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-capture: analyze and store important information after agent ends
|
||||||
|
if (cfg.autoCapture) {
|
||||||
|
api.on("agent_end", async (event) => {
|
||||||
|
if (!event.success || !event.messages || event.messages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract text content from messages (handling unknown[] type)
|
||||||
|
const texts: string[] = [];
|
||||||
|
for (const msg of event.messages) {
|
||||||
|
// Type guard for message object
|
||||||
|
if (!msg || typeof msg !== "object") continue;
|
||||||
|
const msgObj = msg as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Only process user and assistant messages
|
||||||
|
const role = msgObj.role;
|
||||||
|
if (role !== "user" && role !== "assistant") continue;
|
||||||
|
|
||||||
|
const content = msgObj.content;
|
||||||
|
|
||||||
|
// Handle string content directly
|
||||||
|
if (typeof content === "string") {
|
||||||
|
texts.push(content);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle array content (content blocks)
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const block of content) {
|
||||||
|
if (
|
||||||
|
block &&
|
||||||
|
typeof block === "object" &&
|
||||||
|
"type" in block &&
|
||||||
|
(block as Record<string, unknown>).type === "text" &&
|
||||||
|
"text" in block &&
|
||||||
|
typeof (block as Record<string, unknown>).text === "string"
|
||||||
|
) {
|
||||||
|
texts.push((block as Record<string, unknown>).text as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for capturable content
|
||||||
|
const toCapture = texts.filter(
|
||||||
|
(text) => text && shouldCapture(text),
|
||||||
|
);
|
||||||
|
if (toCapture.length === 0) return;
|
||||||
|
|
||||||
|
// Store each capturable piece (limit to 3 per conversation)
|
||||||
|
let stored = 0;
|
||||||
|
for (const text of toCapture.slice(0, 3)) {
|
||||||
|
const category = detectCategory(text);
|
||||||
|
const vector = await embeddings.embed(text);
|
||||||
|
|
||||||
|
// Check for duplicates (high similarity threshold)
|
||||||
|
const existing = await db.search(vector, 1, 0.95);
|
||||||
|
if (existing.length > 0) continue;
|
||||||
|
|
||||||
|
await db.store({
|
||||||
|
text,
|
||||||
|
vector,
|
||||||
|
importance: 0.7,
|
||||||
|
category,
|
||||||
|
});
|
||||||
|
stored++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stored > 0) {
|
||||||
|
api.logger.info(`memory-lancedb: auto-captured ${stored} memories`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
api.logger.warn(`memory-lancedb: capture failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Service
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
api.registerService({
|
||||||
|
id: "memory-lancedb",
|
||||||
|
start: () => {
|
||||||
|
api.logger.info(
|
||||||
|
`memory-lancedb: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
stop: () => {
|
||||||
|
api.logger.info("memory-lancedb: stopped");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memoryPlugin;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/memory-lancedb",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||||
|
"dependencies": {
|
||||||
|
"@lancedb/lancedb": "^0.15.0",
|
||||||
|
"@sinclair/typebox": "0.34.47",
|
||||||
|
"openai": "^4.104.0"
|
||||||
|
},
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import { msteamsPlugin } from "./src/channel.js";
|
import { msteamsPlugin } from "./src/channel.js";
|
||||||
|
import { setMSTeamsRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
const plugin = {
|
const plugin = {
|
||||||
id: "msteams",
|
id: "msteams",
|
||||||
name: "Microsoft Teams",
|
name: "Microsoft Teams",
|
||||||
description: "Microsoft Teams channel plugin (Bot Framework)",
|
description: "Microsoft Teams channel plugin (Bot Framework)",
|
||||||
register(api: ClawdbotPluginApi) {
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setMSTeamsRuntime(api.runtime);
|
||||||
api.registerChannel({ plugin: msteamsPlugin });
|
api.registerChannel({ plugin: msteamsPlugin });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@microsoft/agents-hosting": "^1.2.2",
|
||||||
|
"@microsoft/agents-hosting-express": "^1.2.2",
|
||||||
|
"@microsoft/agents-hosting-extensions-teams": "^1.2.2",
|
||||||
"clawdbot": "workspace:*",
|
"clawdbot": "workspace:*",
|
||||||
"@microsoft/agents-hosting": "^1.1.1",
|
|
||||||
"@microsoft/agents-hosting-express": "^1.1.1",
|
|
||||||
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
|
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"proper-lockfile": "^4.1.2"
|
"proper-lockfile": "^4.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
import { setMSTeamsRuntime } from "./runtime.js";
|
||||||
|
|
||||||
const detectMimeMock = vi.fn(async () => "image/png");
|
const detectMimeMock = vi.fn(async () => "image/png");
|
||||||
const saveMediaBufferMock = vi.fn(async () => ({
|
const saveMediaBufferMock = vi.fn(async () => ({
|
||||||
path: "/tmp/saved.png",
|
path: "/tmp/saved.png",
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("clawdbot/plugin-sdk", () => ({
|
const runtimeStub = {
|
||||||
detectMime: (...args: unknown[]) => detectMimeMock(...args),
|
media: {
|
||||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
|
detectMime: (...args: unknown[]) => detectMimeMock(...args),
|
||||||
}));
|
},
|
||||||
|
channel: {
|
||||||
|
media: {
|
||||||
|
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime;
|
||||||
|
|
||||||
describe("msteams attachments", () => {
|
describe("msteams attachments", () => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -19,6 +28,7 @@ describe("msteams attachments", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
detectMimeMock.mockClear();
|
detectMimeMock.mockClear();
|
||||||
saveMediaBufferMock.mockClear();
|
saveMediaBufferMock.mockClear();
|
||||||
|
setMSTeamsRuntime(runtimeStub);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildMSTeamsAttachmentPlaceholder", () => {
|
describe("buildMSTeamsAttachmentPlaceholder", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk";
|
import { getMSTeamsRuntime } from "../runtime.js";
|
||||||
import {
|
import {
|
||||||
extractInlineImageCandidates,
|
extractInlineImageCandidates,
|
||||||
inferPlaceholder,
|
inferPlaceholder,
|
||||||
@@ -141,7 +141,7 @@ export async function downloadMSTeamsImageAttachments(params: {
|
|||||||
if (inline.kind !== "data") continue;
|
if (inline.kind !== "data") continue;
|
||||||
if (inline.data.byteLength > params.maxBytes) continue;
|
if (inline.data.byteLength > params.maxBytes) continue;
|
||||||
try {
|
try {
|
||||||
const saved = await saveMediaBuffer(
|
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||||
inline.data,
|
inline.data,
|
||||||
inline.contentType,
|
inline.contentType,
|
||||||
"inbound",
|
"inbound",
|
||||||
@@ -167,12 +167,12 @@ export async function downloadMSTeamsImageAttachments(params: {
|
|||||||
if (!res.ok) continue;
|
if (!res.ok) continue;
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
if (buffer.byteLength > params.maxBytes) continue;
|
if (buffer.byteLength > params.maxBytes) continue;
|
||||||
const mime = await detectMime({
|
const mime = await getMSTeamsRuntime().media.detectMime({
|
||||||
buffer,
|
buffer,
|
||||||
headerMime: res.headers.get("content-type"),
|
headerMime: res.headers.get("content-type"),
|
||||||
filePath: candidate.fileHint ?? candidate.url,
|
filePath: candidate.fileHint ?? candidate.url,
|
||||||
});
|
});
|
||||||
const saved = await saveMediaBuffer(
|
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||||
buffer,
|
buffer,
|
||||||
mime ?? candidate.contentTypeHint,
|
mime ?? candidate.contentTypeHint,
|
||||||
"inbound",
|
"inbound",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk";
|
import { getMSTeamsRuntime } from "../runtime.js";
|
||||||
import { downloadMSTeamsImageAttachments } from "./download.js";
|
import { downloadMSTeamsImageAttachments } from "./download.js";
|
||||||
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -154,13 +154,13 @@ async function downloadGraphHostedImages(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (buffer.byteLength > params.maxBytes) continue;
|
if (buffer.byteLength > params.maxBytes) continue;
|
||||||
const mime = await detectMime({
|
const mime = await getMSTeamsRuntime().media.detectMime({
|
||||||
buffer,
|
buffer,
|
||||||
headerMime: item.contentType ?? undefined,
|
headerMime: item.contentType ?? undefined,
|
||||||
});
|
});
|
||||||
if (mime && !mime.startsWith("image/")) continue;
|
if (mime && !mime.startsWith("image/")) continue;
|
||||||
try {
|
try {
|
||||||
const saved = await saveMediaBuffer(
|
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||||
buffer,
|
buffer,
|
||||||
mime ?? item.contentType ?? undefined,
|
mime ?? item.contentType ?? undefined,
|
||||||
"inbound",
|
"inbound",
|
||||||
|
|||||||
@@ -2,12 +2,29 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
import type { StoredConversationReference } from "./conversation-store.js";
|
import type { StoredConversationReference } from "./conversation-store.js";
|
||||||
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
||||||
|
import { setMSTeamsRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
const runtimeStub = {
|
||||||
|
state: {
|
||||||
|
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
|
||||||
|
const override = env.CLAWDBOT_STATE_DIR?.trim();
|
||||||
|
if (override) return override;
|
||||||
|
const resolvedHome = homedir ? homedir() : os.homedir();
|
||||||
|
return path.join(resolvedHome, ".clawdbot");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime;
|
||||||
|
|
||||||
describe("msteams conversation store (fs)", () => {
|
describe("msteams conversation store (fs)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setMSTeamsRuntime(runtimeStub);
|
||||||
|
});
|
||||||
|
|
||||||
it("filters and prunes expired entries (but keeps legacy ones)", async () => {
|
it("filters and prunes expired entries (but keeps legacy ones)", async () => {
|
||||||
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-msteams-store-"));
|
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-msteams-store-"));
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { SILENT_REPLY_TOKEN } from "clawdbot/plugin-sdk";
|
import { SILENT_REPLY_TOKEN, type PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
import type { StoredConversationReference } from "./conversation-store.js";
|
import type { StoredConversationReference } from "./conversation-store.js";
|
||||||
import {
|
import {
|
||||||
type MSTeamsAdapter,
|
type MSTeamsAdapter,
|
||||||
renderReplyPayloadsToMessages,
|
renderReplyPayloadsToMessages,
|
||||||
sendMSTeamsMessages,
|
sendMSTeamsMessages,
|
||||||
} from "./messenger.js";
|
} from "./messenger.js";
|
||||||
|
import { setMSTeamsRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
const runtimeStub = {
|
||||||
|
channel: {
|
||||||
|
text: {
|
||||||
|
chunkMarkdownText: (text: string, limit: number) => {
|
||||||
|
if (!text) return [];
|
||||||
|
if (limit <= 0 || text.length <= limit) return [text];
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let index = 0; index < text.length; index += limit) {
|
||||||
|
chunks.push(text.slice(index, index + limit));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime;
|
||||||
|
|
||||||
describe("msteams messenger", () => {
|
describe("msteams messenger", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setMSTeamsRuntime(runtimeStub);
|
||||||
|
});
|
||||||
|
|
||||||
describe("renderReplyPayloadsToMessages", () => {
|
describe("renderReplyPayloadsToMessages", () => {
|
||||||
it("filters silent replies", () => {
|
it("filters silent replies", () => {
|
||||||
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
|
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
chunkMarkdownText,
|
|
||||||
isSilentReplyText,
|
isSilentReplyText,
|
||||||
type MSTeamsReplyStyle,
|
type MSTeamsReplyStyle,
|
||||||
type ReplyPayload,
|
type ReplyPayload,
|
||||||
@@ -7,6 +6,7 @@ import {
|
|||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
import type { StoredConversationReference } from "./conversation-store.js";
|
import type { StoredConversationReference } from "./conversation-store.js";
|
||||||
import { classifyMSTeamsSendError } from "./errors.js";
|
import { classifyMSTeamsSendError } from "./errors.js";
|
||||||
|
import { getMSTeamsRuntime } from "./runtime.js";
|
||||||
|
|
||||||
type SendContext = {
|
type SendContext = {
|
||||||
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
||||||
@@ -108,7 +108,7 @@ function pushTextMessages(
|
|||||||
) {
|
) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (opts.chunkText) {
|
if (opts.chunkText) {
|
||||||
for (const chunk of chunkMarkdownText(text, opts.chunkLimit)) {
|
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
||||||
out.push(trimmed);
|
out.push(trimmed);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
import { danger } from "clawdbot/plugin-sdk";
|
|
||||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||||
import type { MSTeamsAdapter } from "./messenger.js";
|
import type { MSTeamsAdapter } from "./messenger.js";
|
||||||
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
|
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
|
||||||
@@ -42,7 +41,7 @@ export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
|
|||||||
try {
|
try {
|
||||||
await handleTeamsMessage(context as MSTeamsTurnContext);
|
await handleTeamsMessage(context as MSTeamsTurnContext);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
deps.runtime.error?.(danger(`msteams handler failed: ${String(err)}`));
|
deps.runtime.error?.(`msteams handler failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,25 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
clearHistoryEntries,
|
clearHistoryEntries,
|
||||||
createInboundDebouncer,
|
|
||||||
danger,
|
|
||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
readChannelAllowFromStore,
|
|
||||||
recordSessionMetaFromInbound,
|
|
||||||
recordPendingHistoryEntry,
|
recordPendingHistoryEntry,
|
||||||
resolveAgentRoute,
|
|
||||||
resolveCommandAuthorizedFromAuthorizers,
|
|
||||||
resolveInboundDebounceMs,
|
|
||||||
resolveMentionGating,
|
resolveMentionGating,
|
||||||
resolveStorePath,
|
|
||||||
dispatchReplyFromConfig,
|
|
||||||
finalizeInboundContext,
|
|
||||||
formatAgentEnvelope,
|
|
||||||
formatAllowlistMatchMeta,
|
formatAllowlistMatchMeta,
|
||||||
hasControlCommand,
|
|
||||||
logVerbose,
|
|
||||||
shouldLogVerbose,
|
|
||||||
upsertChannelPairingRequest,
|
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
@@ -50,6 +35,7 @@ import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
|
|||||||
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
|
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
|
||||||
import type { MSTeamsTurnContext } from "../sdk-types.js";
|
import type { MSTeamsTurnContext } from "../sdk-types.js";
|
||||||
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
|
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
|
||||||
|
import { getMSTeamsRuntime } from "../runtime.js";
|
||||||
|
|
||||||
export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||||
const {
|
const {
|
||||||
@@ -64,6 +50,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
pollStore,
|
pollStore,
|
||||||
log,
|
log,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
const core = getMSTeamsRuntime();
|
||||||
|
const logVerboseMessage = (message: string) => {
|
||||||
|
if (core.logging.shouldLogVerbose()) {
|
||||||
|
log.debug(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
const msteamsCfg = cfg.channels?.msteams;
|
const msteamsCfg = cfg.channels?.msteams;
|
||||||
const historyLimit = Math.max(
|
const historyLimit = Math.max(
|
||||||
0,
|
0,
|
||||||
@@ -72,7 +64,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
);
|
);
|
||||||
const conversationHistories = new Map<string, HistoryEntry[]>();
|
const conversationHistories = new Map<string, HistoryEntry[]>();
|
||||||
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "msteams" });
|
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
||||||
|
cfg,
|
||||||
|
channel: "msteams",
|
||||||
|
});
|
||||||
|
|
||||||
type MSTeamsDebounceEntry = {
|
type MSTeamsDebounceEntry = {
|
||||||
context: MSTeamsTurnContext;
|
context: MSTeamsTurnContext;
|
||||||
@@ -126,7 +121,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
|
|
||||||
const senderName = from.name ?? from.id;
|
const senderName = from.name ?? from.id;
|
||||||
const senderId = from.aadObjectId ?? from.id;
|
const senderId = from.aadObjectId ?? from.id;
|
||||||
const storedAllowFrom = await readChannelAllowFromStore("msteams").catch(() => []);
|
const storedAllowFrom = await core.channel.pairing
|
||||||
|
.readAllowFromStore("msteams")
|
||||||
|
.catch(() => []);
|
||||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||||
|
|
||||||
// Check DM policy for direct messages.
|
// Check DM policy for direct messages.
|
||||||
@@ -151,7 +148,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
|
|
||||||
if (!allowMatch.allowed) {
|
if (!allowMatch.allowed) {
|
||||||
if (dmPolicy === "pairing") {
|
if (dmPolicy === "pairing") {
|
||||||
const request = await upsertChannelPairingRequest({
|
const request = await core.channel.pairing.upsertPairingRequest({
|
||||||
channel: "msteams",
|
channel: "msteams",
|
||||||
id: senderId,
|
id: senderId,
|
||||||
meta: { name: senderName },
|
meta: { name: senderName },
|
||||||
@@ -254,15 +251,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
senderId,
|
senderId,
|
||||||
senderName,
|
senderName,
|
||||||
});
|
});
|
||||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
authorizers: [
|
authorizers: [
|
||||||
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
||||||
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (hasControlCommand(text, cfg) && !commandAuthorized) {
|
if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) {
|
||||||
logVerbose(`msteams: drop control command from unauthorized sender ${senderId}`);
|
logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +326,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
: `msteams:group:${conversationId}`;
|
: `msteams:group:${conversationId}`;
|
||||||
const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`;
|
const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`;
|
||||||
|
|
||||||
const route = resolveAgentRoute({
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "msteams",
|
channel: "msteams",
|
||||||
peer: {
|
peer: {
|
||||||
@@ -343,7 +340,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
? `Teams DM from ${senderName}`
|
? `Teams DM from ${senderName}`
|
||||||
: `Teams message in ${conversationType} from ${senderName}`;
|
: `Teams message in ${conversationType} from ${senderName}`;
|
||||||
|
|
||||||
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
|
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
|
||||||
});
|
});
|
||||||
@@ -409,7 +406,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
|
|
||||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||||
const body = formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Teams",
|
channel: "Teams",
|
||||||
from: envelopeFrom,
|
from: envelopeFrom,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -425,7 +422,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
limit: historyLimit,
|
limit: historyLimit,
|
||||||
currentMessage: combinedBody,
|
currentMessage: combinedBody,
|
||||||
formatEntry: (entry) =>
|
formatEntry: (entry) =>
|
||||||
formatAgentEnvelope({
|
core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Teams",
|
channel: "Teams",
|
||||||
from: conversationType,
|
from: conversationType,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
@@ -434,7 +431,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctxPayload = finalizeInboundContext({
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
Body: combinedBody,
|
Body: combinedBody,
|
||||||
RawBody: rawBody,
|
RawBody: rawBody,
|
||||||
CommandBody: rawBody,
|
CommandBody: rawBody,
|
||||||
@@ -458,20 +455,18 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
...mediaPayload,
|
...mediaPayload,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = resolveStorePath(cfg.session?.store, {
|
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
});
|
||||||
void recordSessionMetaFromInbound({
|
void core.channel.session.recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
logVerbose(`msteams: failed updating session meta: ${String(err)}`);
|
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (shouldLogVerbose()) {
|
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
||||||
logVerbose(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
|
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -493,7 +488,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
|
|
||||||
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
||||||
try {
|
try {
|
||||||
const { queuedFinal, counts } = await dispatchReplyFromConfig({
|
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
@@ -513,18 +508,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (shouldLogVerbose()) {
|
const finalCount = counts.final;
|
||||||
const finalCount = counts.final;
|
logVerboseMessage(
|
||||||
logVerbose(
|
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
||||||
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isRoomish && historyKey && historyLimit > 0) {
|
if (isRoomish && historyKey && historyLimit > 0) {
|
||||||
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
|
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("dispatch failed", { error: String(err) });
|
log.error("dispatch failed", { error: String(err) });
|
||||||
runtime.error?.(danger(`msteams dispatch failed: ${String(err)}`));
|
runtime.error?.(`msteams dispatch failed: ${String(err)}`);
|
||||||
try {
|
try {
|
||||||
await context.sendActivity(
|
await context.sendActivity(
|
||||||
`⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`,
|
`⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
@@ -535,7 +528,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const inboundDebouncer = createInboundDebouncer<MSTeamsDebounceEntry>({
|
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<MSTeamsDebounceEntry>({
|
||||||
debounceMs: inboundDebounceMs,
|
debounceMs: inboundDebounceMs,
|
||||||
buildKey: (entry) => {
|
buildKey: (entry) => {
|
||||||
const conversationId = normalizeMSTeamsConversationId(
|
const conversationId = normalizeMSTeamsConversationId(
|
||||||
@@ -549,7 +542,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
shouldDebounce: (entry) => {
|
shouldDebounce: (entry) => {
|
||||||
if (!entry.text.trim()) return false;
|
if (!entry.text.trim()) return false;
|
||||||
if (entry.attachments.length > 0) return false;
|
if (entry.attachments.length > 0) return false;
|
||||||
return !hasControlCommand(entry.text, cfg);
|
return !core.channel.text.hasControlCommand(entry.text, cfg);
|
||||||
},
|
},
|
||||||
onFlush: async (entries) => {
|
onFlush: async (entries) => {
|
||||||
const last = entries.at(-1);
|
const last = entries.at(-1);
|
||||||
@@ -579,7 +572,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
runtime.error?.(danger(`msteams debounce flush failed: ${String(err)}`));
|
runtime.error?.(`msteams debounce flush failed: ${String(err)}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import {
|
import {
|
||||||
getChildLogger,
|
|
||||||
mergeAllowlist,
|
mergeAllowlist,
|
||||||
resolveTextChunkLimit,
|
|
||||||
summarizeMapping,
|
summarizeMapping,
|
||||||
type ClawdbotConfig,
|
type ClawdbotConfig,
|
||||||
type RuntimeEnv,
|
type RuntimeEnv,
|
||||||
@@ -19,8 +17,7 @@ import {
|
|||||||
} from "./resolve-allowlist.js";
|
} from "./resolve-allowlist.js";
|
||||||
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||||
import { resolveMSTeamsCredentials } from "./token.js";
|
import { resolveMSTeamsCredentials } from "./token.js";
|
||||||
|
import { getMSTeamsRuntime } from "./runtime.js";
|
||||||
const log = getChildLogger({ name: "msteams" });
|
|
||||||
|
|
||||||
export type MonitorMSTeamsOpts = {
|
export type MonitorMSTeamsOpts = {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
@@ -38,6 +35,8 @@ export type MonitorMSTeamsResult = {
|
|||||||
export async function monitorMSTeamsProvider(
|
export async function monitorMSTeamsProvider(
|
||||||
opts: MonitorMSTeamsOpts,
|
opts: MonitorMSTeamsOpts,
|
||||||
): Promise<MonitorMSTeamsResult> {
|
): Promise<MonitorMSTeamsResult> {
|
||||||
|
const core = getMSTeamsRuntime();
|
||||||
|
const log = core.logging.getChildLogger({ name: "msteams" });
|
||||||
let cfg = opts.cfg;
|
let cfg = opts.cfg;
|
||||||
let msteamsCfg = cfg.channels?.msteams;
|
let msteamsCfg = cfg.channels?.msteams;
|
||||||
if (!msteamsCfg?.enabled) {
|
if (!msteamsCfg?.enabled) {
|
||||||
@@ -197,7 +196,7 @@ export async function monitorMSTeamsProvider(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const port = msteamsCfg.webhook?.port ?? 3978;
|
const port = msteamsCfg.webhook?.port ?? 3978;
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "msteams");
|
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "msteams");
|
||||||
const MB = 1024 * 1024;
|
const MB = 1024 * 1024;
|
||||||
const agentDefaults = cfg.agents?.defaults;
|
const agentDefaults = cfg.agents?.defaults;
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
|
|||||||
Loaded 100 of 379 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user