Compare commits
71 Commits
refactor/g
...
feat/boot-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
296275b53b | ||
|
|
4c9760bbbc | ||
|
|
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 | ||
|
|
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 | ||
|
|
ebfeb7a6bf | ||
|
|
b594f5130d | ||
|
|
e2bb5eecf3 |
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -1,4 +0,0 @@
|
||||
[submodule "Peekaboo"]
|
||||
path = Peekaboo
|
||||
url = https://github.com/steipete/Peekaboo.git
|
||||
branch = main
|
||||
@@ -1,6 +1,6 @@
|
||||
# Repository Guidelines
|
||||
- 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
|
||||
- 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.
|
||||
- Never update the Carbon dependency.
|
||||
- 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.
|
||||
- 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.**
|
||||
|
||||
62
CHANGELOG.md
62
CHANGELOG.md
@@ -2,26 +2,60 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.18-3
|
||||
## 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.
|
||||
- Exec: add host/security/ask routing for gateway + node exec.
|
||||
- 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: 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.
|
||||
- Hooks: run `BOOT.md` on gateway startup with the boot-md hook. (#1164) — thanks @ngutman.
|
||||
- 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.
|
||||
- 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: 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
|
||||
- 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)
|
||||
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
||||
- Exec approvals: parse command tokens correctly for PATH and relative resolution.
|
||||
- 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.
|
||||
- Tests: clean up gateway env stubs and assert config.apply sentinel writes. (#1148) — thanks @TSavo.
|
||||
|
||||
## 2026.1.18-2
|
||||
|
||||
### Fixes
|
||||
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
|
||||
- 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
|
||||
|
||||
@@ -49,22 +83,6 @@ Docs: https://docs.clawd.bot
|
||||
### Fixes
|
||||
- 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
|
||||
|
||||
### Changes
|
||||
|
||||
1
Peekaboo
1
Peekaboo
Submodule Peekaboo deleted from 5c195f5e46
@@ -13,7 +13,7 @@ let package = Package(
|
||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||
],
|
||||
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"),
|
||||
],
|
||||
targets: [
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
{
|
||||
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
|
||||
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -10,15 +28,6 @@
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "eventsource",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/mattt/eventsource.git",
|
||||
"state" : {
|
||||
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "menubarextraaccess",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -28,6 +37,15 @@
|
||||
"version" : "1.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "peekaboo",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Peekaboo.git",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sparkle",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -46,33 +64,6 @@
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -82,24 +73,6 @@
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -118,24 +91,6 @@
|
||||
"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",
|
||||
"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/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/steipete/Peekaboo.git", branch: "main"),
|
||||
.package(path: "../shared/ClawdbotKit"),
|
||||
.package(path: "../../Swabble"),
|
||||
.package(path: "../../Peekaboo/Core/PeekabooCore"),
|
||||
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@@ -61,8 +60,8 @@ let package = Package(
|
||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||
.product(name: "Logging", package: "swift-log"),
|
||||
.product(name: "Sparkle", package: "Sparkle"),
|
||||
.product(name: "PeekabooBridge", package: "PeekabooCore"),
|
||||
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
||||
.product(name: "PeekabooBridge", package: "Peekaboo"),
|
||||
.product(name: "PeekabooAutomationKit", package: "Peekaboo"),
|
||||
],
|
||||
exclude: [
|
||||
"Resources/Info.plist",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Security
|
||||
@@ -121,6 +122,13 @@ struct ExecApprovalsFile: Codable {
|
||||
var agents: [String: ExecApprovalsAgent]?
|
||||
}
|
||||
|
||||
struct ExecApprovalsSnapshot: Codable {
|
||||
var path: String
|
||||
var exists: Bool
|
||||
var hash: String
|
||||
var file: ExecApprovalsFile
|
||||
}
|
||||
|
||||
struct ExecApprovalsResolved {
|
||||
let url: URL
|
||||
let socketPath: String
|
||||
@@ -153,6 +161,58 @@ enum ExecApprovalsStore {
|
||||
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 {
|
||||
let url = self.fileURL()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
@@ -162,7 +222,7 @@ enum ExecApprovalsStore {
|
||||
let data = try Data(contentsOf: url)
|
||||
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
||||
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
|
||||
} catch {
|
||||
@@ -204,7 +264,7 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func resolve(agentId: String?) -> ExecApprovalsResolved {
|
||||
var file = self.ensureFile()
|
||||
let file = self.ensureFile()
|
||||
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||
let resolvedDefaults = ExecApprovalsResolvedDefaults(
|
||||
security: defaults.security ?? self.defaultSecurity,
|
||||
@@ -372,6 +432,12 @@ enum ExecApprovalsStore {
|
||||
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 {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed == "~" {
|
||||
@@ -397,11 +463,32 @@ struct ExecCommandResolution: Sendable {
|
||||
let executableName: 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? {
|
||||
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
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 resolvedPath: String? = {
|
||||
if hasPathSeparator {
|
||||
@@ -419,6 +506,20 @@ struct ExecCommandResolution: Sendable {
|
||||
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] {
|
||||
let raw = env?["PATH"]
|
||||
if let raw, !raw.isEmpty {
|
||||
@@ -439,6 +540,12 @@ enum ExecCommandFormatter {
|
||||
return "\"\(escaped)\""
|
||||
}.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 {
|
||||
@@ -522,7 +629,7 @@ struct ExecEventPayload: Codable, Sendable {
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.count <= maxChars { return trimmed }
|
||||
let suffix = trimmed.suffix(maxChars)
|
||||
return "… (truncated) \(suffix)"
|
||||
return "... (truncated) \(suffix)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,9 +157,10 @@ final class ExecApprovalsPromptServer {
|
||||
}
|
||||
}
|
||||
|
||||
private enum ExecApprovalsPromptPresenter {
|
||||
enum ExecApprovalsPromptPresenter {
|
||||
@MainActor
|
||||
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
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 socketPath: String
|
||||
private let token: String
|
||||
|
||||
@@ -16,6 +16,10 @@ enum GatewayLaunchAgentManager {
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||
_ = bundlePath
|
||||
guard !CommandResolver.connectionModeIsRemote() else {
|
||||
self.logger.info("launchd change skipped (remote mode)")
|
||||
return nil
|
||||
}
|
||||
if enabled, self.isLaunchAgentWriteDisabled() {
|
||||
self.logger.info("launchd enable skipped (disable marker set)")
|
||||
return nil
|
||||
@@ -112,7 +116,9 @@ extension GatewayLaunchAgentManager {
|
||||
{
|
||||
let command = CommandResolver.clawdbotCommand(
|
||||
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
|
||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||
|
||||
@@ -114,6 +114,9 @@ final class GatewayProcessManager {
|
||||
self.lastFailureReason = nil
|
||||
self.status = .stopped
|
||||
self.logger.info("gateway stop requested")
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
return
|
||||
}
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
Task {
|
||||
_ = await GatewayLaunchAgentManager.set(
|
||||
|
||||
@@ -158,6 +158,8 @@ final class MacNodeModeCoordinator {
|
||||
ClawdbotSystemCommand.notify.rawValue,
|
||||
ClawdbotSystemCommand.which.rawValue,
|
||||
ClawdbotSystemCommand.run.rawValue,
|
||||
ClawdbotSystemCommand.execApprovalsGet.rawValue,
|
||||
ClawdbotSystemCommand.execApprovalsSet.rawValue,
|
||||
]
|
||||
|
||||
let capsSet = Set(caps)
|
||||
|
||||
@@ -64,6 +64,10 @@ actor MacNodeRuntime {
|
||||
return try await self.handleSystemWhich(req)
|
||||
case ClawdbotSystemCommand.notify.rawValue:
|
||||
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:
|
||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
|
||||
}
|
||||
@@ -432,6 +436,7 @@ actor MacNodeRuntime {
|
||||
guard !command.isEmpty else {
|
||||
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 agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
||||
@@ -444,7 +449,12 @@ actor MacNodeRuntime {
|
||||
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: self.mainSessionKey
|
||||
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
|
||||
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
||||
: nil
|
||||
@@ -463,7 +473,7 @@ actor MacNodeRuntime {
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
command: displayCommand,
|
||||
reason: "security=deny"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
@@ -477,12 +487,11 @@ actor MacNodeRuntime {
|
||||
return false
|
||||
}()
|
||||
|
||||
var approvedByAsk = false
|
||||
if requiresAsk {
|
||||
let decision = await ExecApprovalsSocketClient.requestDecision(
|
||||
socketPath: approvals.socketPath,
|
||||
token: approvals.token,
|
||||
request: ExecApprovalPromptRequest(
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
let decision: ExecApprovalDecision? = await ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: displayCommand,
|
||||
cwd: params.cwd,
|
||||
host: "node",
|
||||
security: security.rawValue,
|
||||
@@ -498,21 +507,40 @@ actor MacNodeRuntime {
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
command: displayCommand,
|
||||
reason: "user-denied"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied")
|
||||
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(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
command: displayCommand,
|
||||
reason: "approval-required"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
@@ -520,6 +548,7 @@ actor MacNodeRuntime {
|
||||
message: "SYSTEM_RUN_DENIED: approval required")
|
||||
}
|
||||
case .allowAlways?:
|
||||
approvedByAsk = true
|
||||
if security == .allowlist {
|
||||
let pattern = resolution?.resolvedPath ??
|
||||
resolution?.rawExecutable ??
|
||||
@@ -530,20 +559,33 @@ actor MacNodeRuntime {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
ExecApprovalsStore.recordAllowlistUse(
|
||||
agentId: agentId,
|
||||
pattern: match.pattern,
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
command: displayCommand,
|
||||
resolvedPath: resolution?.resolvedPath)
|
||||
}
|
||||
|
||||
let env = Self.sanitizedEnv(params.env)
|
||||
|
||||
if params.needsScreenRecording == true {
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
@@ -554,7 +596,7 @@ actor MacNodeRuntime {
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
command: displayCommand,
|
||||
reason: "permission:screenRecording"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
@@ -570,20 +612,23 @@ actor MacNodeRuntime {
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: ExecCommandFormatter.displayString(for: command)))
|
||||
command: displayCommand))
|
||||
let result = await ShellExecutor.runDetailed(
|
||||
command: command,
|
||||
cwd: params.cwd,
|
||||
env: env,
|
||||
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(
|
||||
"exec.finished",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
command: displayCommand,
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
@@ -635,6 +680,72 @@ actor MacNodeRuntime {
|
||||
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 {
|
||||
guard let sender = self.eventSender else { return }
|
||||
guard let data = try? JSONEncoder().encode(payload),
|
||||
|
||||
@@ -13,18 +13,16 @@ struct SystemRunSettingsView: View {
|
||||
Text("Exec approvals")
|
||||
.font(.body)
|
||||
Spacer(minLength: 0)
|
||||
if self.model.agentIds.count > 1 {
|
||||
Picker("Agent", selection: Binding(
|
||||
get: { self.model.selectedAgentId },
|
||||
set: { self.model.selectAgent($0) }))
|
||||
{
|
||||
ForEach(self.model.agentIds, id: \.self) { id in
|
||||
Text(id).tag(id)
|
||||
}
|
||||
Picker("Agent", selection: Binding(
|
||||
get: { self.model.selectedAgentId },
|
||||
set: { self.model.selectAgent($0) }))
|
||||
{
|
||||
ForEach(self.model.agentPickerIds, id: \.self) { id in
|
||||
Text(self.model.label(for: id)).tag(id)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 180, alignment: .trailing)
|
||||
}
|
||||
|
||||
Picker("", selection: self.$tab) {
|
||||
@@ -82,7 +80,9 @@ struct SystemRunSettingsView: View {
|
||||
.labelsHidden()
|
||||
.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)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -101,31 +101,37 @@ struct SystemRunSettingsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
|
||||
.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.")
|
||||
if self.model.isDefaultsScope {
|
||||
Text("Allowlists are per-agent. Select an agent to edit its allowlist.")
|
||||
.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) })
|
||||
HStack(spacing: 8) {
|
||||
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
|
||||
.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)
|
||||
.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()))")
|
||||
.font(.caption)
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -201,6 +215,7 @@ struct ExecAllowlistRow: View {
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ExecApprovalsSettingsModel {
|
||||
private static let defaultsScopeId = "__defaults__"
|
||||
var agentIds: [String] = []
|
||||
var selectedAgentId: String = "main"
|
||||
var defaultAgentId: String = "main"
|
||||
@@ -211,6 +226,19 @@ final class ExecApprovalsSettingsModel {
|
||||
var entries: [ExecAllowlistEntry] = []
|
||||
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 {
|
||||
await self.refreshAgents()
|
||||
self.loadSettings(for: self.selectedAgentId)
|
||||
@@ -242,6 +270,9 @@ final class ExecApprovalsSettingsModel {
|
||||
}
|
||||
self.agentIds = ids
|
||||
self.defaultAgentId = defaultId ?? "main"
|
||||
if self.selectedAgentId == Self.defaultsScopeId {
|
||||
return
|
||||
}
|
||||
if !self.agentIds.contains(self.selectedAgentId) {
|
||||
self.selectedAgentId = self.defaultAgentId
|
||||
}
|
||||
@@ -254,6 +285,15 @@ final class ExecApprovalsSettingsModel {
|
||||
}
|
||||
|
||||
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)
|
||||
self.security = resolved.agent.security
|
||||
self.ask = resolved.agent.ask
|
||||
@@ -265,36 +305,61 @@ final class ExecApprovalsSettingsModel {
|
||||
|
||||
func setSecurity(_ security: ExecSecurity) {
|
||||
self.security = security
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.security = security
|
||||
if self.isDefaultsScope {
|
||||
ExecApprovalsStore.updateDefaults { defaults in
|
||||
defaults.security = security
|
||||
}
|
||||
} else {
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.security = security
|
||||
}
|
||||
}
|
||||
self.syncQuickMode()
|
||||
}
|
||||
|
||||
func setAsk(_ ask: ExecAsk) {
|
||||
self.ask = ask
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.ask = ask
|
||||
if self.isDefaultsScope {
|
||||
ExecApprovalsStore.updateDefaults { defaults in
|
||||
defaults.ask = ask
|
||||
}
|
||||
} else {
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.ask = ask
|
||||
}
|
||||
}
|
||||
self.syncQuickMode()
|
||||
}
|
||||
|
||||
func setAskFallback(_ mode: ExecSecurity) {
|
||||
self.askFallback = mode
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.askFallback = mode
|
||||
if self.isDefaultsScope {
|
||||
ExecApprovalsStore.updateDefaults { defaults in
|
||||
defaults.askFallback = mode
|
||||
}
|
||||
} else {
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.askFallback = mode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setAutoAllowSkills(_ enabled: Bool) {
|
||||
self.autoAllowSkills = enabled
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.autoAllowSkills = enabled
|
||||
if self.isDefaultsScope {
|
||||
ExecApprovalsStore.updateDefaults { defaults in
|
||||
defaults.autoAllowSkills = enabled
|
||||
}
|
||||
} else {
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.autoAllowSkills = enabled
|
||||
}
|
||||
}
|
||||
Task { await self.refreshSkillBins(force: enabled) }
|
||||
}
|
||||
|
||||
func addEntry(_ pattern: String) {
|
||||
guard !self.isDefaultsScope else { return }
|
||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
|
||||
@@ -302,12 +367,14 @@ final class ExecApprovalsSettingsModel {
|
||||
}
|
||||
|
||||
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
|
||||
guard !self.isDefaultsScope else { return }
|
||||
guard self.entries.indices.contains(index) else { return }
|
||||
self.entries[index] = entry
|
||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||
}
|
||||
|
||||
func removeEntry(at index: Int) {
|
||||
guard !self.isDefaultsScope else { return }
|
||||
guard self.entries.indices.contains(index) else { return }
|
||||
self.entries.remove(at: index)
|
||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||
@@ -323,6 +390,10 @@ final class ExecApprovalsSettingsModel {
|
||||
}
|
||||
|
||||
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 {
|
||||
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
||||
}
|
||||
|
||||
@@ -1632,6 +1632,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 let sessionkey: String
|
||||
public let limit: Int?
|
||||
|
||||
@@ -134,4 +134,27 @@ import Testing
|
||||
#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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ public enum ClawdbotSystemCommand: String, Codable, Sendable {
|
||||
case run = "system.run"
|
||||
case which = "system.which"
|
||||
case notify = "system.notify"
|
||||
case execApprovalsGet = "system.execApprovals.get"
|
||||
case execApprovalsSet = "system.execApprovals.set"
|
||||
}
|
||||
|
||||
public enum ClawdbotNotificationPriority: String, Codable, Sendable {
|
||||
@@ -20,6 +22,7 @@ public enum ClawdbotNotificationDelivery: String, Codable, Sendable {
|
||||
|
||||
public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||
public var command: [String]
|
||||
public var rawCommand: String?
|
||||
public var cwd: String?
|
||||
public var env: [String: String]?
|
||||
public var timeoutMs: Int?
|
||||
@@ -29,6 +32,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||
|
||||
public init(
|
||||
command: [String],
|
||||
rawCommand: String? = nil,
|
||||
cwd: String? = nil,
|
||||
env: [String: String]? = nil,
|
||||
timeoutMs: Int? = nil,
|
||||
@@ -37,6 +41,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||
sessionKey: String? = nil)
|
||||
{
|
||||
self.command = command
|
||||
self.rawCommand = rawCommand
|
||||
self.cwd = cwd
|
||||
self.env = env
|
||||
self.timeoutMs = timeoutMs
|
||||
|
||||
194
docs.acp.md
Normal file
194
docs.acp.md
Normal file
@@ -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`
|
||||
@@ -1,11 +1,13 @@
|
||||
---
|
||||
summary: "Slack socket mode setup and Clawdbot config"
|
||||
read_when: "Setting up Slack or debugging Slack socket mode"
|
||||
summary: "Slack setup for socket or HTTP webhook 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**.
|
||||
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
|
||||
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.
|
||||
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-...`).
|
||||
@@ -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.
|
||||
|
||||
## Clawdbot config (minimal)
|
||||
### Clawdbot config (minimal)
|
||||
|
||||
Set tokens via env vars (recommended):
|
||||
- `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,
|
||||
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
|
||||
@@ -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,
|
||||
search) prefer the user token when configured, otherwise the bot token.
|
||||
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
|
||||
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
|
||||
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.
|
||||
- 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
|
||||
user scopes if you plan to configure a user token.
|
||||
|
||||
|
||||
166
docs/cli/acp.md
Normal file
166
docs/cli/acp.md
Normal file
@@ -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.
|
||||
44
docs/cli/approvals.md
Normal file
44
docs/cli/approvals.md
Normal file
@@ -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 set browser.executablePath "/usr/bin/google-chrome"
|
||||
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
|
||||
```
|
||||
|
||||
@@ -27,6 +28,13 @@ clawdbot config get agents.defaults.workspace
|
||||
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 are parsed as JSON5 when possible; otherwise they are treated as strings.
|
||||
|
||||
@@ -9,6 +9,9 @@ read_when:
|
||||
|
||||
Manage the Gateway daemon (background service).
|
||||
|
||||
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains
|
||||
as a legacy alias for compatibility.
|
||||
|
||||
Related:
|
||||
- Gateway CLI: [Gateway](/cli/gateway)
|
||||
- macOS platform notes: [macOS](/platforms/macos)
|
||||
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
# `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:
|
||||
- Hooks: [Hooks](/hooks)
|
||||
@@ -29,9 +29,10 @@ List all discovered hooks from workspace, managed, and bundled directories.
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Hooks (3/3 ready)
|
||||
Hooks (4/4 ready)
|
||||
|
||||
Ready:
|
||||
🚀 boot-md ✓ - Run BOOT.md on gateway startup
|
||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
||||
💾 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
|
||||
@@ -107,8 +108,8 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
|
||||
```
|
||||
Hooks Status
|
||||
|
||||
Total hooks: 2
|
||||
Ready: 2
|
||||
Total hooks: 4
|
||||
Ready: 4
|
||||
Not ready: 0
|
||||
```
|
||||
|
||||
@@ -273,3 +274,17 @@ clawdbot hooks enable 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)
|
||||
|
||||
@@ -23,15 +23,19 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`message`](/cli/message)
|
||||
- [`agent`](/cli/agent)
|
||||
- [`agents`](/cli/agents)
|
||||
- [`acp`](/cli/acp)
|
||||
- [`status`](/cli/status)
|
||||
- [`health`](/cli/health)
|
||||
- [`sessions`](/cli/sessions)
|
||||
- [`gateway`](/cli/gateway)
|
||||
- [`daemon`](/cli/daemon)
|
||||
- [`service`](/cli/service)
|
||||
- [`logs`](/cli/logs)
|
||||
- [`models`](/cli/models)
|
||||
- [`memory`](/cli/memory)
|
||||
- [`nodes`](/cli/nodes)
|
||||
- [`node`](/cli/node)
|
||||
- [`approvals`](/cli/approvals)
|
||||
- [`sandbox`](/cli/sandbox)
|
||||
- [`tui`](/cli/tui)
|
||||
- [`browser`](/cli/browser)
|
||||
@@ -125,6 +129,7 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
list
|
||||
add
|
||||
delete
|
||||
acp
|
||||
status
|
||||
health
|
||||
sessions
|
||||
@@ -140,6 +145,21 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
start
|
||||
stop
|
||||
restart
|
||||
service
|
||||
gateway
|
||||
status
|
||||
install
|
||||
uninstall
|
||||
start
|
||||
stop
|
||||
restart
|
||||
node
|
||||
status
|
||||
install
|
||||
uninstall
|
||||
start
|
||||
stop
|
||||
restart
|
||||
logs
|
||||
models
|
||||
list
|
||||
@@ -168,21 +188,19 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
runs
|
||||
run
|
||||
nodes
|
||||
status
|
||||
describe
|
||||
list
|
||||
pending
|
||||
approve
|
||||
reject
|
||||
rename
|
||||
invoke
|
||||
run
|
||||
notify
|
||||
camera list|snap|clip
|
||||
canvas snapshot|present|hide|navigate|eval
|
||||
canvas a2ui push|reset
|
||||
screen record
|
||||
location get
|
||||
node
|
||||
start
|
||||
daemon
|
||||
status
|
||||
install
|
||||
uninstall
|
||||
start
|
||||
stop
|
||||
restart
|
||||
approvals
|
||||
get
|
||||
set
|
||||
allowlist add|remove
|
||||
browser
|
||||
status
|
||||
start
|
||||
@@ -506,6 +524,11 @@ Options:
|
||||
- `--force`
|
||||
- `--json`
|
||||
|
||||
### `acp`
|
||||
Run the ACP bridge that connects IDEs to the Gateway.
|
||||
|
||||
See [`acp`](/cli/acp) for full options and examples.
|
||||
|
||||
### `status`
|
||||
Show linked session health and recent recipients.
|
||||
|
||||
@@ -518,6 +541,9 @@ Options:
|
||||
- `--verbose`
|
||||
- `--debug` (alias for `--verbose`)
|
||||
|
||||
Notes:
|
||||
- Overview includes Gateway + Node service status when available.
|
||||
|
||||
### Usage tracking
|
||||
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`.
|
||||
|
||||
## 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` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).
|
||||
@@ -788,7 +831,7 @@ Subcommands:
|
||||
- `nodes reject <requestId>`
|
||||
- `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 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)
|
||||
|
||||
Camera:
|
||||
|
||||
@@ -28,3 +28,5 @@ clawdbot memory search "release checklist"
|
||||
## Options
|
||||
|
||||
- `--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).
|
||||
|
||||
96
docs/cli/node.md
Normal file
96
docs/cli/node.md
Normal file
@@ -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)
|
||||
51
docs/cli/service.md
Normal file
51
docs/cli/service.md
Normal file
@@ -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:
|
||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||
- 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)).
|
||||
|
||||
@@ -5,13 +5,19 @@ read_when:
|
||||
---
|
||||
# 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
|
||||
- Gateway RPC: `agent` and `agent.wait`.
|
||||
- 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.
|
||||
2) `agentCommand` runs the agent:
|
||||
- resolves model + thinking/verbose defaults
|
||||
@@ -19,8 +25,9 @@ Short, exact flow of one agent run.
|
||||
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
|
||||
- emits **lifecycle end/error** if the embedded loop does not emit one
|
||||
3) `runEmbeddedPiAgent`:
|
||||
- builds `AgentSession` and subscribes to pi events
|
||||
- streams assistant deltas + tool events
|
||||
- serializes runs via per-session + global queues
|
||||
- resolves model + auth profile and builds the pi session
|
||||
- subscribes to pi events and streams assistant/tool deltas
|
||||
- enforces timeout -> aborts run if exceeded
|
||||
- returns payloads + usage metadata
|
||||
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`
|
||||
- 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)
|
||||
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
|
||||
- `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.
|
||||
- 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`
|
||||
- One-time first-run ritual.
|
||||
- Only created for a brand-new workspace.
|
||||
|
||||
@@ -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
|
||||
`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:
|
||||
|
||||
```json5
|
||||
@@ -99,8 +118,8 @@ agents: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
remote: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
apiKey: "YOUR_GEMINI_API_KEY",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
apiKey: "YOUR_PROXY_KEY",
|
||||
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
|
||||
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”
|
||||
|
||||
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.
|
||||
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
|
||||
- **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.
|
||||
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
|
||||
- **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.
|
||||
|
||||
## 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:
|
||||
`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`.
|
||||
- Default identity: **C3‑PO** (protocol droid).
|
||||
- Skips channel providers in dev mode (`CLAWDBOT_SKIP_CHANNELS=1`).
|
||||
|
||||
Reset flow (fresh start):
|
||||
|
||||
|
||||
@@ -657,6 +657,10 @@
|
||||
"source": "/templates/AGENTS",
|
||||
"destination": "/reference/templates/AGENTS"
|
||||
},
|
||||
{
|
||||
"source": "/templates/BOOT",
|
||||
"destination": "/reference/templates/BOOT"
|
||||
},
|
||||
{
|
||||
"source": "/templates/BOOTSTRAP",
|
||||
"destination": "/reference/templates/BOOTSTRAP"
|
||||
@@ -822,8 +826,10 @@
|
||||
"cli/models",
|
||||
"cli/logs",
|
||||
"cli/nodes",
|
||||
"cli/approvals",
|
||||
"cli/gateway",
|
||||
"cli/daemon",
|
||||
"cli/service",
|
||||
"cli/tui",
|
||||
"cli/voicecall",
|
||||
"cli/wake",
|
||||
@@ -1051,6 +1057,7 @@
|
||||
"reference/RELEASING",
|
||||
"reference/AGENTS.default",
|
||||
"reference/templates/AGENTS",
|
||||
"reference/templates/BOOT",
|
||||
"reference/templates/BOOTSTRAP",
|
||||
"reference/templates/HEARTBEAT",
|
||||
"reference/templates/IDENTITY",
|
||||
|
||||
@@ -46,8 +46,8 @@ When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
|
||||
## Frames
|
||||
|
||||
Client → Gateway:
|
||||
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake)
|
||||
- `event`: node signals (voice transcript, agent request, chat subscribe)
|
||||
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake, skills.bins)
|
||||
- `event`: node signals (voice transcript, agent request, chat subscribe, exec lifecycle)
|
||||
|
||||
Gateway → Client:
|
||||
- `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`.
|
||||
|
||||
## 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
|
||||
|
||||
- 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
|
||||
},
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-004",
|
||||
provider: "gemini",
|
||||
model: "gemini-embedding-001",
|
||||
remote: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
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:
|
||||
|
||||
- Requires node pairing (approval + token).
|
||||
- Controlled on the Mac via **Settings → "Node Run Commands"**: "Always Ask" (default), "Always Allow", or "Never".
|
||||
- If you don’t want remote execution, set the policy to "Never" and remove node pairing for that Mac.
|
||||
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
|
||||
- If you don’t want remote execution, set security to **deny** and remove node pairing for that Mac.
|
||||
|
||||
## Dynamic skills (watcher / remote nodes)
|
||||
|
||||
|
||||
@@ -37,10 +37,11 @@ The hooks system allows you to:
|
||||
|
||||
### 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`
|
||||
- **📝 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
|
||||
|
||||
List available hooks:
|
||||
@@ -195,7 +196,7 @@ Each event includes:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'command' | 'session' | 'agent',
|
||||
type: 'command' | 'session' | 'agent' | 'gateway',
|
||||
action: string, // e.g., 'new', 'reset', 'stop'
|
||||
sessionKey: string, // Session identifier
|
||||
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`)
|
||||
|
||||
### Gateway Events
|
||||
|
||||
Triggered when the gateway starts:
|
||||
|
||||
- **`gateway:startup`**: After channels start and hooks are loaded
|
||||
|
||||
### Future Events
|
||||
|
||||
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
|
||||
|
||||
### Keep Handlers Fast
|
||||
@@ -614,6 +641,7 @@ The gateway logs hook loading at startup:
|
||||
```
|
||||
Registered hook: session-memory -> command:new
|
||||
Registered hook: command-logger -> command
|
||||
Registered hook: boot-md -> gateway:startup
|
||||
```
|
||||
|
||||
### Check Discovery
|
||||
|
||||
@@ -147,9 +147,10 @@ Notes:
|
||||
- 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`.
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -163,12 +164,58 @@ Notes:
|
||||
- `system.notify` respects notification permission state on the macOS app.
|
||||
- `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.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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
**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)
|
||||
If you set `capabilities`, the entry only runs for those media types. For shared
|
||||
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.
|
||||
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
|
||||
|
||||
## 1. Initialize Submodules
|
||||
|
||||
Clawdbot depends on several submodules (like `Peekaboo`). You must initialize these recursively:
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
## 2. Install Dependencies
|
||||
## 1. Install Dependencies
|
||||
|
||||
Install the project-wide dependencies:
|
||||
|
||||
@@ -30,7 +22,7 @@ Install the project-wide dependencies:
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 4. Install the CLI
|
||||
## 3. Install the CLI
|
||||
|
||||
The macOS app expects a global `clawdbot` CLI install to manage background tasks.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
summary: "PeekabooBridge integration for macOS UI automation"
|
||||
read_when:
|
||||
- Hosting PeekabooBridge in Clawdbot.app
|
||||
- Integrating Peekaboo as a submodule
|
||||
- Integrating Peekaboo via Swift Package Manager
|
||||
- Changing PeekabooBridge protocol/paths
|
||||
---
|
||||
# Peekaboo Bridge (macOS UI automation)
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Node run policy + allowlist
|
||||
## Exec approvals (system.run)
|
||||
|
||||
`system.run` is controlled by the macOS app **Node Run Commands** policy:
|
||||
|
||||
- `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:
|
||||
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
|
||||
Security + ask + allowlist are stored locally on the Mac in:
|
||||
|
||||
```
|
||||
~/.clawdbot/macos-node.json
|
||||
~/.clawdbot/exec-approvals.json
|
||||
```
|
||||
|
||||
Schema:
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"systemRun": {
|
||||
"policy": "ask",
|
||||
"allowlist": [
|
||||
"[\"/bin/echo\",\"hello\"]"
|
||||
]
|
||||
"version": 1,
|
||||
"defaults": {
|
||||
"security": "deny",
|
||||
"ask": "on-miss"
|
||||
},
|
||||
"agents": {
|
||||
"main": {
|
||||
"security": "allowlist",
|
||||
"ask": "on-miss",
|
||||
"allowlist": [
|
||||
{ "pattern": "/opt/homebrew/bin/rg" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -29,6 +29,7 @@ read_when:
|
||||
- **Runner:** headless system service; UI app hosts a Unix socket for approvals.
|
||||
- **Node identity:** use existing `nodeId`.
|
||||
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
||||
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
|
||||
|
||||
## Key concepts
|
||||
### Host
|
||||
@@ -168,9 +169,9 @@ If UI missing:
|
||||
- Stored in the gateway in-memory queue (`enqueueSystemEvent`).
|
||||
|
||||
### Event text
|
||||
- `Exec started (host=node, node=<id>, id=<runId>)`
|
||||
- `Exec finished (exit=<code>, tail=<...>)`
|
||||
- `Exec denied (policy=<...>, reason=<...>)`
|
||||
- `Exec started (node=<id>, id=<runId>)`
|
||||
- `Exec finished (node=<id>, id=<runId>, code=<code>)` + optional output tail
|
||||
- `Exec denied (node=<id>, id=<runId>, <reason>)`
|
||||
|
||||
### Transport
|
||||
Option A (recommended):
|
||||
|
||||
116
docs/reference/api-usage-costs.md
Normal file
116
docs/reference/api-usage-costs.md
Normal file
@@ -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).
|
||||
9
docs/reference/templates/BOOT.md
Normal file
9
docs/reference/templates/BOOT.md
Normal file
@@ -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.
|
||||
@@ -60,6 +60,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [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)
|
||||
- [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)
|
||||
- [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)
|
||||
@@ -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.
|
||||
|
||||
**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).**
|
||||
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.
|
||||
|
||||
### 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?
|
||||
|
||||
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 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 are allowed only when policy + allowlist + (optional) user approval all agree.
|
||||
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:
|
||||
- **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
|
||||
|
||||
Approvals live in a local JSON file:
|
||||
Approvals live in a local JSON file on the execution host:
|
||||
|
||||
`~/.clawdbot/exec-approvals.json`
|
||||
|
||||
@@ -97,8 +97,22 @@ Each allowlist entry tracks:
|
||||
## Auto-allow skill CLIs
|
||||
|
||||
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
|
||||
manual allowlists.
|
||||
are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
|
||||
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
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Notes:
|
||||
- `host` defaults to `sandbox`.
|
||||
- `elevated` is ignored when sandboxing is off (exec already runs on the host).
|
||||
- `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.
|
||||
|
||||
## Config
|
||||
@@ -41,6 +41,15 @@ Notes:
|
||||
- `tools.exec.ask` (default: `on-miss`)
|
||||
- `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`)
|
||||
|
||||
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 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.
|
||||
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`.
|
||||
- `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).
|
||||
- `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).
|
||||
|
||||
### `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)
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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.*`)
|
||||
- Skills: status, enable/disable, install, API key updates (`skills.*`)
|
||||
- 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: apply + restart with validation (`config.apply`) and wake the last active session
|
||||
- Config writes include a base-hash guard to prevent clobbering concurrent edits
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
@@ -836,7 +836,7 @@ async function processMessage(
|
||||
const fromLabel = message.isGroup
|
||||
? `group:${peerId}`
|
||||
: message.senderName || `user:${message.senderId}`;
|
||||
const body = formatAgentEnvelope({
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "BlueBubbles",
|
||||
from: fromLabel,
|
||||
timestamp: message.timestamp,
|
||||
@@ -1058,7 +1058,7 @@ async function processReaction(
|
||||
const senderLabel = reaction.senderName || reaction.senderId;
|
||||
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
||||
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`;
|
||||
enqueueSystemEvent(text, {
|
||||
core.system.enqueueSystemEvent(text, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
||||
});
|
||||
|
||||
16
extensions/discord/index.ts
Normal file
16
extensions/discord/index.ts
Normal file
@@ -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;
|
||||
9
extensions/discord/package.json
Normal file
9
extensions/discord/package.json
Normal file
@@ -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 {
|
||||
applyAccountNameToChannelSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "./setup-helpers.js";
|
||||
import { collectDiscordStatusIssues } from "./status-issues/discord.js";
|
||||
import type { ChannelPlugin } from "./types.js";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
collectDiscordAuditChannelIds,
|
||||
collectDiscordStatusIssues,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
discordOnboardingAdapter,
|
||||
DiscordConfigSchema,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
listDiscordAccountIds,
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
} from "./directory-config.js";
|
||||
import {
|
||||
listDiscordDirectoryGroupsLive,
|
||||
listDiscordDirectoryPeersLive,
|
||||
} from "../../discord/directory-live.js";
|
||||
looksLikeDiscordTargetId,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
normalizeDiscordMessagingTarget,
|
||||
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 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> = {
|
||||
id: "discord",
|
||||
meta: {
|
||||
@@ -54,7 +48,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
idLabel: "discordUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""),
|
||||
notifyApproval: async ({ id }) => {
|
||||
await sendMessageDiscord(`user:${id}`, PAIRING_APPROVED_MESSAGE);
|
||||
await getDiscordRuntime().channel.discord.sendMessageDiscord(
|
||||
`user:${id}`,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
@@ -165,8 +162,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
self: async () => null,
|
||||
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
|
||||
listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),
|
||||
listPeersLive: async (params) => listDiscordDirectoryPeersLive(params),
|
||||
listGroupsLive: async (params) => listDiscordDirectoryGroupsLive(params),
|
||||
listPeersLive: async (params) =>
|
||||
getDiscordRuntime().channel.discord.listDirectoryPeersLive(params),
|
||||
listGroupsLive: async (params) =>
|
||||
getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params),
|
||||
},
|
||||
resolver: {
|
||||
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
|
||||
@@ -180,7 +179,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
}));
|
||||
}
|
||||
if (kind === "group") {
|
||||
const resolved = await resolveDiscordChannelAllowlist({ token, entries: inputs });
|
||||
const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({
|
||||
token,
|
||||
entries: inputs,
|
||||
});
|
||||
return resolved.map((entry) => ({
|
||||
input: entry.input,
|
||||
resolved: entry.resolved,
|
||||
@@ -192,7 +194,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
note: entry.note,
|
||||
}));
|
||||
}
|
||||
const resolved = await resolveDiscordUserAllowlist({ token, entries: inputs });
|
||||
const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({
|
||||
token,
|
||||
entries: inputs,
|
||||
});
|
||||
return resolved.map((entry) => ({
|
||||
input: entry.input,
|
||||
resolved: entry.resolved,
|
||||
@@ -274,7 +279,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
textChunkLimit: 2000,
|
||||
pollMaxOptions: 10,
|
||||
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, {
|
||||
verbose: false,
|
||||
replyTo: replyToId ?? undefined,
|
||||
@@ -283,7 +289,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
return { channel: "discord", ...result };
|
||||
},
|
||||
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, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
@@ -293,7 +300,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
return { channel: "discord", ...result };
|
||||
},
|
||||
sendPoll: async ({ to, poll, accountId }) =>
|
||||
await sendPollDiscord(to, poll, {
|
||||
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
},
|
||||
@@ -317,7 +324,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
probeDiscord(account.token, timeoutMs, { includeApplication: true }),
|
||||
getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
|
||||
includeApplication: true,
|
||||
}),
|
||||
auditAccount: async ({ account, timeoutMs, cfg }) => {
|
||||
const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
|
||||
cfg,
|
||||
@@ -334,7 +343,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
const audit = await auditDiscordChannelPermissions({
|
||||
const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({
|
||||
token: botToken,
|
||||
accountId: account.accountId,
|
||||
channelIds,
|
||||
@@ -371,7 +380,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
const token = account.token.trim();
|
||||
let discordBotLabel = "";
|
||||
try {
|
||||
const probe = await probeDiscord(token, 2500, {
|
||||
const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, {
|
||||
includeApplication: true,
|
||||
});
|
||||
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
||||
@@ -392,14 +401,12 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (shouldLogVerbose()) {
|
||||
if (getDiscordRuntime().logging.shouldLogVerbose()) {
|
||||
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorDiscordProvider } = await import("../../discord/index.js");
|
||||
return monitorDiscordProvider({
|
||||
return getDiscordRuntime().channel.discord.monitorDiscordProvider({
|
||||
token,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
14
extensions/discord/src/runtime.ts
Normal file
14
extensions/discord/src/runtime.ts
Normal file
@@ -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;
|
||||
}
|
||||
16
extensions/imessage/index.ts
Normal file
16
extensions/imessage/index.ts
Normal file
@@ -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;
|
||||
9
extensions/imessage/package.json
Normal file
9
extensions/imessage/package.json
Normal file
@@ -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 {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
imessageOnboardingAdapter,
|
||||
IMessageConfigSchema,
|
||||
listIMessageAccountIds,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "./setup-helpers.js";
|
||||
import type { ChannelPlugin } from "./types.js";
|
||||
normalizeAccountId,
|
||||
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");
|
||||
|
||||
@@ -32,13 +28,17 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
id: "imessage",
|
||||
meta: {
|
||||
...meta,
|
||||
aliases: ["imsg"],
|
||||
showConfigured: false,
|
||||
},
|
||||
onboarding: imessageOnboardingAdapter,
|
||||
pairing: {
|
||||
idLabel: "imessageSenderId",
|
||||
notifyApproval: async ({ id }) => {
|
||||
await sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE);
|
||||
await getIMessageRuntime().channel.imessage.sendMessageIMessage(
|
||||
id,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
@@ -183,10 +183,10 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkText,
|
||||
chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||
const send = deps?.sendIMessage ?? sendMessageIMessage;
|
||||
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
|
||||
const maxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||
@@ -201,7 +201,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
return { channel: "imessage", ...result };
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
|
||||
const send = deps?.sendIMessage ?? sendMessageIMessage;
|
||||
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
|
||||
const maxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||
@@ -251,7 +251,8 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ timeoutMs }) => probeIMessage(timeoutMs),
|
||||
probeAccount: async ({ timeoutMs }) =>
|
||||
getIMessageRuntime().channel.imessage.probeIMessage(timeoutMs),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
@@ -282,9 +283,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
ctx.log?.info(
|
||||
`[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`,
|
||||
);
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorIMessageProvider } = await import("../../imessage/index.js");
|
||||
return monitorIMessageProvider({
|
||||
return getIMessageRuntime().channel.imessage.monitorIMessageProvider({
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
14
extensions/imessage/src/runtime.ts
Normal file
14
extensions/imessage/src/runtime.ts
Normal file
@@ -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 { matrixPlugin } from "./channel.js";
|
||||
import { setMatrixRuntime } from "./runtime.js";
|
||||
|
||||
describe("matrix directory", () => {
|
||||
beforeEach(() => {
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: () => os.tmpdir(),
|
||||
},
|
||||
} as PluginRuntime);
|
||||
});
|
||||
|
||||
it("lists peers and groups from config", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
RoomTopicEventContent,
|
||||
} 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 { getActiveMatrixClient } from "./active-client.js";
|
||||
import {
|
||||
@@ -74,12 +74,14 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<M
|
||||
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
|
||||
if (shouldShareClient) {
|
||||
const client = await resolveSharedMatrixClient({
|
||||
cfg: loadConfig() as CoreConfig,
|
||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
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({
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
||||
|
||||
import { loadConfig } from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
|
||||
export type MatrixResolvedConfig = {
|
||||
homeserver: string;
|
||||
@@ -46,7 +46,7 @@ function clean(value?: string): string {
|
||||
}
|
||||
|
||||
export function resolveMatrixConfig(
|
||||
cfg: CoreConfig = loadConfig() as CoreConfig,
|
||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
const matrix = cfg.channels?.matrix ?? {};
|
||||
@@ -75,7 +75,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): 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 resolved = resolveMatrixConfig(cfg, env);
|
||||
if (!resolved.homeserver) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveStateDir } from "clawdbot/plugin-sdk";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
@@ -16,9 +16,11 @@ const CREDENTIALS_FILENAME = "credentials.json";
|
||||
|
||||
export function resolveMatrixCredentialsDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
stateDir: string = resolveStateDir(env, os.homedir),
|
||||
stateDir?: 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 {
|
||||
|
||||
@@ -3,7 +3,8 @@ import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
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";
|
||||
|
||||
@@ -40,7 +41,7 @@ export async function ensureMatrixSdkInstalled(params: {
|
||||
? ["pnpm", "install"]
|
||||
: ["npm", "install", "--omit=dev", "--silent"];
|
||||
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
|
||||
const result = await runCommandWithTimeout(command, {
|
||||
const result = await getMatrixRuntime().system.runCommandWithTimeout(command, {
|
||||
cwd: root,
|
||||
timeoutMs: 300_000,
|
||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { MatrixClient, MatrixEvent, RoomMember } 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 { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
export function registerMatrixAutoJoin(params: {
|
||||
client: MatrixClient;
|
||||
@@ -10,6 +11,11 @@ export function registerMatrixAutoJoin(params: {
|
||||
runtime: RuntimeEnv;
|
||||
}) {
|
||||
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 autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
|
||||
|
||||
@@ -36,7 +42,7 @@ export function registerMatrixAutoJoin(params: {
|
||||
await client.joinRoom(roomId);
|
||||
logVerbose(`matrix: joined room ${roomId}`);
|
||||
} 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 {
|
||||
buildMentionRegexes,
|
||||
chunkMarkdownText,
|
||||
createReplyDispatcherWithTyping,
|
||||
danger,
|
||||
dispatchReplyFromConfig,
|
||||
enqueueSystemEvent,
|
||||
finalizeInboundContext,
|
||||
formatAgentEnvelope,
|
||||
formatAllowlistMatchMeta,
|
||||
getChildLogger,
|
||||
hasControlCommand,
|
||||
loadConfig,
|
||||
logVerbose,
|
||||
mergeAllowlist,
|
||||
matchesMentionPatterns,
|
||||
readChannelAllowFromStore,
|
||||
recordSessionMetaFromInbound,
|
||||
resolveAgentRoute,
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
resolveStorePath,
|
||||
resolveTextChunkLimit,
|
||||
shouldHandleTextCommands,
|
||||
shouldLogVerbose,
|
||||
summarizeMapping,
|
||||
updateLastRoute,
|
||||
upsertChannelPairingRequest,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
@@ -61,6 +36,7 @@ import { deliverMatrixReplies } from "./replies.js";
|
||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
||||
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
||||
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
export type MonitorMatrixOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
@@ -76,7 +52,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
if (isBunRuntime()) {
|
||||
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;
|
||||
|
||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||
@@ -207,8 +184,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
setActiveMatrixClient(client);
|
||||
|
||||
const mentionRegexes = buildMentionRegexes(cfg);
|
||||
const logger = getChildLogger({ module: "matrix-auto-reply" });
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
|
||||
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 defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
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 dmPolicyRaw = dmConfig?.policy ?? "pairing";
|
||||
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
|
||||
const allowFrom = dmConfig?.allowFrom ?? [];
|
||||
const textLimit = resolveTextChunkLimit(cfg, "matrix");
|
||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||
const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
||||
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
||||
const startupMs = Date.now();
|
||||
@@ -306,22 +287,22 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
}`;
|
||||
|
||||
if (roomConfigInfo.config && !roomConfigInfo.allowed) {
|
||||
logVerbose(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
||||
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (!roomConfigInfo.allowlistConfigured) {
|
||||
logVerbose(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
||||
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
||||
return;
|
||||
}
|
||||
if (!roomConfigInfo.config) {
|
||||
logVerbose(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
||||
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
if (isDirectMessage) {
|
||||
@@ -335,13 +316,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
||||
if (!allowMatch.allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await upsertChannelPairingRequest({
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "matrix",
|
||||
id: senderId,
|
||||
meta: { name: senderName },
|
||||
});
|
||||
if (created) {
|
||||
logVerbose(
|
||||
logVerboseMessage(
|
||||
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
|
||||
);
|
||||
try {
|
||||
@@ -358,12 +339,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
{ client },
|
||||
);
|
||||
} catch (err) {
|
||||
logVerbose(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dmPolicy !== "pairing") {
|
||||
logVerbose(
|
||||
logVerboseMessage(
|
||||
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
||||
);
|
||||
}
|
||||
@@ -379,7 +360,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
userName: senderName,
|
||||
});
|
||||
if (!userMatch.allowed) {
|
||||
logVerbose(
|
||||
logVerboseMessage(
|
||||
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
||||
userMatch,
|
||||
)})`,
|
||||
@@ -388,7 +369,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
}
|
||||
}
|
||||
if (isRoom) {
|
||||
logVerbose(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
||||
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
||||
}
|
||||
|
||||
const rawBody = content.body.trim();
|
||||
@@ -416,7 +397,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
maxBytes: mediaMaxBytes,
|
||||
});
|
||||
} 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,
|
||||
mentionRegexes,
|
||||
});
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "matrix",
|
||||
});
|
||||
@@ -439,14 +420,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
userId: senderId,
|
||||
userName: senderName,
|
||||
});
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
],
|
||||
});
|
||||
if (isRoom && allowTextCommands && hasControlCommand(bodyText, cfg) && !commandAuthorized) {
|
||||
logVerbose(`matrix: drop control command from unauthorized sender ${senderId}`);
|
||||
if (
|
||||
isRoom &&
|
||||
allowTextCommands &&
|
||||
core.channel.text.hasControlCommand(bodyText, cfg) &&
|
||||
!commandAuthorized
|
||||
) {
|
||||
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
|
||||
return;
|
||||
}
|
||||
const shouldRequireMention = isRoom
|
||||
@@ -465,7 +451,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
!wasMentioned &&
|
||||
!hasExplicitMention &&
|
||||
commandAuthorized &&
|
||||
hasControlCommand(bodyText);
|
||||
core.channel.text.hasControlCommand(bodyText);
|
||||
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
|
||||
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
|
||||
return;
|
||||
@@ -482,14 +468,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
|
||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||
const body = formatAgentEnvelope({
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Matrix",
|
||||
from: envelopeFrom,
|
||||
timestamp: event.getTs() ?? undefined,
|
||||
body: textWithId,
|
||||
});
|
||||
|
||||
const route = resolveAgentRoute({
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
peer: {
|
||||
@@ -499,7 +485,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
|
||||
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: bodyText,
|
||||
CommandBody: bodyText,
|
||||
@@ -531,10 +517,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
OriginatingTo: `room:${roomId}`,
|
||||
});
|
||||
|
||||
const storePath = resolveStorePath(cfg.session?.store, {
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void recordSessionMetaFromInbound({
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
@@ -546,7 +532,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
|
||||
if (isDirectMessage) {
|
||||
await updateLastRoute({
|
||||
await core.channel.session.updateLastRoute({
|
||||
storePath,
|
||||
sessionKey: route.mainSessionKey,
|
||||
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");
|
||||
logVerbose(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
||||
}
|
||||
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
||||
|
||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
@@ -577,20 +561,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
};
|
||||
if (shouldAckReaction() && messageId) {
|
||||
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;
|
||||
if (!replyTarget) {
|
||||
runtime.error?.(danger("matrix: missing reply target"));
|
||||
runtime.error?.("matrix: missing reply target");
|
||||
return;
|
||||
}
|
||||
|
||||
let didSendReply = false;
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload) => {
|
||||
await deliverMatrixReplies({
|
||||
replies: [payload],
|
||||
@@ -604,13 +588,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
didSendReply = true;
|
||||
},
|
||||
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(() => {}),
|
||||
onIdle: () => sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
|
||||
});
|
||||
|
||||
const { queuedFinal, counts } = await dispatchReplyFromConfig({
|
||||
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
@@ -622,19 +606,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
markDispatchIdle();
|
||||
if (!queuedFinal) return;
|
||||
didSendReply = true;
|
||||
if (shouldLogVerbose()) {
|
||||
const finalCount = counts.final;
|
||||
logVerbose(`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`);
|
||||
}
|
||||
const finalCount = counts.final;
|
||||
logVerboseMessage(
|
||||
`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||
);
|
||||
if (didSendReply) {
|
||||
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,
|
||||
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
|
||||
});
|
||||
}
|
||||
} 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 { saveMediaBuffer } from "clawdbot/plugin-sdk";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
async function fetchMatrixMediaBuffer(params: {
|
||||
client: MatrixClient;
|
||||
@@ -49,7 +49,12 @@ export async function downloadMatrixMedia(params: {
|
||||
});
|
||||
if (!fetched) return null;
|
||||
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 {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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: {
|
||||
content: RoomMessageEventContent;
|
||||
@@ -17,6 +17,9 @@ export function resolveMentions(params: {
|
||||
const wasMentioned =
|
||||
Boolean(mentions?.room) ||
|
||||
(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) };
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import {
|
||||
chunkMarkdownText,
|
||||
danger,
|
||||
logVerbose,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import { sendMessageMatrix } from "../send.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
export async function deliverMatrixReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
@@ -18,6 +13,12 @@ export async function deliverMatrixReplies(params: {
|
||||
replyToMode: "off" | "first" | "all";
|
||||
threadId?: string;
|
||||
}): Promise<void> {
|
||||
const core = getMatrixRuntime();
|
||||
const logVerbose = (message: string) => {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
params.runtime.log?.(message);
|
||||
}
|
||||
};
|
||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||
let hasReplied = false;
|
||||
for (const reply of params.replies) {
|
||||
@@ -27,7 +28,7 @@ export async function deliverMatrixReplies(params: {
|
||||
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
|
||||
continue;
|
||||
}
|
||||
params.runtime.error?.(danger("matrix reply missing text/media"));
|
||||
params.runtime.error?.("matrix reply missing text/media");
|
||||
continue;
|
||||
}
|
||||
const replyToIdRaw = reply.replyToId?.trim();
|
||||
@@ -42,7 +43,7 @@ export async function deliverMatrixReplies(params: {
|
||||
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
||||
|
||||
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();
|
||||
if (!trimmed) continue;
|
||||
await sendMessageMatrix(params.roomId, trimmed, {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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", () => ({
|
||||
EventType: {
|
||||
Direct: "m.direct",
|
||||
@@ -18,21 +21,33 @@ vi.mock("matrix-js-sdk", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("clawdbot/plugin-sdk", () => ({
|
||||
loadConfig: () => ({}),
|
||||
resolveTextChunkLimit: () => 4000,
|
||||
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
||||
loadWebMedia: vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("media"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
}),
|
||||
mediaKindFromMime: () => "image",
|
||||
isVoiceCompatibleAudio: () => false,
|
||||
getImageMetadata: vi.fn().mockResolvedValue(null),
|
||||
resizeToJpeg: vi.fn(),
|
||||
}));
|
||||
const loadWebMediaMock = vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("media"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
});
|
||||
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
|
||||
const resizeToJpegMock = vi.fn();
|
||||
|
||||
const runtimeStub = {
|
||||
config: {
|
||||
loadConfig: () => ({}),
|
||||
},
|
||||
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;
|
||||
|
||||
@@ -50,11 +65,13 @@ const makeClient = () => {
|
||||
|
||||
describe("sendMessageMatrix media", () => {
|
||||
beforeAll(async () => {
|
||||
setMatrixRuntime(runtimeStub);
|
||||
({ sendMessageMatrix } = await import("./send.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setMatrixRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("uploads media with url payloads", async () => {
|
||||
|
||||
@@ -5,17 +5,8 @@ import type {
|
||||
ReactionEventContent,
|
||||
} from "matrix-js-sdk/lib/@types/events.js";
|
||||
|
||||
import {
|
||||
chunkMarkdownText,
|
||||
getImageMetadata,
|
||||
isVoiceCompatibleAudio,
|
||||
loadConfig,
|
||||
loadWebMedia,
|
||||
mediaKindFromMime,
|
||||
type PollInput,
|
||||
resolveTextChunkLimit,
|
||||
resizeToJpeg,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { PollInput } from "clawdbot/plugin-sdk";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import { getActiveMatrixClient } from "./active-client.js";
|
||||
import {
|
||||
createMatrixClient,
|
||||
@@ -29,6 +20,7 @@ import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
|
||||
const MATRIX_TEXT_LIMIT = 4000;
|
||||
const getCore = () => getMatrixRuntime();
|
||||
|
||||
type MatrixDirectAccountData = AccountDataEvents[EventType.Direct];
|
||||
|
||||
@@ -65,7 +57,7 @@ function ensureNodeRuntime() {
|
||||
}
|
||||
|
||||
function resolveMediaMaxBytes(): number | undefined {
|
||||
const cfg = loadConfig() as CoreConfig;
|
||||
const cfg = getCore().config.loadConfig() as CoreConfig;
|
||||
if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
|
||||
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
|
||||
}
|
||||
@@ -224,7 +216,7 @@ function resolveMatrixMsgType(
|
||||
contentType?: string,
|
||||
fileName?: string,
|
||||
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
|
||||
const kind = mediaKindFromMime(contentType ?? "");
|
||||
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return MsgType.Image;
|
||||
@@ -243,7 +235,7 @@ function resolveMatrixVoiceDecision(opts: {
|
||||
fileName?: string;
|
||||
}): { useVoice: boolean } {
|
||||
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: false };
|
||||
@@ -256,19 +248,19 @@ async function prepareImageInfo(params: {
|
||||
buffer: Buffer;
|
||||
client: MatrixClient;
|
||||
}): 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;
|
||||
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
|
||||
const maxDim = Math.max(meta.width, meta.height);
|
||||
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
||||
try {
|
||||
const thumbBuffer = await resizeToJpeg({
|
||||
const thumbBuffer = await getCore().media.resizeToJpeg({
|
||||
buffer: params.buffer,
|
||||
maxSide: THUMBNAIL_MAX_SIDE,
|
||||
quality: THUMBNAIL_QUALITY,
|
||||
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, {
|
||||
type: "image/jpeg",
|
||||
name: "thumbnail.jpg",
|
||||
@@ -352,10 +344,10 @@ export async function sendMessageMatrix(
|
||||
});
|
||||
try {
|
||||
const roomId = await resolveMatrixRoomId(client, to);
|
||||
const cfg = loadConfig();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "matrix");
|
||||
const cfg = getCore().config.loadConfig();
|
||||
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||
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 relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
|
||||
const sendContent = (content: RoomMessageEventContent) =>
|
||||
@@ -364,7 +356,7 @@ export async function sendMessageMatrix(
|
||||
let lastMessageId = "";
|
||||
if (opts.mediaUrl) {
|
||||
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, {
|
||||
contentType: media.contentType,
|
||||
filename: media.fileName,
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
createMemoryGetTool,
|
||||
createMemorySearchTool,
|
||||
registerMemoryCli,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
const memoryCorePlugin = {
|
||||
id: "memory-core",
|
||||
name: "Memory (Core)",
|
||||
@@ -14,11 +8,11 @@ const memoryCorePlugin = {
|
||||
register(api: ClawdbotPluginApi) {
|
||||
api.registerTool(
|
||||
(ctx) => {
|
||||
const memorySearchTool = createMemorySearchTool({
|
||||
const memorySearchTool = api.runtime.tools.createMemorySearchTool({
|
||||
config: ctx.config,
|
||||
agentSessionKey: ctx.sessionKey,
|
||||
});
|
||||
const memoryGetTool = createMemoryGetTool({
|
||||
const memoryGetTool = api.runtime.tools.createMemoryGetTool({
|
||||
config: ctx.config,
|
||||
agentSessionKey: ctx.sessionKey,
|
||||
});
|
||||
@@ -30,7 +24,7 @@ const memoryCorePlugin = {
|
||||
|
||||
api.registerCli(
|
||||
({ program }) => {
|
||||
registerMemoryCli(program);
|
||||
api.runtime.tools.registerMemoryCli(program);
|
||||
},
|
||||
{ commands: ["memory"] },
|
||||
);
|
||||
|
||||
102
extensions/memory/config.ts
Normal file
102
extensions/memory/config.ts
Normal file
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
282
extensions/memory/index.test.ts
Normal file
282
extensions/memory/index.test.ts
Normal file
@@ -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");
|
||||
expect(memoryPlugin.name).toBe("Memory (Vector)");
|
||||
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",
|
||||
name: "Memory (Vector)",
|
||||
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
|
||||
});
|
||||
588
extensions/memory/index.ts
Normal file
588
extensions/memory/index.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
/**
|
||||
* Clawdbot Memory 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",
|
||||
name: "Memory (Vector)",
|
||||
description: "Long-term memory with vector search and seamless 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: 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("Long-term 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: 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: 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: auto-captured ${stored} memories`);
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`memory: capture failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Service
|
||||
// ========================================================================
|
||||
|
||||
api.registerService({
|
||||
id: "memory",
|
||||
start: () => {
|
||||
api.logger.info(
|
||||
`memory: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
|
||||
);
|
||||
},
|
||||
stop: () => {
|
||||
api.logger.info("memory: stopped");
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default memoryPlugin;
|
||||
16
extensions/memory/package.json
Normal file
16
extensions/memory/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@clawdbot/memory",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot long-term memory plugin with vector search and seamless 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 { msteamsPlugin } from "./src/channel.js";
|
||||
import { setMSTeamsRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "msteams",
|
||||
name: "Microsoft Teams",
|
||||
description: "Microsoft Teams channel plugin (Bot Framework)",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setMSTeamsRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: msteamsPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/agents-hosting": "^1.2.2",
|
||||
"@microsoft/agents-hosting-express": "^1.2.2",
|
||||
"@microsoft/agents-hosting-extensions-teams": "^1.2.2",
|
||||
"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",
|
||||
"proper-lockfile": "^4.1.2"
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
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 saveMediaBufferMock = vi.fn(async () => ({
|
||||
path: "/tmp/saved.png",
|
||||
contentType: "image/png",
|
||||
}));
|
||||
|
||||
vi.mock("clawdbot/plugin-sdk", () => ({
|
||||
detectMime: (...args: unknown[]) => detectMimeMock(...args),
|
||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
|
||||
}));
|
||||
const runtimeStub = {
|
||||
media: {
|
||||
detectMime: (...args: unknown[]) => detectMimeMock(...args),
|
||||
},
|
||||
channel: {
|
||||
media: {
|
||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
describe("msteams attachments", () => {
|
||||
const load = async () => {
|
||||
@@ -19,6 +28,7 @@ describe("msteams attachments", () => {
|
||||
beforeEach(() => {
|
||||
detectMimeMock.mockClear();
|
||||
saveMediaBufferMock.mockClear();
|
||||
setMSTeamsRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
describe("buildMSTeamsAttachmentPlaceholder", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import {
|
||||
extractInlineImageCandidates,
|
||||
inferPlaceholder,
|
||||
@@ -141,7 +141,7 @@ export async function downloadMSTeamsImageAttachments(params: {
|
||||
if (inline.kind !== "data") continue;
|
||||
if (inline.data.byteLength > params.maxBytes) continue;
|
||||
try {
|
||||
const saved = await saveMediaBuffer(
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
inline.data,
|
||||
inline.contentType,
|
||||
"inbound",
|
||||
@@ -167,12 +167,12 @@ export async function downloadMSTeamsImageAttachments(params: {
|
||||
if (!res.ok) continue;
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
if (buffer.byteLength > params.maxBytes) continue;
|
||||
const mime = await detectMime({
|
||||
const mime = await getMSTeamsRuntime().media.detectMime({
|
||||
buffer,
|
||||
headerMime: res.headers.get("content-type"),
|
||||
filePath: candidate.fileHint ?? candidate.url,
|
||||
});
|
||||
const saved = await saveMediaBuffer(
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
buffer,
|
||||
mime ?? candidate.contentTypeHint,
|
||||
"inbound",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { downloadMSTeamsImageAttachments } from "./download.js";
|
||||
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
||||
import type {
|
||||
@@ -154,13 +154,13 @@ async function downloadGraphHostedImages(params: {
|
||||
continue;
|
||||
}
|
||||
if (buffer.byteLength > params.maxBytes) continue;
|
||||
const mime = await detectMime({
|
||||
const mime = await getMSTeamsRuntime().media.detectMime({
|
||||
buffer,
|
||||
headerMime: item.contentType ?? undefined,
|
||||
});
|
||||
if (mime && !mime.startsWith("image/")) continue;
|
||||
try {
|
||||
const saved = await saveMediaBuffer(
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
buffer,
|
||||
mime ?? item.contentType ?? undefined,
|
||||
"inbound",
|
||||
|
||||
@@ -2,12 +2,29 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
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 { 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)", () => {
|
||||
beforeEach(() => {
|
||||
setMSTeamsRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("filters and prunes expired entries (but keeps legacy ones)", async () => {
|
||||
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 MSTeamsAdapter,
|
||||
renderReplyPayloadsToMessages,
|
||||
sendMSTeamsMessages,
|
||||
} 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", () => {
|
||||
beforeEach(() => {
|
||||
setMSTeamsRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
describe("renderReplyPayloadsToMessages", () => {
|
||||
it("filters silent replies", () => {
|
||||
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
chunkMarkdownText,
|
||||
isSilentReplyText,
|
||||
type MSTeamsReplyStyle,
|
||||
type ReplyPayload,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import { classifyMSTeamsSendError } from "./errors.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
type SendContext = {
|
||||
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
||||
@@ -108,7 +108,7 @@ function pushTextMessages(
|
||||
) {
|
||||
if (!text) return;
|
||||
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();
|
||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
||||
out.push(trimmed);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import { danger } from "clawdbot/plugin-sdk";
|
||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
|
||||
@@ -42,7 +41,7 @@ export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
|
||||
try {
|
||||
await handleTeamsMessage(context as MSTeamsTurnContext);
|
||||
} catch (err) {
|
||||
deps.runtime.error?.(danger(`msteams handler failed: ${String(err)}`));
|
||||
deps.runtime.error?.(`msteams handler failed: ${String(err)}`);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntries,
|
||||
createInboundDebouncer,
|
||||
danger,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
readChannelAllowFromStore,
|
||||
recordSessionMetaFromInbound,
|
||||
recordPendingHistoryEntry,
|
||||
resolveAgentRoute,
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
resolveInboundDebounceMs,
|
||||
resolveMentionGating,
|
||||
resolveStorePath,
|
||||
dispatchReplyFromConfig,
|
||||
finalizeInboundContext,
|
||||
formatAgentEnvelope,
|
||||
formatAllowlistMatchMeta,
|
||||
hasControlCommand,
|
||||
logVerbose,
|
||||
shouldLogVerbose,
|
||||
upsertChannelPairingRequest,
|
||||
type HistoryEntry,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
@@ -50,6 +35,7 @@ import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
|
||||
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
|
||||
import type { MSTeamsTurnContext } from "../sdk-types.js";
|
||||
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
|
||||
export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
const {
|
||||
@@ -64,6 +50,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
pollStore,
|
||||
log,
|
||||
} = deps;
|
||||
const core = getMSTeamsRuntime();
|
||||
const logVerboseMessage = (message: string) => {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
log.debug(message);
|
||||
}
|
||||
};
|
||||
const msteamsCfg = cfg.channels?.msteams;
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
@@ -72,7 +64,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
const conversationHistories = new Map<string, HistoryEntry[]>();
|
||||
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "msteams" });
|
||||
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
||||
cfg,
|
||||
channel: "msteams",
|
||||
});
|
||||
|
||||
type MSTeamsDebounceEntry = {
|
||||
context: MSTeamsTurnContext;
|
||||
@@ -126,7 +121,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
const senderName = from.name ?? 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;
|
||||
|
||||
// Check DM policy for direct messages.
|
||||
@@ -151,7 +148,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
if (!allowMatch.allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const request = await upsertChannelPairingRequest({
|
||||
const request = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "msteams",
|
||||
id: senderId,
|
||||
meta: { name: senderName },
|
||||
@@ -254,15 +251,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
senderId,
|
||||
senderName,
|
||||
});
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
||||
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
||||
],
|
||||
});
|
||||
if (hasControlCommand(text, cfg) && !commandAuthorized) {
|
||||
logVerbose(`msteams: drop control command from unauthorized sender ${senderId}`);
|
||||
if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) {
|
||||
logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -329,7 +326,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
: `msteams:group:${conversationId}`;
|
||||
const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`;
|
||||
|
||||
const route = resolveAgentRoute({
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "msteams",
|
||||
peer: {
|
||||
@@ -343,7 +340,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
? `Teams DM from ${senderName}`
|
||||
: `Teams message in ${conversationType} from ${senderName}`;
|
||||
|
||||
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
|
||||
});
|
||||
@@ -409,7 +406,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||
const body = formatAgentEnvelope({
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Teams",
|
||||
from: envelopeFrom,
|
||||
timestamp,
|
||||
@@ -425,7 +422,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
formatAgentEnvelope({
|
||||
core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Teams",
|
||||
from: conversationType,
|
||||
timestamp: entry.timestamp,
|
||||
@@ -434,7 +431,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
});
|
||||
}
|
||||
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
@@ -458,20 +455,18 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
...mediaPayload,
|
||||
});
|
||||
|
||||
const storePath = resolveStorePath(cfg.session?.store, {
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void recordSessionMetaFromInbound({
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
}).catch((err) => {
|
||||
logVerbose(`msteams: failed updating session meta: ${String(err)}`);
|
||||
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
|
||||
});
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
||||
}
|
||||
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
|
||||
cfg,
|
||||
@@ -493,7 +488,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
||||
try {
|
||||
const { queuedFinal, counts } = await dispatchReplyFromConfig({
|
||||
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
@@ -513,18 +508,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (shouldLogVerbose()) {
|
||||
const finalCount = counts.final;
|
||||
logVerbose(
|
||||
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
||||
);
|
||||
}
|
||||
const finalCount = counts.final;
|
||||
logVerboseMessage(
|
||||
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
||||
);
|
||||
if (isRoomish && historyKey && historyLimit > 0) {
|
||||
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("dispatch failed", { error: String(err) });
|
||||
runtime.error?.(danger(`msteams dispatch failed: ${String(err)}`));
|
||||
runtime.error?.(`msteams dispatch failed: ${String(err)}`);
|
||||
try {
|
||||
await context.sendActivity(
|
||||
`⚠️ 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,
|
||||
buildKey: (entry) => {
|
||||
const conversationId = normalizeMSTeamsConversationId(
|
||||
@@ -549,7 +542,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
shouldDebounce: (entry) => {
|
||||
if (!entry.text.trim()) 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) => {
|
||||
const last = entries.at(-1);
|
||||
@@ -579,7 +572,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
});
|
||||
},
|
||||
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 {
|
||||
getChildLogger,
|
||||
mergeAllowlist,
|
||||
resolveTextChunkLimit,
|
||||
summarizeMapping,
|
||||
type ClawdbotConfig,
|
||||
type RuntimeEnv,
|
||||
@@ -19,8 +17,7 @@ import {
|
||||
} from "./resolve-allowlist.js";
|
||||
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
const log = getChildLogger({ name: "msteams" });
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
export type MonitorMSTeamsOpts = {
|
||||
cfg: ClawdbotConfig;
|
||||
@@ -38,6 +35,8 @@ export type MonitorMSTeamsResult = {
|
||||
export async function monitorMSTeamsProvider(
|
||||
opts: MonitorMSTeamsOpts,
|
||||
): Promise<MonitorMSTeamsResult> {
|
||||
const core = getMSTeamsRuntime();
|
||||
const log = core.logging.getChildLogger({ name: "msteams" });
|
||||
let cfg = opts.cfg;
|
||||
let msteamsCfg = cfg.channels?.msteams;
|
||||
if (!msteamsCfg?.enabled) {
|
||||
@@ -197,7 +196,7 @@ export async function monitorMSTeamsProvider(
|
||||
};
|
||||
|
||||
const port = msteamsCfg.webhook?.port ?? 3978;
|
||||
const textLimit = resolveTextChunkLimit(cfg, "msteams");
|
||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "msteams");
|
||||
const MB = 1024 * 1024;
|
||||
const agentDefaults = cfg.agents?.defaults;
|
||||
const mediaMaxBytes =
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { chunkMarkdownText, type ChannelOutboundAdapter } from "clawdbot/plugin-sdk";
|
||||
import type { ChannelOutboundAdapter } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { createMSTeamsPollStoreFs } from "./polls.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
|
||||
|
||||
export const msteamsOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkMarkdownText,
|
||||
chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
textChunkLimit: 4000,
|
||||
pollMaxOptions: 12,
|
||||
sendText: async ({ cfg, to, text, deps }) => {
|
||||
|
||||
@@ -2,11 +2,28 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
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 { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.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 polls", () => {
|
||||
beforeEach(() => {
|
||||
setMSTeamsRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("builds poll cards with fallback text", () => {
|
||||
const card = buildMSTeamsPollCard({
|
||||
question: "Lunch?",
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import {
|
||||
createReplyDispatcherWithTyping,
|
||||
danger,
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
type ClawdbotConfig,
|
||||
type MSTeamsReplyStyle,
|
||||
type RuntimeEnv,
|
||||
import type {
|
||||
ClawdbotConfig,
|
||||
MSTeamsReplyStyle,
|
||||
RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import {
|
||||
@@ -20,6 +16,7 @@ import {
|
||||
} from "./messenger.js";
|
||||
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
export function createMSTeamsReplyDispatcher(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
@@ -34,6 +31,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
textLimit: number;
|
||||
onSentMessageIds?: (ids: string[]) => void;
|
||||
}) {
|
||||
const core = getMSTeamsRuntime();
|
||||
const sendTypingIndicator = async () => {
|
||||
try {
|
||||
await params.context.sendActivities([{ type: "typing" }]);
|
||||
@@ -42,9 +40,12 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
}
|
||||
};
|
||||
|
||||
return createReplyDispatcherWithTyping({
|
||||
responsePrefix: resolveEffectiveMessagesConfig(params.cfg, params.agentId).responsePrefix,
|
||||
humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||
return core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(
|
||||
params.cfg,
|
||||
params.agentId,
|
||||
).responsePrefix,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||
deliver: async (payload) => {
|
||||
const messages = renderReplyPayloadsToMessages([payload], {
|
||||
textChunkLimit: params.textLimit,
|
||||
@@ -74,7 +75,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const hint = formatMSTeamsSendErrorHint(classification);
|
||||
params.runtime.error?.(
|
||||
danger(`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`),
|
||||
`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
|
||||
);
|
||||
params.log.error("reply failed", {
|
||||
kind: info.kind,
|
||||
|
||||
14
extensions/msteams/src/runtime.ts
Normal file
14
extensions/msteams/src/runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setMSTeamsRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getMSTeamsRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("MSTeams runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import type { getChildLogger as getChildLoggerFn } from "clawdbot/plugin-sdk";
|
||||
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import type {
|
||||
MSTeamsConversationStore,
|
||||
StoredConversationReference,
|
||||
@@ -9,8 +8,10 @@ import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
let _log: ReturnType<typeof getChildLoggerFn> | undefined;
|
||||
const getLog = async (): Promise<ReturnType<typeof getChildLoggerFn>> => {
|
||||
type GetChildLogger = PluginRuntime["logging"]["getChildLogger"];
|
||||
|
||||
let _log: ReturnType<GetChildLogger> | undefined;
|
||||
const getLog = async (): Promise<ReturnType<GetChildLogger>> => {
|
||||
if (_log) return _log;
|
||||
const { getChildLogger } = await import("../logging.js");
|
||||
_log = getChildLogger({ name: "msteams:send" });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveStateDir } from "clawdbot/plugin-sdk";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
export type MSTeamsStorePathOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -15,6 +15,8 @@ export function resolveMSTeamsStorePath(params: MSTeamsStorePathOptions): string
|
||||
if (params.stateDir) return path.join(params.stateDir, params.filename);
|
||||
|
||||
const env = params.env ?? process.env;
|
||||
const stateDir = params.homedir ? resolveStateDir(env, params.homedir) : resolveStateDir(env);
|
||||
const stateDir = params.homedir
|
||||
? getMSTeamsRuntime().state.resolveStateDir(env, params.homedir)
|
||||
: getMSTeamsRuntime().state.resolveStateDir(env);
|
||||
return path.join(stateDir, params.filename);
|
||||
}
|
||||
|
||||
16
extensions/signal/index.ts
Normal file
16
extensions/signal/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { signalPlugin } from "./src/channel.js";
|
||||
import { setSignalRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "signal",
|
||||
name: "Signal",
|
||||
description: "Signal channel plugin",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setSignalRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: signalPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
9
extensions/signal/package.json
Normal file
9
extensions/signal/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@clawdbot/signal",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Signal channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,28 @@
|
||||
import { chunkText } from "../../auto-reply/chunk.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import {
|
||||
listSignalAccountIds,
|
||||
type ResolvedSignalAccount,
|
||||
resolveDefaultSignalAccountId,
|
||||
resolveSignalAccount,
|
||||
} from "../../signal/accounts.js";
|
||||
import { probeSignal } from "../../signal/probe.js";
|
||||
import { sendMessageSignal } from "../../signal/send.js";
|
||||
import { normalizeE164 } from "../../utils.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import { SignalConfigSchema } from "../../config/zod-schema.providers-core.js";
|
||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "./config-helpers.js";
|
||||
import { formatPairingApproveHint } from "./helpers.js";
|
||||
import { resolveChannelMediaMaxBytes } from "./media-limits.js";
|
||||
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize/signal.js";
|
||||
import { signalOnboardingAdapter } from "./onboarding/signal.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
listSignalAccountIds,
|
||||
looksLikeSignalTargetId,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "./setup-helpers.js";
|
||||
import type { ChannelPlugin } from "./types.js";
|
||||
normalizeAccountId,
|
||||
normalizeE164,
|
||||
normalizeSignalMessagingTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveChannelMediaMaxBytes,
|
||||
resolveDefaultSignalAccountId,
|
||||
resolveSignalAccount,
|
||||
setAccountEnabledInConfigSection,
|
||||
signalOnboardingAdapter,
|
||||
SignalConfigSchema,
|
||||
type ChannelPlugin,
|
||||
type ResolvedSignalAccount,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getSignalRuntime } from "./runtime.js";
|
||||
|
||||
const meta = getChatChannelMeta("signal");
|
||||
|
||||
@@ -39,7 +36,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
idLabel: "signalNumber",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""),
|
||||
notifyApproval: async ({ id }) => {
|
||||
await sendMessageSignal(id, PAIRING_APPROVED_MESSAGE);
|
||||
await getSignalRuntime().channel.signal.sendMessageSignal(id, PAIRING_APPROVED_MESSAGE);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
@@ -199,10 +196,10 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkText,
|
||||
chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit),
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||
const send = deps?.sendSignal ?? sendMessageSignal;
|
||||
const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
|
||||
const maxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||
@@ -217,7 +214,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
return { channel: "signal", ...result };
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
|
||||
const send = deps?.sendSignal ?? sendMessageSignal;
|
||||
const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
|
||||
const maxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||
@@ -266,7 +263,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) => {
|
||||
const baseUrl = account.baseUrl;
|
||||
return await probeSignal(baseUrl, timeoutMs);
|
||||
return await getSignalRuntime().channel.signal.probeSignal(baseUrl, timeoutMs);
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
@@ -292,8 +289,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting provider (${account.baseUrl})`);
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorSignalProvider } = await import("../../signal/index.js");
|
||||
return monitorSignalProvider({
|
||||
return getSignalRuntime().channel.signal.monitorSignalProvider({
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user