Compare commits

..
Author SHA1 Message Date
Peter Steinberger ace906539f fix: gate tool-error fallback replies (#1175) (thanks @vrknetha) 2026-01-18 14:07:34 +00:00
vrknetha f039fdf8e9 Agents: surface tool failures without assistant output 2026-01-18 14:03:08 +00:00
Peter Steinberger 858a5f48d8 Merge pull request #1176 from sibbl/fix-matrix-allowfrom
Matrix: fix redundant allowFrom assignment in monitorMatrixProvider
2026-01-18 13:57:00 +00:00
Peter Steinberger 20c26eb303 fix: prevent sqlite-vec duplicate id failures 2026-01-18 13:55:56 +00:00
Peter Steinberger f3ef609839 fix: show exec approval alerts for local mac node 2026-01-18 13:42:23 +00:00
Sebastian Schubotz 234fe5b5cd fix(matrix): remove redundant allowFrom assignment in monitorMatrixProvider 2026-01-18 14:05:08 +01:00
Peter Steinberger e944f21ec0 test: drop core runtime import in matrix directory 2026-01-18 11:03:27 +00:00
Peter Steinberger ee6e534ccb refactor: route channel runtime via plugin api 2026-01-18 11:01:16 +00:00
Peter Steinberger 676d41d415 fix: seed embedding cache for atomic reindex 2026-01-18 09:28:42 +00:00
Peter Steinberger a3a4996adb feat: add gemini memory embeddings 2026-01-18 09:09:45 +00:00
Peter Steinberger b015c7e5ad fix: sync protocol outputs 2026-01-18 08:58:41 +00:00
Peter Steinberger 4de3c3a028 feat: add exec approvals editor in control ui and mac app 2026-01-18 08:54:38 +00:00
Peter Steinberger b739a3897f fix: stabilize acp streams and tests 2026-01-18 08:54:00 +00:00
Peter Steinberger c5e19f5c67 refactor: migrate messaging plugins to sdk 2026-01-18 08:54:00 +00:00
Peter Steinberger 9241e21114 fix: address acp client typing 2026-01-18 08:51:57 +00:00
Peter Steinberger 65bed815a8 fix: resolve ci failures 2026-01-18 08:45:29 +00:00
Peter Steinberger d776cfb4e1 fix: skip launchd for remote mode 2026-01-18 08:35:14 +00:00
Peter Steinberger c6e7e1821b test: tolerate tool summary payloads in install e2e 2026-01-18 08:33:45 +00:00
Peter Steinberger f76ab69612 feat: add memory indexing progress options 2026-01-18 08:30:04 +00:00
Peter Steinberger 889db137b8 test: add beta tag install option for docker installer 2026-01-18 08:30:00 +00:00
Peter Steinberger 9db682750d chore: point Peekaboo to main 2026-01-18 08:29:00 +00:00
Peter Steinberger 9809b47d45 feat(acp): add interactive client harness 2026-01-18 08:27:37 +00:00
Peter Steinberger 68d79e56c2 feat: add node binding controls in control ui 2026-01-18 08:26:32 +00:00
Peter SteinbergerandMykyta Bozhenko d3862ae30a fix(auth): preserve auto-pin preference
Co-authored-by: Mykyta Bozhenko <21245729+cheeeee@users.noreply.github.com>
2026-01-18 08:22:55 +00:00
Peter Steinberger e49a2952d9 fix: clean up duplicate import (#1098)
Follow-up after rebase.
2026-01-18 08:15:21 +00:00
Peter SteinbergerandTyler Yust 8b57f519c3 fix: tighten native image injection (#1098)
Thanks @tyler6204.

Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
2026-01-18 08:15:21 +00:00
Tyler Yust ddcc05f5f4 fix: improve error handling for file URL processing
- Enhanced error handling in image reference detection to skip malformed file URLs without crashing.
- Updated media loading logic to throw an error for invalid file URLs, ensuring better feedback for users.
2026-01-18 08:15:21 +00:00
Tyler Yust 8c0e290db1 fix: enhance image reference detection and optimize image processing
- Added support for detecting file URLs in prompts using fileURLToPath for accurate path resolution.
- Updated image loading logic to default to JPEG format for optimized image processing.
- Improved error handling in image optimization to continue processing on failures.
2026-01-18 08:15:21 +00:00
Tyler Yust 7bfc77db25 fix: improve file URL handling and enhance image loading logic
- Added handling for file URLs using fileURLToPath for proper resolution.
- Updated logic to skip relative path resolution if ref.resolved is already absolute.
- Enhanced cap calculation for image loading to handle undefined maxBytes more gracefully.
2026-01-18 08:15:21 +00:00
Tyler YustandClaude Opus 4.5 8d74578ceb feat: native image injection for vision-capable models
- Auto-detect and load images referenced in user prompts
- Inject history images at their original message positions
- Fix EXIF orientation - rotate before resizing in resizeToJpeg
- Sandbox security: validate paths, block remote URLs when sandbox enabled
- Prevent duplicate history image injection across turns
- Handle string-based user message content (convert to array)
- Add bounds check for message index in history processing
- Fix regex to properly match relative paths (./  ../)
- Add multi-image support for iMessage attachments
- Pass MAX_IMAGE_BYTES limit to image loading

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 08:15:21 +00:00
Peter Steinberger f7123ec30a fix: repair context report and tool config 2026-01-18 08:15:21 +00:00
Peter Steinberger ad4f4388f4 docs: explain per-agent exec node binding 2026-01-18 08:15:15 +00:00
Peter Steinberger 2a86504723 perf: lazy-load memory manager 2026-01-18 08:05:36 +00:00
Peter SteinbergerandJonathan Taylor de3b68740a feat(acp): add experimental ACP support
Co-authored-by: Jonathan Taylor <visionik@pobox.com>
2026-01-18 08:03:36 +00:00
Peter Steinberger efaa73f543 docs: align exec event text 2026-01-18 08:01:25 +00:00
Peter Steinberger 1589c73697 test: cover bridge exec events 2026-01-18 08:01:25 +00:00
Peter Steinberger 359d2af8a8 fix: resolve mac build errors 2026-01-18 08:00:58 +00:00
Peter Steinberger fa897e5dfe docs: explain node host use cases 2026-01-18 07:59:03 +00:00
Peter Steinberger 7fa8ae56cb docs: add exec events to bridge protocol 2026-01-18 07:59:03 +00:00
Peter SteinbergerandMykyta Bozhenko ec27c813cc fix(fallback): handle timeout aborts
Co-authored-by: Mykyta Bozhenko <21245729+cheeeee@users.noreply.github.com>
2026-01-18 07:52:44 +00:00
Peter Steinberger 3b24fe639a chore: remove peekaboo submodule 2026-01-18 07:47:32 +00:00
Peter Steinberger e5cca6e432 chore: switch Peekaboo to SPM 2026-01-18 07:47:31 +00:00
Peter Steinberger ae0b4c4990 feat: add exec host routing + node daemon 2026-01-18 07:46:00 +00:00
Peter Steinberger 49bd2d96fa test: fix gateway test lint 2026-01-18 07:44:14 +00:00
Peter Steinberger ca350fc66c chore(format): oxfmt memory 2026-01-18 07:30:07 +00:00
Peter Steinberger 30338ce1a7 refactor: share memory plugin config helpers 2026-01-18 07:24:16 +00:00
Peter Steinberger faa94f0168 Merge pull request #1148 from TSavo/refactor/gateway-test-monkeypatching
refactor: remove monkeypatching from gateway tests
2026-01-18 07:16:33 +00:00
Peter Steinberger f5c84768ff chore(format): oxfmt 2026-01-18 07:14:40 +00:00
Peter Steinberger df752d4706 Merge pull request #1149 from radek-paclt/feature/memory-plugin-v2
feat(memory): add lifecycle hooks and vector memory plugin
2026-01-18 07:10:06 +00:00
Peter Steinberger c9c9516206 refactor(memory): extract sync + status helpers 2026-01-18 07:03:06 +00:00
Peter Steinberger d3b15c6afa ci: stabilize vitest runs 2026-01-18 06:58:54 +00:00
Peter SteinbergerandAustin Mudd f86b24c511 refactor(session): centralize thread reset detection
Co-authored-by: Austin Mudd <austinm911@gmail.com>
2026-01-18 06:55:04 +00:00
Peter Steinberger b5ddf08763 test: expand soul-evil coverage 2026-01-18 06:39:26 +00:00
Peter SteinbergerandAustin Mudd 367826f6e4 feat(session): add daily reset policy
Co-authored-by: Austin Mudd <austinm911@gmail.com>
2026-01-18 06:37:37 +00:00
Peter SteinbergerandAndrew Lauppe f03c3b3f05 docs: update changelog for #1147
Co-authored-by: Andrew Lauppe <andy@t5tele.com>
2026-01-18 06:37:29 +00:00
Radek PacltandClaude Opus 4.5 ebfeb7a6bf feat(memory): add lifecycle hooks and vector memory plugin
Add plugin lifecycle hooks infrastructure:
- before_agent_start: inject context before agent loop
- agent_end: analyze conversation after completion
- 13 hook types total (message, tool, session, gateway hooks)

Memory plugin implementation:
- LanceDB vector storage with OpenAI embeddings
- kind: "memory" to integrate with upstream slot system
- Auto-recall: injects <relevant-memories> when context found
- Auto-capture: stores preferences, decisions, entities
- Rule-based capture filtering with 0.95 similarity dedup
- Tools: memory_recall, memory_store, memory_forget
- CLI: clawdbot ltm list|search|stats

Plugin infrastructure:
- api.on() method for hook registration
- Global hook runner singleton for cross-module access
- Priority ordering and error catching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 06:34:43 +00:00
Peter Steinberger ac1b2d8c40 chore(gate): fix lint and protocol 2026-01-18 06:31:02 +00:00
Peter Steinberger 2087f0c6a1 ci: bump vitest timeouts 2026-01-18 06:31:02 +00:00
Peter Steinberger bcfdcc6820 fix: keep bootstrap files in context report 2026-01-18 06:30:01 +00:00
Peter Steinberger b65acfcbb7 chore(lint): fix context report bootstrap destructure 2026-01-18 06:30:01 +00:00
Peter Steinberger f7fcfafb4c fix: resolve lint after rebase 2026-01-18 06:30:01 +00:00
Peter Steinberger 15606b4d88 test: cover bundled memory plugin package metadata 2026-01-18 06:30:01 +00:00
Peter Steinberger bb8f08734a build: package memory-core as a workspace plugin 2026-01-18 06:30:01 +00:00
Peter SteinbergerandAndrew Lauppe 0b00e591e1 fix(streaming): emit assistant deltas
Co-authored-by: Andrew Lauppe <andy@t5tele.com>
2026-01-18 06:24:52 +00:00
Peter Steinberger e39fd7dbb3 docs: update bundled hooks list 2026-01-18 06:23:09 +00:00
Peter Steinberger b8a82923e9 docs: add soul-evil hook docs 2026-01-18 06:21:00 +00:00
Peter Steinberger 28f8b7bafa refactor: add hook guards and test helpers 2026-01-18 06:15:24 +00:00
Peter Steinberger 32dd052260 chore: show plugin hooks in plugins info 2026-01-18 06:14:09 +00:00
Peter Steinberger 8f7f7ee7dc feat: add /exec session overrides 2026-01-18 06:12:54 +00:00
Peter Steinberger 1d8614c7c2 fix: align exec tool config and test timeouts 2026-01-18 06:12:53 +00:00
Peter SteinbergerandAndrew Lauppe 436c5fd751 fix(openai-http): reuse history markers for chat prompts
Co-authored-by: Andrew Lauppe <andy@t5tele.com>
2026-01-18 06:07:59 +00:00
tsavo b594f5130d refactor: add afterEach cleanup to all gateway tests
Added afterEach hooks with server/ws cleanup to:
- server.channels.test.ts (3 tests)
- server.config-apply.test.ts (2 tests)
- server.sessions-send.test.ts (already had this)

This ensures ports are properly released between tests, preventing
timeout issues from port conflicts.
2026-01-17 21:35:01 -08:00
tsavo e2bb5eecf3 refactor: remove monkeypatching from gateway tests
Replace manual process.env backup/restore with vi.stubEnv():
- server.config-apply.test.ts: Simplified env var pattern
- server.channels.test.ts: Simplified env var pattern
- server.sessions-send.test.ts: Added afterEach cleanup hook, removed try-finally blocks from all 4 tests

Uses proper Vitest isolation instead of manual restoration.
2026-01-17 21:32:14 -08:00
342 changed files with 14367 additions and 2284 deletions

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
[submodule "Peekaboo"]
path = Peekaboo
url = https://github.com/steipete/Peekaboo.git
branch = main
+28 -1
View File
@@ -2,18 +2,45 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.18-4
### Changes
- 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) — thanks @gumadeiras.
### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151) — thanks @gumadeiras.
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151) — thanks @gumadeiras.
- Agents: surface tool failures when no assistant output is emitted. (#1175) — thanks @vrknetha.
## 2026.1.18-3 ## 2026.1.18-3
### Changes ### Changes
- Exec: add host/security/ask routing for gateway + node exec. - Exec: add host/security/ask routing for gateway + node exec.
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle. - macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
- macOS: add approvals socket UI server + node exec lifecycle events. - macOS: add approvals socket UI server + node exec lifecycle events.
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
- Nodes: add node daemon service install/status/start/stop/restart.
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639. - Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
- Agents: auto-inject local image references for vision models and avoid reloading history images. (#1098) — thanks @tyler6204.
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals - Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
- CLI: add `clawdbot acp` ACP bridge for IDE integrations. - 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.
### Fixes ### Fixes
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
- Tools: return a companion-app-required message when node exec is requested with no paired node. - Tools: return a companion-app-required message when node exec is requested with no paired node.
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
## 2026.1.18-2 ## 2026.1.18-2
Submodule Peekaboo deleted from 5c195f5e46.
+1 -1
View File
@@ -13,7 +13,7 @@ let package = Package(
.executable(name: "swabble", targets: ["SwabbleCLI"]), .executable(name: "swabble", targets: ["SwabbleCLI"]),
], ],
dependencies: [ dependencies: [
.package(path: "../Peekaboo/Commander"), .package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"), .package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
], ],
targets: [ targets: [
+28 -73
View File
@@ -1,6 +1,24 @@
{ {
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b", "originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
"pins" : [ "pins" : [
{
"identity" : "axorcist",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/AXorcist.git",
"state" : {
"revision" : "c75d06f7f93e264a9786edc2b78c04973061cb2f",
"version" : "0.1.0"
}
},
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
"version" : "0.2.1"
}
},
{ {
"identity" : "elevenlabskit", "identity" : "elevenlabskit",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -10,15 +28,6 @@
"version" : "0.1.0" "version" : "0.1.0"
} }
}, },
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattt/eventsource.git",
"state" : {
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
"version" : "1.3.0"
}
},
{ {
"identity" : "menubarextraaccess", "identity" : "menubarextraaccess",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -28,6 +37,15 @@
"version" : "1.2.2" "version" : "1.2.2"
} }
}, },
{
"identity" : "peekaboo",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Peekaboo.git",
"state" : {
"branch" : "main",
"revision" : "b2d0384d9f0f45b945d5f718f8a865bd574d83c2"
}
},
{ {
"identity" : "sparkle", "identity" : "sparkle",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -46,33 +64,6 @@
"version" : "1.2.1" "version" : "1.2.1"
} }
}, },
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
"version" : "1.5.1"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms",
"state" : {
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
"version" : "1.1.1"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"branch" : "main",
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
}
},
{ {
"identity" : "swift-concurrency-extras", "identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -82,24 +73,6 @@
"version" : "1.3.2" "version" : "1.3.2"
} }
}, },
{
"identity" : "swift-configuration",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-configuration",
"state" : {
"revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749",
"version" : "1.0.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
"version" : "4.2.0"
}
},
{ {
"identity" : "swift-log", "identity" : "swift-log",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -118,24 +91,6 @@
"version" : "1.1.1" "version" : "1.1.1"
} }
}, },
{
"identity" : "swift-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/modelcontextprotocol/swift-sdk.git",
"state" : {
"revision" : "c0407a0b52677cb395d824cac2879b963075ba8c",
"version" : "0.10.2"
}
},
{
"identity" : "swift-service-lifecycle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle",
"state" : {
"revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
"version" : "2.9.1"
}
},
{ {
"identity" : "swift-subprocess", "identity" : "swift-subprocess",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
+3 -4
View File
@@ -20,10 +20,9 @@ let package = Package(
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"), .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
.package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"),
.package(path: "../shared/ClawdbotKit"), .package(path: "../shared/ClawdbotKit"),
.package(path: "../../Swabble"), .package(path: "../../Swabble"),
.package(path: "../../Peekaboo/Core/PeekabooCore"),
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
], ],
targets: [ targets: [
.target( .target(
@@ -61,8 +60,8 @@ let package = Package(
.product(name: "Subprocess", package: "swift-subprocess"), .product(name: "Subprocess", package: "swift-subprocess"),
.product(name: "Logging", package: "swift-log"), .product(name: "Logging", package: "swift-log"),
.product(name: "Sparkle", package: "Sparkle"), .product(name: "Sparkle", package: "Sparkle"),
.product(name: "PeekabooBridge", package: "PeekabooCore"), .product(name: "PeekabooBridge", package: "Peekaboo"),
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"), .product(name: "PeekabooAutomationKit", package: "Peekaboo"),
], ],
exclude: [ exclude: [
"Resources/Info.plist", "Resources/Info.plist",
@@ -162,7 +162,7 @@ enum ExecApprovalsStore {
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data) let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
if decoded.version != 1 { if decoded.version != 1 {
return ExecApprovalsFile(version: 1, socket: decoded.socket, defaults: decoded.defaults, agents: decoded.agents) return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
} }
return decoded return decoded
} catch { } catch {
@@ -204,7 +204,7 @@ enum ExecApprovalsStore {
} }
static func resolve(agentId: String?) -> ExecApprovalsResolved { static func resolve(agentId: String?) -> ExecApprovalsResolved {
var file = self.ensureFile() let file = self.ensureFile()
let defaults = file.defaults ?? ExecApprovalsDefaults() let defaults = file.defaults ?? ExecApprovalsDefaults()
let resolvedDefaults = ExecApprovalsResolvedDefaults( let resolvedDefaults = ExecApprovalsResolvedDefaults(
security: defaults.security ?? self.defaultSecurity, security: defaults.security ?? self.defaultSecurity,
@@ -397,11 +397,32 @@ struct ExecCommandResolution: Sendable {
let executableName: String let executableName: String
let cwd: String? let cwd: String?
static func resolve(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?
) -> ExecCommandResolution? {
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
}
return self.resolve(command: command, cwd: cwd, env: env)
}
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? { static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil return nil
} }
let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
}
private static func resolveExecutable(
rawExecutable: String,
cwd: String?,
env: [String: String]?
) -> ExecCommandResolution? {
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\") let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
let resolvedPath: String? = { let resolvedPath: String? = {
if hasPathSeparator { if hasPathSeparator {
@@ -419,6 +440,20 @@ struct ExecCommandResolution: Sendable {
return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd) return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
} }
private static func parseFirstToken(_ command: String) -> String? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let first = trimmed.first else { return nil }
if first == "\"" || first == "'" {
let rest = trimmed.dropFirst()
if let end = rest.firstIndex(of: first) {
return String(rest[..<end])
}
return String(rest)
}
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
}
private static func searchPaths(from env: [String: String]?) -> [String] { private static func searchPaths(from env: [String: String]?) -> [String] {
let raw = env?["PATH"] let raw = env?["PATH"]
if let raw, !raw.isEmpty { if let raw, !raw.isEmpty {
@@ -439,6 +474,12 @@ enum ExecCommandFormatter {
return "\"\(escaped)\"" return "\"\(escaped)\""
}.joined(separator: " ") }.joined(separator: " ")
} }
static func displayString(for argv: [String], rawCommand: String?) -> String {
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty { return trimmed }
return self.displayString(for: argv)
}
} }
enum ExecAllowlistMatcher { enum ExecAllowlistMatcher {
@@ -522,7 +563,7 @@ struct ExecEventPayload: Codable, Sendable {
guard !trimmed.isEmpty else { return nil } guard !trimmed.isEmpty else { return nil }
if trimmed.count <= maxChars { return trimmed } if trimmed.count <= maxChars { return trimmed }
let suffix = trimmed.suffix(maxChars) let suffix = trimmed.suffix(maxChars)
return "… (truncated) \(suffix)" return "... (truncated) \(suffix)"
} }
} }
@@ -157,9 +157,10 @@ final class ExecApprovalsPromptServer {
} }
} }
private enum ExecApprovalsPromptPresenter { enum ExecApprovalsPromptPresenter {
@MainActor @MainActor
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision { static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert() let alert = NSAlert()
alert.alertStyle = .warning alert.alertStyle = .warning
alert.messageText = "Allow this command?" alert.messageText = "Allow this command?"
@@ -205,7 +206,7 @@ private enum ExecApprovalsPromptPresenter {
} }
} }
private final class ExecApprovalsSocketServer { private final class ExecApprovalsSocketServer: @unchecked Sendable {
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket") private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
private let socketPath: String private let socketPath: String
private let token: String private let token: String
@@ -16,6 +16,10 @@ enum GatewayLaunchAgentManager {
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
_ = bundlePath _ = bundlePath
guard !CommandResolver.connectionModeIsRemote() else {
self.logger.info("launchd change skipped (remote mode)")
return nil
}
if enabled, self.isLaunchAgentWriteDisabled() { if enabled, self.isLaunchAgentWriteDisabled() {
self.logger.info("launchd enable skipped (disable marker set)") self.logger.info("launchd enable skipped (disable marker set)")
return nil return nil
@@ -112,7 +116,9 @@ extension GatewayLaunchAgentManager {
{ {
let command = CommandResolver.clawdbotCommand( let command = CommandResolver.clawdbotCommand(
subcommand: "daemon", subcommand: "daemon",
extraArgs: self.withJsonFlag(args)) extraArgs: self.withJsonFlag(args),
// Launchd management must always run locally, even if remote mode is configured.
configRoot: ["gateway": ["mode": "local"]])
var env = ProcessInfo.processInfo.environment var env = ProcessInfo.processInfo.environment
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
@@ -114,6 +114,9 @@ final class GatewayProcessManager {
self.lastFailureReason = nil self.lastFailureReason = nil
self.status = .stopped self.status = .stopped
self.logger.info("gateway stop requested") self.logger.info("gateway stop requested")
if CommandResolver.connectionModeIsRemote() {
return
}
let bundlePath = Bundle.main.bundleURL.path let bundlePath = Bundle.main.bundleURL.path
Task { Task {
_ = await GatewayLaunchAgentManager.set( _ = await GatewayLaunchAgentManager.set(
@@ -432,6 +432,7 @@ actor MacNodeRuntime {
guard !command.isEmpty else { guard !command.isEmpty else {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required") return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
} }
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: params.rawCommand)
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
@@ -444,7 +445,12 @@ actor MacNodeRuntime {
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines) ? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
: self.mainSessionKey : self.mainSessionKey
let runId = UUID().uuidString let runId = UUID().uuidString
let resolution = ExecCommandResolution.resolve(command: command, cwd: params.cwd, env: params.env) let env = Self.sanitizedEnv(params.env)
let resolution = ExecCommandResolution.resolve(
command: command,
rawCommand: params.rawCommand,
cwd: params.cwd,
env: env)
let allowlistMatch = security == .allowlist let allowlistMatch = security == .allowlist
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution) ? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
: nil : nil
@@ -463,7 +469,7 @@ actor MacNodeRuntime {
sessionKey: sessionKey, sessionKey: sessionKey,
runId: runId, runId: runId,
host: "node", host: "node",
command: ExecCommandFormatter.displayString(for: command), command: displayCommand,
reason: "security=deny")) reason: "security=deny"))
return Self.errorResponse( return Self.errorResponse(
req, req,
@@ -477,12 +483,11 @@ actor MacNodeRuntime {
return false return false
}() }()
var approvedByAsk = false
if requiresAsk { if requiresAsk {
let decision = await ExecApprovalsSocketClient.requestDecision( let decision: ExecApprovalDecision? = await ExecApprovalsPromptPresenter.prompt(
socketPath: approvals.socketPath, ExecApprovalPromptRequest(
token: approvals.token, command: displayCommand,
request: ExecApprovalPromptRequest(
command: ExecCommandFormatter.displayString(for: command),
cwd: params.cwd, cwd: params.cwd,
host: "node", host: "node",
security: security.rawValue, security: security.rawValue,
@@ -498,21 +503,40 @@ actor MacNodeRuntime {
sessionKey: sessionKey, sessionKey: sessionKey,
runId: runId, runId: runId,
host: "node", host: "node",
command: ExecCommandFormatter.displayString(for: command), command: displayCommand,
reason: "user-denied")) reason: "user-denied"))
return Self.errorResponse( return Self.errorResponse(
req, req,
code: .unavailable, code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied") message: "SYSTEM_RUN_DENIED: user denied")
case nil: case nil:
if askFallback == .deny || (askFallback == .allowlist && allowlistMatch == nil && !skillAllow) { if askFallback == .full {
approvedByAsk = true
} else if askFallback == .allowlist {
if allowlistMatch != nil || skillAllow {
approvedByAsk = true
} else {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "approval-required"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: approval required")
}
} else {
await self.emitExecEvent( await self.emitExecEvent(
"exec.denied", "exec.denied",
payload: ExecEventPayload( payload: ExecEventPayload(
sessionKey: sessionKey, sessionKey: sessionKey,
runId: runId, runId: runId,
host: "node", host: "node",
command: ExecCommandFormatter.displayString(for: command), command: displayCommand,
reason: "approval-required")) reason: "approval-required"))
return Self.errorResponse( return Self.errorResponse(
req, req,
@@ -520,6 +544,7 @@ actor MacNodeRuntime {
message: "SYSTEM_RUN_DENIED: approval required") message: "SYSTEM_RUN_DENIED: approval required")
} }
case .allowAlways?: case .allowAlways?:
approvedByAsk = true
if security == .allowlist { if security == .allowlist {
let pattern = resolution?.resolvedPath ?? let pattern = resolution?.resolvedPath ??
resolution?.rawExecutable ?? resolution?.rawExecutable ??
@@ -530,20 +555,33 @@ actor MacNodeRuntime {
} }
} }
case .allowOnce?: case .allowOnce?:
break approvedByAsk = true
} }
} }
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "allowlist-miss"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: allowlist miss")
}
if let match = allowlistMatch { if let match = allowlistMatch {
ExecApprovalsStore.recordAllowlistUse( ExecApprovalsStore.recordAllowlistUse(
agentId: agentId, agentId: agentId,
pattern: match.pattern, pattern: match.pattern,
command: ExecCommandFormatter.displayString(for: command), command: displayCommand,
resolvedPath: resolution?.resolvedPath) resolvedPath: resolution?.resolvedPath)
} }
let env = Self.sanitizedEnv(params.env)
if params.needsScreenRecording == true { if params.needsScreenRecording == true {
let authorized = await PermissionManager let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false .status([.screenRecording])[.screenRecording] ?? false
@@ -554,7 +592,7 @@ actor MacNodeRuntime {
sessionKey: sessionKey, sessionKey: sessionKey,
runId: runId, runId: runId,
host: "node", host: "node",
command: ExecCommandFormatter.displayString(for: command), command: displayCommand,
reason: "permission:screenRecording")) reason: "permission:screenRecording"))
return Self.errorResponse( return Self.errorResponse(
req, req,
@@ -570,20 +608,23 @@ actor MacNodeRuntime {
sessionKey: sessionKey, sessionKey: sessionKey,
runId: runId, runId: runId,
host: "node", host: "node",
command: ExecCommandFormatter.displayString(for: command))) command: displayCommand))
let result = await ShellExecutor.runDetailed( let result = await ShellExecutor.runDetailed(
command: command, command: command,
cwd: params.cwd, cwd: params.cwd,
env: env, env: env,
timeout: timeoutSec) timeout: timeoutSec)
let combined = [result.stdout, result.stderr, result.errorMessage].filter { !$0.isEmpty }.joined(separator: "\n") let combined = [result.stdout, result.stderr, result.errorMessage]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: "\n")
await self.emitExecEvent( await self.emitExecEvent(
"exec.finished", "exec.finished",
payload: ExecEventPayload( payload: ExecEventPayload(
sessionKey: sessionKey, sessionKey: sessionKey,
runId: runId, runId: runId,
host: "node", host: "node",
command: ExecCommandFormatter.displayString(for: command), command: displayCommand,
exitCode: result.exitCode, exitCode: result.exitCode,
timedOut: result.timedOut, timedOut: result.timedOut,
success: result.success, success: result.success,
@@ -13,18 +13,16 @@ struct SystemRunSettingsView: View {
Text("Exec approvals") Text("Exec approvals")
.font(.body) .font(.body)
Spacer(minLength: 0) Spacer(minLength: 0)
if self.model.agentIds.count > 1 { Picker("Agent", selection: Binding(
Picker("Agent", selection: Binding( get: { self.model.selectedAgentId },
get: { self.model.selectedAgentId }, set: { self.model.selectAgent($0) }))
set: { self.model.selectAgent($0) })) {
{ ForEach(self.model.agentPickerIds, id: \.self) { id in
ForEach(self.model.agentIds, id: \.self) { id in Text(self.model.label(for: id)).tag(id)
Text(id).tag(id)
}
} }
.pickerStyle(.menu)
.frame(width: 160, alignment: .trailing)
} }
.pickerStyle(.menu)
.frame(width: 180, alignment: .trailing)
} }
Picker("", selection: self.$tab) { Picker("", selection: self.$tab) {
@@ -82,7 +80,9 @@ struct SystemRunSettingsView: View {
.labelsHidden() .labelsHidden()
.pickerStyle(.menu) .pickerStyle(.menu)
Text("Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.") Text(self.model.isDefaultsScope
? "Defaults apply when an agent has no overrides. Ask controls prompt behavior; fallback is used when no companion UI is reachable."
: "Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -101,31 +101,37 @@ struct SystemRunSettingsView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
HStack(spacing: 8) { if self.model.isDefaultsScope {
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern) Text("Allowlists are per-agent. Select an agent to edit its allowlist.")
.textFieldStyle(.roundedBorder)
Button("Add") {
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !pattern.isEmpty else { return }
self.model.addEntry(pattern)
self.newPattern = ""
}
.buttonStyle(.bordered)
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
if self.model.entries.isEmpty {
Text("No allowlisted commands yet.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) {
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
ExecAllowlistRow( .textFieldStyle(.roundedBorder)
entry: Binding( Button("Add") {
get: { self.model.entries[index] }, let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
set: { self.model.updateEntry($0, at: index) }), guard !pattern.isEmpty else { return }
onRemove: { self.model.removeEntry(at: index) }) self.model.addEntry(pattern)
self.newPattern = ""
}
.buttonStyle(.bordered)
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
if self.model.entries.isEmpty {
Text("No allowlisted commands yet.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
ExecAllowlistRow(
entry: Binding(
get: { self.model.entries[index] },
set: { self.model.updateEntry($0, at: index) }),
onRemove: { self.model.removeEntry(at: index) })
}
} }
} }
} }
@@ -177,8 +183,16 @@ struct ExecAllowlistRow: View {
Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))") Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty { }
Text("Last used: \(lastUsedCommand)")
if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
Text("Last command: \(lastUsedCommand)")
.font(.caption)
.foregroundStyle(.secondary)
}
if let lastResolvedPath = self.entry.lastResolvedPath, !lastResolvedPath.isEmpty {
Text("Resolved path: \(lastResolvedPath)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -201,6 +215,7 @@ struct ExecAllowlistRow: View {
@MainActor @MainActor
@Observable @Observable
final class ExecApprovalsSettingsModel { final class ExecApprovalsSettingsModel {
private static let defaultsScopeId = "__defaults__"
var agentIds: [String] = [] var agentIds: [String] = []
var selectedAgentId: String = "main" var selectedAgentId: String = "main"
var defaultAgentId: String = "main" var defaultAgentId: String = "main"
@@ -211,6 +226,19 @@ final class ExecApprovalsSettingsModel {
var entries: [ExecAllowlistEntry] = [] var entries: [ExecAllowlistEntry] = []
var skillBins: [String] = [] var skillBins: [String] = []
var agentPickerIds: [String] {
[Self.defaultsScopeId] + self.agentIds
}
var isDefaultsScope: Bool {
self.selectedAgentId == Self.defaultsScopeId
}
func label(for id: String) -> String {
if id == Self.defaultsScopeId { return "Defaults" }
return id
}
func refresh() async { func refresh() async {
await self.refreshAgents() await self.refreshAgents()
self.loadSettings(for: self.selectedAgentId) self.loadSettings(for: self.selectedAgentId)
@@ -242,6 +270,9 @@ final class ExecApprovalsSettingsModel {
} }
self.agentIds = ids self.agentIds = ids
self.defaultAgentId = defaultId ?? "main" self.defaultAgentId = defaultId ?? "main"
if self.selectedAgentId == Self.defaultsScopeId {
return
}
if !self.agentIds.contains(self.selectedAgentId) { if !self.agentIds.contains(self.selectedAgentId) {
self.selectedAgentId = self.defaultAgentId self.selectedAgentId = self.defaultAgentId
} }
@@ -254,6 +285,15 @@ final class ExecApprovalsSettingsModel {
} }
func loadSettings(for agentId: String) { func loadSettings(for agentId: String) {
if agentId == Self.defaultsScopeId {
let defaults = ExecApprovalsStore.resolveDefaults()
self.security = defaults.security
self.ask = defaults.ask
self.askFallback = defaults.askFallback
self.autoAllowSkills = defaults.autoAllowSkills
self.entries = []
return
}
let resolved = ExecApprovalsStore.resolve(agentId: agentId) let resolved = ExecApprovalsStore.resolve(agentId: agentId)
self.security = resolved.agent.security self.security = resolved.agent.security
self.ask = resolved.agent.ask self.ask = resolved.agent.ask
@@ -265,36 +305,61 @@ final class ExecApprovalsSettingsModel {
func setSecurity(_ security: ExecSecurity) { func setSecurity(_ security: ExecSecurity) {
self.security = security self.security = security
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in if self.isDefaultsScope {
entry.security = security ExecApprovalsStore.updateDefaults { defaults in
defaults.security = security
}
} else {
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.security = security
}
} }
self.syncQuickMode() self.syncQuickMode()
} }
func setAsk(_ ask: ExecAsk) { func setAsk(_ ask: ExecAsk) {
self.ask = ask self.ask = ask
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in if self.isDefaultsScope {
entry.ask = ask ExecApprovalsStore.updateDefaults { defaults in
defaults.ask = ask
}
} else {
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.ask = ask
}
} }
self.syncQuickMode() self.syncQuickMode()
} }
func setAskFallback(_ mode: ExecSecurity) { func setAskFallback(_ mode: ExecSecurity) {
self.askFallback = mode self.askFallback = mode
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in if self.isDefaultsScope {
entry.askFallback = mode ExecApprovalsStore.updateDefaults { defaults in
defaults.askFallback = mode
}
} else {
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.askFallback = mode
}
} }
} }
func setAutoAllowSkills(_ enabled: Bool) { func setAutoAllowSkills(_ enabled: Bool) {
self.autoAllowSkills = enabled self.autoAllowSkills = enabled
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in if self.isDefaultsScope {
entry.autoAllowSkills = enabled ExecApprovalsStore.updateDefaults { defaults in
defaults.autoAllowSkills = enabled
}
} else {
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.autoAllowSkills = enabled
}
} }
Task { await self.refreshSkillBins(force: enabled) } Task { await self.refreshSkillBins(force: enabled) }
} }
func addEntry(_ pattern: String) { func addEntry(_ pattern: String) {
guard !self.isDefaultsScope else { return }
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return } guard !trimmed.isEmpty else { return }
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil)) self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
@@ -302,12 +367,14 @@ final class ExecApprovalsSettingsModel {
} }
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) { func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
guard !self.isDefaultsScope else { return }
guard self.entries.indices.contains(index) else { return } guard self.entries.indices.contains(index) else { return }
self.entries[index] = entry self.entries[index] = entry
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
} }
func removeEntry(at index: Int) { func removeEntry(at index: Int) {
guard !self.isDefaultsScope else { return }
guard self.entries.indices.contains(index) else { return } guard self.entries.indices.contains(index) else { return }
self.entries.remove(at: index) self.entries.remove(at: index)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
@@ -323,6 +390,10 @@ final class ExecApprovalsSettingsModel {
} }
private func syncQuickMode() { private func syncQuickMode() {
if self.isDefaultsScope {
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
return
}
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 { if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask) AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
} }
@@ -760,6 +760,10 @@ public struct SessionsPatchParams: Codable, Sendable {
public let reasoninglevel: AnyCodable? public let reasoninglevel: AnyCodable?
public let responseusage: AnyCodable? public let responseusage: AnyCodable?
public let elevatedlevel: AnyCodable? public let elevatedlevel: AnyCodable?
public let exechost: AnyCodable?
public let execsecurity: AnyCodable?
public let execask: AnyCodable?
public let execnode: AnyCodable?
public let model: AnyCodable? public let model: AnyCodable?
public let spawnedby: AnyCodable? public let spawnedby: AnyCodable?
public let sendpolicy: AnyCodable? public let sendpolicy: AnyCodable?
@@ -773,6 +777,10 @@ public struct SessionsPatchParams: Codable, Sendable {
reasoninglevel: AnyCodable?, reasoninglevel: AnyCodable?,
responseusage: AnyCodable?, responseusage: AnyCodable?,
elevatedlevel: AnyCodable?, elevatedlevel: AnyCodable?,
exechost: AnyCodable?,
execsecurity: AnyCodable?,
execask: AnyCodable?,
execnode: AnyCodable?,
model: AnyCodable?, model: AnyCodable?,
spawnedby: AnyCodable?, spawnedby: AnyCodable?,
sendpolicy: AnyCodable?, sendpolicy: AnyCodable?,
@@ -785,6 +793,10 @@ public struct SessionsPatchParams: Codable, Sendable {
self.reasoninglevel = reasoninglevel self.reasoninglevel = reasoninglevel
self.responseusage = responseusage self.responseusage = responseusage
self.elevatedlevel = elevatedlevel self.elevatedlevel = elevatedlevel
self.exechost = exechost
self.execsecurity = execsecurity
self.execask = execask
self.execnode = execnode
self.model = model self.model = model
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.sendpolicy = sendpolicy self.sendpolicy = sendpolicy
@@ -798,6 +810,10 @@ public struct SessionsPatchParams: Codable, Sendable {
case reasoninglevel = "reasoningLevel" case reasoninglevel = "reasoningLevel"
case responseusage = "responseUsage" case responseusage = "responseUsage"
case elevatedlevel = "elevatedLevel" case elevatedlevel = "elevatedLevel"
case exechost = "execHost"
case execsecurity = "execSecurity"
case execask = "execAsk"
case execnode = "execNode"
case model case model
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case sendpolicy = "sendPolicy" case sendpolicy = "sendPolicy"
@@ -1616,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 struct ChatHistoryParams: Codable, Sendable {
public let sessionkey: String public let sessionkey: String
public let limit: Int? public let limit: Int?
@@ -134,4 +134,27 @@ import Testing
#expect(script.contains("CLI=")) #expect(script.contains("CLI="))
} }
} }
@Test func configRootLocalOverridesRemoteDefaults() async throws {
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
defaults.set("clawd@example.com:2222", forKey: remoteTargetKey)
let tmp = try makeTempDir()
CommandResolver.setProjectRoot(tmp.path)
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
try self.makeExec(at: clawdbotPath)
let cmd = CommandResolver.clawdbotCommand(
subcommand: "daemon",
defaults: defaults,
configRoot: ["gateway": ["mode": "local"]])
#expect(cmd.first == clawdbotPath.path)
#expect(cmd.count >= 2)
if cmd.count >= 2 {
#expect(cmd[1] == "daemon")
}
}
} }
@@ -20,6 +20,7 @@ public enum ClawdbotNotificationDelivery: String, Codable, Sendable {
public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable { public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
public var command: [String] public var command: [String]
public var rawCommand: String?
public var cwd: String? public var cwd: String?
public var env: [String: String]? public var env: [String: String]?
public var timeoutMs: Int? public var timeoutMs: Int?
@@ -29,6 +30,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
public init( public init(
command: [String], command: [String],
rawCommand: String? = nil,
cwd: String? = nil, cwd: String? = nil,
env: [String: String]? = nil, env: [String: String]? = nil,
timeoutMs: Int? = nil, timeoutMs: Int? = nil,
@@ -37,6 +39,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
sessionKey: String? = nil) sessionKey: String? = nil)
{ {
self.command = command self.command = command
self.rawCommand = rawCommand
self.cwd = cwd self.cwd = cwd
self.env = env self.env = env
self.timeoutMs = timeoutMs self.timeoutMs = timeoutMs
+23
View File
@@ -30,6 +30,21 @@ clawdbot acp --session-label "support inbox"
clawdbot acp --session agent:main:main --reset-session 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 ## How to use this
Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want
@@ -141,3 +156,11 @@ Learn more about session keys at [/concepts/session](/concepts/session).
- `--reset-session`: reset the session key before first use. - `--reset-session`: reset the session key before first use.
- `--no-prefix-cwd`: do not prefix prompts with the working directory. - `--no-prefix-cwd`: do not prefix prompts with the working directory.
- `--verbose, -v`: verbose logging to stderr. - `--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.
+8
View File
@@ -15,6 +15,7 @@ the configure wizard (same as `clawdbot configure`).
clawdbot config get browser.executablePath clawdbot config get browser.executablePath
clawdbot config set browser.executablePath "/usr/bin/google-chrome" clawdbot config set browser.executablePath "/usr/bin/google-chrome"
clawdbot config set agents.defaults.heartbeat.every "2h" clawdbot config set agents.defaults.heartbeat.every "2h"
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
clawdbot config unset tools.web.search.apiKey clawdbot config unset tools.web.search.apiKey
``` ```
@@ -27,6 +28,13 @@ clawdbot config get agents.defaults.workspace
clawdbot config get agents.list[0].id clawdbot config get agents.list[0].id
``` ```
Use the agent list index to target a specific agent:
```bash
clawdbot config get agents.list
clawdbot config set agents.list[1].tools.exec.node "node-id-or-name"
```
## Values ## Values
Values are parsed as JSON5 when possible; otherwise they are treated as strings. Values are parsed as JSON5 when possible; otherwise they are treated as strings.
+3 -2
View File
@@ -29,11 +29,12 @@ List all discovered hooks from workspace, managed, and bundled directories.
**Example output:** **Example output:**
``` ```
Hooks (2/2 ready) Hooks (3/3 ready)
Ready: Ready:
📝 command-logger ✓ - Log all command events to a centralized audit file 📝 command-logger ✓ - Log all command events to a centralized audit file
💾 session-memory ✓ - Save session context to memory when /new command is issued 💾 session-memory ✓ - Save session context to memory when /new command is issued
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
``` ```
**Example (verbose):** **Example (verbose):**
@@ -271,4 +272,4 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
clawdbot hooks enable soul-evil clawdbot hooks enable soul-evil
``` ```
**See:** [soul-evil documentation](/hooks#soul-evil) **See:** [SOUL Evil Hook](/hooks/soul-evil)
+25 -16
View File
@@ -33,6 +33,7 @@ This page describes the current CLI behavior. If commands change, update this do
- [`models`](/cli/models) - [`models`](/cli/models)
- [`memory`](/cli/memory) - [`memory`](/cli/memory)
- [`nodes`](/cli/nodes) - [`nodes`](/cli/nodes)
- [`node`](/cli/node)
- [`sandbox`](/cli/sandbox) - [`sandbox`](/cli/sandbox)
- [`tui`](/cli/tui) - [`tui`](/cli/tui)
- [`browser`](/cli/browser) - [`browser`](/cli/browser)
@@ -170,21 +171,15 @@ clawdbot [--dev] [--profile <name>] <command>
runs runs
run run
nodes nodes
status node
describe start
list daemon
pending status
approve install
reject uninstall
rename start
invoke stop
run restart
notify
camera list|snap|clip
canvas snapshot|present|hide|navigate|eval
canvas a2ui push|reset
screen record
location get
browser browser
status status
start start
@@ -779,6 +774,20 @@ Subcommands:
All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`. All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
## Node host
`node` runs a **headless node host** or manages it as a background service. See
[`clawdbot node`](/cli/node).
Subcommands:
- `node start --host <gateway-host> --port 18790`
- `node daemon status`
- `node daemon install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
- `node daemon uninstall`
- `node daemon start`
- `node daemon stop`
- `node daemon restart`
## Nodes ## Nodes
`nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes). `nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).
@@ -795,7 +804,7 @@ Subcommands:
- `nodes reject <requestId>` - `nodes reject <requestId>`
- `nodes rename --node <id|name|ip> --name <displayName>` - `nodes rename --node <id|name|ip> --name <displayName>`
- `nodes invoke --node <id|name|ip> --command <command> [--params <json>] [--invoke-timeout <ms>] [--idempotency-key <key>]` - `nodes invoke --node <id|name|ip> --command <command> [--params <json>] [--invoke-timeout <ms>] [--idempotency-key <key>]`
- `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac only) - `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac node or headless node host)
- `nodes notify --node <id|name|ip> [--title <text>] [--body <text>] [--sound <name>] [--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>] [--invoke-timeout <ms>]` (mac only) - `nodes notify --node <id|name|ip> [--title <text>] [--body <text>] [--sound <name>] [--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>] [--invoke-timeout <ms>]` (mac only)
Camera: Camera:
+2
View File
@@ -28,3 +28,5 @@ clawdbot memory search "release checklist"
## Options ## Options
- `--verbose`: emit debug logs during memory probes and indexing. - `--verbose`: emit debug logs during memory probes and indexing.
- `--index-mode auto|batch|direct`: override batch usage when indexing (`direct` favors speed; `batch` favors OpenAI Batch pricing).
- `--progress auto|line|log|none`: progress output mode (`log` prints updates even without a TTY).
+85
View File
@@ -0,0 +1,85 @@
---
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
## Daemon (background service)
Install a headless node host as a user service.
```bash
clawdbot node daemon 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 daemon status
clawdbot node daemon start
clawdbot node daemon stop
clawdbot node daemon restart
clawdbot node daemon uninstall
```
## 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)
+22 -3
View File
@@ -89,7 +89,26 @@ OAuth only covers chat/completions and does **not** satisfy embeddings for
memory search. When using a custom OpenAI-compatible endpoint, set memory search. When using a custom OpenAI-compatible endpoint, set
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`). `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
If you want to use a **custom OpenAI-compatible endpoint** (like Gemini, OpenRouter, or a proxy), If you want to use **Gemini embeddings** directly, set the provider to `gemini`:
```json5
agents: {
defaults: {
memorySearch: {
provider: "gemini",
model: "gemini-embedding-001", // default
remote: {
apiKey: "${GEMINI_API_KEY}"
}
}
}
}
```
Gemini uses `GEMINI_API_KEY` (or `models.providers.google.apiKey`). Override
`memorySearch.remote.baseUrl` to point at a custom Gemini-compatible endpoint.
If you want to use a **custom OpenAI-compatible endpoint** (like OpenRouter or a proxy),
you can use the `remote` configuration: you can use the `remote` configuration:
```json5 ```json5
@@ -99,8 +118,8 @@ agents: {
provider: "openai", provider: "openai",
model: "text-embedding-3-small", model: "text-embedding-3-small",
remote: { remote: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", baseUrl: "https://proxy.example/v1",
apiKey: "YOUR_GEMINI_API_KEY", apiKey: "YOUR_PROXY_KEY",
headers: { "X-Custom-Header": "value" } headers: { "X-Custom-Header": "value" }
} }
} }
+5
View File
@@ -59,6 +59,11 @@ It does **not** rotate on every request. The pinned profile is reused until:
Manual selection via `/model …@<profileId>` sets a **user override** for that session Manual selection via `/model …@<profileId>` sets a **user override** for that session
and is not auto‑rotated until a new session starts. and is not auto‑rotated until a new session starts.
Auto‑pinned profiles (selected by the session router) are treated as a **preference**:
they are tried first, but Clawdbot may rotate to another profile on rate limits/timeouts.
User‑pinned profiles stay locked to that profile; if it fails and model fallbacks
are configured, Clawdbot moves to the next model instead of switching profiles.
### Why OAuth can “look lost” ### Why OAuth can “look lost”
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile: If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
+18 -3
View File
@@ -54,8 +54,12 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook) - Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
- Node bridge runs: `node-<nodeId>` - Node bridge runs: `node-<nodeId>`
## Lifecyle ## Lifecycle
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message. - Reset policy: sessions are reused until they expire, and expiry is evaluated on the next inbound message.
- Daily reset: defaults to **4:00 AM local time on the gateway host**. A session is stale once its last update is earlier than the most recent daily reset time.
- Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
- Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility.
- Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset. - Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them. - Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse). - Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
@@ -93,7 +97,18 @@ Send these as standalone messages so they register.
identityLinks: { identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"] alice: ["telegram:123456789", "discord:987654321012345678"]
}, },
idleMinutes: 120, reset: {
// Defaults: mode=daily, atHour=4 (gateway host local time).
// If you also set idleMinutes, whichever expires first wins.
mode: "daily",
atHour: 4,
idleMinutes: 120
},
resetByType: {
thread: { mode: "daily", atHour: 4 },
dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 }
},
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json", store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
mainKey: "main", mainKey: "main",
+1
View File
@@ -77,6 +77,7 @@ What this does:
- Seeds the workspace files if missing: - Seeds the workspace files if missing:
`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`. `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`.
- Default identity: **C3‑PO** (protocol droid). - Default identity: **C3‑PO** (protocol droid).
- Skips channel providers in dev mode (`CLAWDBOT_SKIP_CHANNELS=1`).
Reset flow (fresh start): Reset flow (fresh start):
+2
View File
@@ -956,6 +956,8 @@
{ {
"group": "Automation & Hooks", "group": "Automation & Hooks",
"pages": [ "pages": [
"hooks",
"hooks/soul-evil",
"automation/auth-monitoring", "automation/auth-monitoring",
"automation/webhook", "automation/webhook",
"automation/gmail-pubsub", "automation/gmail-pubsub",
+14 -2
View File
@@ -46,8 +46,8 @@ When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
## Frames ## Frames
Client → Gateway: Client → Gateway:
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake) - `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake, skills.bins)
- `event`: node signals (voice transcript, agent request, chat subscribe) - `event`: node signals (voice transcript, agent request, chat subscribe, exec lifecycle)
Gateway → Client: Gateway → Client:
- `invoke` / `invoke-res`: node commands (`canvas.*`, `camera.*`, `screen.record`, - `invoke` / `invoke-res`: node commands (`canvas.*`, `camera.*`, `screen.record`,
@@ -57,6 +57,18 @@ Gateway → Client:
Exact allowlist is enforced in `src/gateway/server-bridge.ts`. Exact allowlist is enforced in `src/gateway/server-bridge.ts`.
## Exec lifecycle events
Nodes can emit `exec.started`, `exec.finished`, or `exec.denied` events to surface
system.run activity. These are mapped to system events in the gateway.
Payload fields (all optional unless noted):
- `sessionKey` (required): agent session to receive the system event.
- `runId`: unique exec id for grouping.
- `command`: raw or formatted command string.
- `exitCode`, `timedOut`, `success`, `output`: completion details (finished only).
- `reason`: denial reason (denied only).
## Tailnet usage ## Tailnet usage
- Bind the bridge to a tailnet IP: `bridge.bind: "tailnet"` in - Bind the bridge to a tailnet IP: `bridge.bind: "tailnet"` in
+7 -4
View File
@@ -146,7 +146,11 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
// Session behavior // Session behavior
session: { session: {
scope: "per-sender", scope: "per-sender",
idleMinutes: 60, reset: {
mode: "daily",
atHour: 4,
idleMinutes: 60
},
heartbeatIdleMinutes: 120, heartbeatIdleMinutes: 120,
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/default/sessions/sessions.json", store: "~/.clawdbot/agents/default/sessions/sessions.json",
@@ -257,10 +261,9 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
ackMaxChars: 300 ackMaxChars: 300
}, },
memorySearch: { memorySearch: {
provider: "openai", provider: "gemini",
model: "text-embedding-004", model: "gemini-embedding-001",
remote: { remote: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
apiKey: "${GEMINI_API_KEY}" apiKey: "${GEMINI_API_KEY}"
} }
}, },
+23 -7
View File
@@ -2416,7 +2416,7 @@ Notes:
### `session` ### `session`
Controls session scoping, idle expiry, reset triggers, and where the session store is written. Controls session scoping, reset policy, reset triggers, and where the session store is written.
```json5 ```json5
{ {
@@ -2426,7 +2426,16 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
identityLinks: { identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"] alice: ["telegram:123456789", "discord:987654321012345678"]
}, },
idleMinutes: 60, reset: {
mode: "daily",
atHour: 4,
idleMinutes: 60
},
resetByType: {
thread: { mode: "daily", atHour: 4 },
dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 }
},
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json // Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
// You can override with {agentId} templating: // You can override with {agentId} templating:
@@ -2437,12 +2446,12 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
// Max ping-pong reply turns between requester/target (0–5). // Max ping-pong reply turns between requester/target (0–5).
maxPingPongTurns: 5 maxPingPongTurns: 5
}, },
sendPolicy: { sendPolicy: {
rules: [ rules: [
{ action: "deny", match: { channel: "discord", chatType: "group" } } { action: "deny", match: { channel: "discord", chatType: "group" } }
], ],
default: "allow" default: "allow"
} }
} }
} }
``` ```
@@ -2456,6 +2465,13 @@ Fields:
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes). - `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`. - `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`. - Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
- `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host.
- `mode`: `daily` or `idle` (default: `daily` when `reset` is present).
- `atHour`: local hour (0-23) for the daily reset boundary.
- `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins.
- `resetByType`: per-session overrides for `dm`, `group`, and `thread`.
- If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility.
- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled).
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5). - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. - `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
+2 -2
View File
@@ -65,8 +65,8 @@ stronger isolation between agents, run them under separate OS users or separate
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac: If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
- Requires node pairing (approval + token). - Requires node pairing (approval + token).
- Controlled on the Mac via **Settings → "Node Run Commands"**: "Always Ask" (default), "Always Allow", or "Never". - Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
- If you don’t want remote execution, set the policy to "Never" and remove node pairing for that Mac. - If you don’t want remote execution, set security to **deny** and remove node pairing for that Mac.
## Dynamic skills (watcher / remote nodes) ## Dynamic skills (watcher / remote nodes)
+6 -2
View File
@@ -239,11 +239,15 @@ Known issue: When you send an image with ONLY a mention (no other text), WhatsAp
ls -la ~/.clawdbot/agents/<agentId>/sessions/ ls -la ~/.clawdbot/agents/<agentId>/sessions/
``` ```
**Check 2:** Is `idleMinutes` too short? **Check 2:** Is the reset window too short?
```json ```json
{ {
"session": { "session": {
"idleMinutes": 10080 // 7 days "reset": {
"mode": "daily",
"atHour": 4,
"idleMinutes": 10080 // 7 days
}
} }
} }
``` ```
+4 -1
View File
@@ -37,10 +37,11 @@ The hooks system allows you to:
### Bundled Hooks ### Bundled Hooks
Clawdbot ships with two bundled hooks that are automatically discovered: Clawdbot ships with three bundled hooks that are automatically discovered:
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new` - **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log` - **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
List available hooks: List available hooks:
@@ -511,6 +512,8 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
**Events**: `agent:bootstrap` **Events**: `agent:bootstrap`
**Docs**: [SOUL Evil Hook](/hooks/soul-evil)
**Output**: No files written; swaps happen in-memory only. **Output**: No files written; swaps happen in-memory only.
**Enable**: **Enable**:
+68
View File
@@ -0,0 +1,68 @@
---
summary: "SOUL Evil hook (swap SOUL.md with SOUL_EVIL.md)"
read_when:
- You want to enable or tune the SOUL Evil hook
- You want a purge window or random-chance persona swap
---
# SOUL Evil Hook
The SOUL Evil hook swaps the **injected** `SOUL.md` content with `SOUL_EVIL.md` during
a purge window or by random chance. It does **not** modify files on disk.
## How It Works
When `agent:bootstrap` runs, the hook can replace the `SOUL.md` content in memory
before the system prompt is assembled. If `SOUL_EVIL.md` is missing or empty,
Clawdbot logs a warning and keeps the normal `SOUL.md`.
Sub-agent runs do **not** include `SOUL.md` in their bootstrap files, so this hook
has no effect on sub-agents.
## Enable
```bash
clawdbot hooks enable soul-evil
```
Then set the config:
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"soul-evil": {
"enabled": true,
"file": "SOUL_EVIL.md",
"chance": 0.1,
"purge": { "at": "21:00", "duration": "15m" }
}
}
}
}
}
```
Create `SOUL_EVIL.md` in the agent workspace root (next to `SOUL.md`).
## Options
- `file` (string): alternate SOUL filename (default: `SOUL_EVIL.md`)
- `chance` (number 0–1): random chance per run to use `SOUL_EVIL.md`
- `purge.at` (HH:mm): daily purge start (24-hour clock)
- `purge.duration` (duration): window length (e.g. `30s`, `10m`, `1h`)
**Precedence:** purge window wins over chance.
**Timezone:** uses `agents.defaults.userTimezone` when set; otherwise host timezone.
## Notes
- No files are written or modified on disk.
- If `SOUL.md` is not in the bootstrap list, the hook does nothing.
## See Also
- [Hooks](/hooks)
+50 -3
View File
@@ -147,9 +147,10 @@ Notes:
- The permission prompt must be accepted on the Android device before the capability is advertised. - The permission prompt must be accepted on the Android device before the capability is advertised.
- Wi-Fi-only devices without telephony will not advertise `sms.send`. - Wi-Fi-only devices without telephony will not advertise `sms.send`.
## System commands (mac node) ## System commands (node host / mac node)
The macOS node exposes `system.run` and `system.notify`. The macOS node exposes `system.run` and `system.notify`. The headless node host
exposes `system.run` and `system.which`.
Examples: Examples:
@@ -163,12 +164,58 @@ Notes:
- `system.notify` respects notification permission state on the macOS app. - `system.notify` respects notification permission state on the macOS app.
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`. - `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`. - `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
- `system.run` is gated by the macOS app policy (Settings → "Node Run Commands"): "Always Ask" prompts per command, "Always Allow" runs without prompts, and "Never" disables the tool. Denied prompts return `SYSTEM_RUN_DENIED`; disabled returns `SYSTEM_RUN_DISABLED`. - On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
- On headless node host, `system.run` is gated by exec approvals (`~/.clawdbot/exec-approvals.json`).
## Exec node binding
When multiple nodes are available, you can bind exec to a specific node.
This sets the default node for `exec host=node` (and can be overridden per agent).
Global default:
```bash
clawdbot config set tools.exec.node "node-id-or-name"
```
Per-agent override:
```bash
clawdbot config get agents.list
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
```
Unset to allow any node:
```bash
clawdbot config unset tools.exec.node
clawdbot config unset agents.list[0].tools.exec.node
```
## Permissions map ## Permissions map
Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted). Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted).
## Headless node host (cross-platform)
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
bridge and exposes `system.run` / `system.which`. This is useful on Linux/Windows
or for running a minimal node alongside a server.
Start it:
```bash
clawdbot node start --host <gateway-host> --port 18790
```
Notes:
- Pairing is still required (the Gateway will show a node approval prompt).
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
(see [Exec approvals](/tools/exec-approvals)).
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
## Mac node mode ## Mac node mode
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac). - The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
+3 -11
View File
@@ -14,15 +14,7 @@ Before building the app, ensure you have the following installed:
1. **Xcode 26.2+**: Required for Swift development. 1. **Xcode 26.2+**: Required for Swift development.
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts. 2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
## 1. Initialize Submodules ## 1. Install Dependencies
Clawdbot depends on several submodules (like `Peekaboo`). You must initialize these recursively:
```bash
git submodule update --init --recursive
```
## 2. Install Dependencies
Install the project-wide dependencies: Install the project-wide dependencies:
@@ -30,7 +22,7 @@ Install the project-wide dependencies:
pnpm install pnpm install
``` ```
## 3. Build and Package the App ## 2. Build and Package the App
To build the macOS app and package it into `dist/Clawdbot.app`, run: To build the macOS app and package it into `dist/Clawdbot.app`, run:
@@ -42,7 +34,7 @@ If you don't have an Apple Developer ID certificate, the script will automatical
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section. > **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
## 4. Install the CLI ## 3. Install the CLI
The macOS app expects a global `clawdbot` CLI install to manage background tasks. The macOS app expects a global `clawdbot` CLI install to manage background tasks.
+1 -1
View File
@@ -2,7 +2,7 @@
summary: "PeekabooBridge integration for macOS UI automation" summary: "PeekabooBridge integration for macOS UI automation"
read_when: read_when:
- Hosting PeekabooBridge in Clawdbot.app - Hosting PeekabooBridge in Clawdbot.app
- Integrating Peekaboo as a submodule - Integrating Peekaboo via Swift Package Manager
- Changing PeekabooBridge protocol/paths - Changing PeekabooBridge protocol/paths
--- ---
# Peekaboo Bridge (macOS UI automation) # Peekaboo Bridge (macOS UI automation)
+18 -15
View File
@@ -54,29 +54,32 @@ The macOS app presents itself as a node. Common commands:
The node reports a `permissions` map so agents can decide what’s allowed. The node reports a `permissions` map so agents can decide what’s allowed.
## Node run policy + allowlist ## Exec approvals (system.run)
`system.run` is controlled by the macOS app **Node Run Commands** policy: `system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
Security + ask + allowlist are stored locally on the Mac in:
- `Always Ask`: prompt per command (default).
- `Always Allow`: run without prompts.
- `Never`: disable `system.run` (tool not advertised).
The policy + allowlist live on the Mac in:
``` ```
~/.clawdbot/macos-node.json ~/.clawdbot/exec-approvals.json
``` ```
Schema: Example:
```json ```json
{ {
"systemRun": { "version": 1,
"policy": "ask", "defaults": {
"allowlist": [ "security": "deny",
"[\"/bin/echo\",\"hello\"]" "ask": "on-miss"
] },
"agents": {
"main": {
"security": "allowlist",
"ask": "on-miss",
"allowlist": [
{ "pattern": "/opt/homebrew/bin/rg" }
]
}
} }
} }
``` ```
+4 -3
View File
@@ -29,6 +29,7 @@ read_when:
- **Runner:** headless system service; UI app hosts a Unix socket for approvals. - **Runner:** headless system service; UI app hosts a Unix socket for approvals.
- **Node identity:** use existing `nodeId`. - **Node identity:** use existing `nodeId`.
- **Socket auth:** Unix socket + token (cross-platform); split later if needed. - **Socket auth:** Unix socket + token (cross-platform); split later if needed.
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
## Key concepts ## Key concepts
### Host ### Host
@@ -168,9 +169,9 @@ If UI missing:
- Stored in the gateway in-memory queue (`enqueueSystemEvent`). - Stored in the gateway in-memory queue (`enqueueSystemEvent`).
### Event text ### Event text
- `Exec started (host=node, node=<id>, id=<runId>)` - `Exec started (node=<id>, id=<runId>)`
- `Exec finished (exit=<code>, tail=<...>)` - `Exec finished (node=<id>, id=<runId>, code=<code>)` + optional output tail
- `Exec denied (policy=<...>, reason=<...>)` - `Exec denied (node=<id>, id=<runId>, <reason>)`
### Transport ### Transport
Option A (recommended): Option A (recommended):
@@ -82,7 +82,8 @@ Each `sessionKey` points at a current `sessionId` (the transcript file that cont
Rules of thumb: Rules of thumb:
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`. - **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
- **Idle expiry** (`session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. - **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary.
- **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins.
Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`. Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`.
+5 -1
View File
@@ -160,7 +160,11 @@ Example:
session: { session: {
scope: "per-sender", scope: "per-sender",
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
idleMinutes: 10080 reset: {
mode: "daily",
atHour: 4,
idleMinutes: 10080
}
} }
} }
``` ```
+28 -5
View File
@@ -60,6 +60,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Remote gateways + nodes](#remote-gateways-nodes) - [Remote gateways + nodes](#remote-gateways-nodes)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes) - [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon) - [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
- [Can I run a headless node host without the macOS app?](#can-i-run-a-headless-node-host-without-the-macos-app)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config) - [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install) - [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac) - [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
@@ -405,7 +406,7 @@ You have three supported patterns:
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS. Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
**Option B - use a macOS node (no SSH).** **Option B - use a macOS node (no SSH).**
Run the Gateway on Linux, pair a macOS node (menubar app), and set **Node Run Commands** to "Always Ask" or "Always Allow" on the Mac. Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Always Ask", approving "Always Allow" in the prompt adds that command to the allowlist. Run the Gateway on Linux, pair a macOS node (menubar app), and configure **Exec approvals** (Settings → Exec approvals) to "Ask" or "Always Allow". Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Ask", selecting "Always Allow" in the prompt adds that command to the allowlist.
**Option C - proxy macOS binaries over SSH (advanced).** **Option C - proxy macOS binaries over SSH (advanced).**
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible. Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
@@ -742,6 +743,23 @@ to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes. A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
### Can I run a headless node host without the macOS app?
Yes. The headless node host is a **command-only** node that exposes `system.run` / `system.which`
without any UI. It has no screen/camera/notify support (use the macOS app for those).
Start it:
```bash
clawdbot node start --host <gateway-host> --port 18790
```
Notes:
- Pairing is still required (`clawdbot nodes pending` → `clawdbot nodes approve <requestId>`).
- Exec approvals still apply via `~/.clawdbot/exec-approvals.json`.
- If prompts are enabled but no companion UI is reachable, `askFallback` decides (default: deny).
Docs: [Node CLI](/cli/node), [Nodes](/nodes), [Exec approvals](/tools/exec-approvals).
### Is there an API / RPC way to apply config? ### Is there an API / RPC way to apply config?
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation. Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
@@ -880,14 +898,19 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce
### Do sessions reset automatically if I never send `/new`? ### Do sessions reset automatically if I never send `/new`?
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next** Yes. By default sessions reset daily at **4:00 AM local time** on the gateway host.
message starts a fresh session id for that chat key. This does not delete You can also add an idle window; when both daily and idle resets are configured,
transcripts — it just starts a new session. whichever expires first starts a new session id on the next message. This does
not delete transcripts — it just starts a new session.
```json5 ```json5
{ {
session: { session: {
idleMinutes: 240 reset: {
mode: "daily",
atHour: 4,
idleMinutes: 240
}
} }
} }
``` ```
+15 -5
View File
@@ -8,7 +8,7 @@ read_when:
# Exec approvals # Exec approvals
Exec approvals are the **companion app guardrail** for letting a sandboxed agent run Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run
commands on a real host (`gateway` or `node`). Think of it like a safety interlock: commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
commands are allowed only when policy + allowlist + (optional) user approval all agree. commands are allowed only when policy + allowlist + (optional) user approval all agree.
Exec approvals are **in addition** to tool policy and elevated gating. Exec approvals are **in addition** to tool policy and elevated gating.
@@ -20,11 +20,11 @@ resolved by the **ask fallback** (default: deny).
Exec approvals are enforced locally on the execution host: Exec approvals are enforced locally on the execution host:
- **gateway host** → `clawdbot` process on the gateway machine - **gateway host** → `clawdbot` process on the gateway machine
- **node host** → node runner (macOS companion app or headless node) - **node host** → node runner (macOS companion app or headless node host)
## Settings and storage ## Settings and storage
Approvals live in a local JSON file: Approvals live in a local JSON file on the execution host:
`~/.clawdbot/exec-approvals.json` `~/.clawdbot/exec-approvals.json`
@@ -97,8 +97,18 @@ Each allowlist entry tracks:
## Auto-allow skill CLIs ## Auto-allow skill CLIs
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
are treated as allowlisted (node hosts only). Disable this if you want strict are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
manual allowlists. gateway for the skill bin list. Disable this if you want strict manual allowlists.
## Control UI editing
Use the **Control UI → Nodes → Exec approvals** card to edit defaults, per‑agent
overrides, and allowlists. Pick a scope (Defaults or an agent), tweak the policy,
add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata
per pattern so you can keep the list tidy.
Note: the Control UI edits the approvals file on the **Gateway host**. For a
headless node host, edit its local `~/.clawdbot/exec-approvals.json` directly.
## Approval flow ## Approval flow
+21 -2
View File
@@ -30,7 +30,7 @@ Notes:
- `host` defaults to `sandbox`. - `host` defaults to `sandbox`.
- `elevated` is ignored when sandboxing is off (exec already runs on the host). - `elevated` is ignored when sandboxing is off (exec already runs on the host).
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`. - `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
- `node` requires a paired node (macOS companion app). - `node` requires a paired node (companion app or headless node host).
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one. - If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
## Config ## Config
@@ -41,7 +41,26 @@ Notes:
- `tools.exec.ask` (default: `on-miss`) - `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset) - `tools.exec.node` (default: unset)
## Exec approvals (macOS app) 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`.
Send `/exec` with no arguments to show the current values.
Example:
```
/exec host=gateway security=allowlist ask=on-miss node=mac-1
```
## Exec approvals (companion app / node host)
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host. Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow. See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
+1
View File
@@ -181,6 +181,7 @@ Notes:
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`. - If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`. - `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op). - `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
- `host=node` can target a macOS companion app or a headless node host (`clawdbot node start`).
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals). - gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
### `process` ### `process`
+1 -1
View File
@@ -187,7 +187,7 @@ Skills can also refresh mid-session when the skills watcher is enabled or when a
## Remote macOS nodes (Linux gateway) ## Remote macOS nodes (Linux gateway)
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Node Run Commands policy not set to "Never"), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`). If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Exec approvals security not set to `deny`), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`).
This relies on the node reporting its command support and on a bin probe via `system.run`. If the macOS node goes offline later, the skills remain visible; invocations may fail until the node reconnects. This relies on the node reporting its command support and on a bin probe via `system.run`. If the macOS node goes offline later, the skills remain visible; invocations may fail until the node reconnects.
+2 -1
View File
@@ -12,7 +12,7 @@ The host-only bash chat command uses `! <cmd>` (with `/bash <cmd>` as an alias).
There are two related systems: There are two related systems:
- **Commands**: standalone `/...` messages. - **Commands**: standalone `/...` messages.
- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/model`, `/queue`. - **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/exec`, `/model`, `/queue`.
- Directives are stripped from the message before the model sees it. - Directives are stripped from the message before the model sees it.
- In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings. - In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings.
- In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement. - In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement.
@@ -77,6 +77,7 @@ Text + native (when enabled):
- `/verbose on|full|off` (alias: `/v`) - `/verbose on|full|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only) - `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`) - `/elevated on|off` (alias: `/elev`)
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current)
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`) - `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) - `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
- `/bash <command>` (host-only; alias for `! <command>`; requires `commands.bash: true` + `tools.elevated` allowlists) - `/bash <command>` (host-only; alias for `! <command>`; requires `commands.bash: true` + `tools.elevated` allowlists)
+1
View File
@@ -36,6 +36,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`) - Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
- Skills: status, enable/disable, install, API key updates (`skills.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`)
- Nodes: list + caps (`node.list`) - Nodes: list + caps (`node.list`)
- Exec approvals: edit allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`) - Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
- Config: apply + restart with validation (`config.apply`) and wake the last active session - Config: apply + restart with validation (`config.apply`) and wake the last active session
- Config writes include a base-hash guard to prevent clobbering concurrent edits - Config writes include a base-hash guard to prevent clobbering concurrent edits
+3 -3
View File
@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { enqueueSystemEvent, formatAgentEnvelope, type ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { downloadBlueBubblesAttachment } from "./attachments.js"; import { downloadBlueBubblesAttachment } from "./attachments.js";
@@ -836,7 +836,7 @@ async function processMessage(
const fromLabel = message.isGroup const fromLabel = message.isGroup
? `group:${peerId}` ? `group:${peerId}`
: message.senderName || `user:${message.senderId}`; : message.senderName || `user:${message.senderId}`;
const body = formatAgentEnvelope({ const body = core.channel.reply.formatAgentEnvelope({
channel: "BlueBubbles", channel: "BlueBubbles",
from: fromLabel, from: fromLabel,
timestamp: message.timestamp, timestamp: message.timestamp,
@@ -1058,7 +1058,7 @@ async function processReaction(
const senderLabel = reaction.senderName || reaction.senderId; const senderLabel = reaction.senderName || reaction.senderId;
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : ""; const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`; const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`;
enqueueSystemEvent(text, { core.system.enqueueSystemEvent(text, {
sessionKey: route.sessionKey, sessionKey: route.sessionKey,
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`, contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
}); });
+16
View 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
View 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 { import {
applyAccountNameToChannelSection, applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount, buildChannelConfigSchema,
} from "./setup-helpers.js"; collectDiscordAuditChannelIds,
import { collectDiscordStatusIssues } from "./status-issues/discord.js"; collectDiscordStatusIssues,
import type { ChannelPlugin } from "./types.js"; DEFAULT_ACCOUNT_ID,
import { deleteAccountFromConfigSection,
discordOnboardingAdapter,
DiscordConfigSchema,
formatPairingApproveHint,
getChatChannelMeta,
listDiscordAccountIds,
listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig, listDiscordDirectoryPeersFromConfig,
} from "./directory-config.js"; looksLikeDiscordTargetId,
import { migrateBaseNameToDefaultAccount,
listDiscordDirectoryGroupsLive, normalizeAccountId,
listDiscordDirectoryPeersLive, normalizeDiscordMessagingTarget,
} from "../../discord/directory-live.js"; PAIRING_APPROVED_MESSAGE,
resolveDiscordAccount,
resolveDefaultDiscordAccountId,
resolveDiscordGroupRequireMention,
setAccountEnabledInConfigSection,
type ChannelMessageActionAdapter,
type ChannelPlugin,
type ResolvedDiscordAccount,
} from "clawdbot/plugin-sdk";
import { getDiscordRuntime } from "./runtime.js";
const meta = getChatChannelMeta("discord"); const meta = getChatChannelMeta("discord");
const discordMessageActions: ChannelMessageActionAdapter = {
listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions.listActions(ctx),
extractToolSend: (ctx) =>
getDiscordRuntime().channel.discord.messageActions.extractToolSend(ctx),
handleAction: async (ctx) =>
await getDiscordRuntime().channel.discord.messageActions.handleAction(ctx),
};
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = { export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
id: "discord", id: "discord",
meta: { meta: {
@@ -54,7 +48,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
idLabel: "discordUserId", idLabel: "discordUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""), normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""),
notifyApproval: async ({ id }) => { notifyApproval: async ({ id }) => {
await sendMessageDiscord(`user:${id}`, PAIRING_APPROVED_MESSAGE); await getDiscordRuntime().channel.discord.sendMessageDiscord(
`user:${id}`,
PAIRING_APPROVED_MESSAGE,
);
}, },
}, },
capabilities: { capabilities: {
@@ -165,8 +162,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
self: async () => null, self: async () => null,
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params), listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params), listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),
listPeersLive: async (params) => listDiscordDirectoryPeersLive(params), listPeersLive: async (params) =>
listGroupsLive: async (params) => listDiscordDirectoryGroupsLive(params), getDiscordRuntime().channel.discord.listDirectoryPeersLive(params),
listGroupsLive: async (params) =>
getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params),
}, },
resolver: { resolver: {
resolveTargets: async ({ cfg, accountId, inputs, kind }) => { resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
@@ -180,7 +179,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
})); }));
} }
if (kind === "group") { if (kind === "group") {
const resolved = await resolveDiscordChannelAllowlist({ token, entries: inputs }); const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({
token,
entries: inputs,
});
return resolved.map((entry) => ({ return resolved.map((entry) => ({
input: entry.input, input: entry.input,
resolved: entry.resolved, resolved: entry.resolved,
@@ -192,7 +194,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
note: entry.note, note: entry.note,
})); }));
} }
const resolved = await resolveDiscordUserAllowlist({ token, entries: inputs }); const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({
token,
entries: inputs,
});
return resolved.map((entry) => ({ return resolved.map((entry) => ({
input: entry.input, input: entry.input,
resolved: entry.resolved, resolved: entry.resolved,
@@ -274,7 +279,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
textChunkLimit: 2000, textChunkLimit: 2000,
pollMaxOptions: 10, pollMaxOptions: 10,
sendText: async ({ to, text, accountId, deps, replyToId }) => { sendText: async ({ to, text, accountId, deps, replyToId }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord; const send =
deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
const result = await send(to, text, { const result = await send(to, text, {
verbose: false, verbose: false,
replyTo: replyToId ?? undefined, replyTo: replyToId ?? undefined,
@@ -283,7 +289,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
return { channel: "discord", ...result }; return { channel: "discord", ...result };
}, },
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => { sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord; const send =
deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
const result = await send(to, text, { const result = await send(to, text, {
verbose: false, verbose: false,
mediaUrl, mediaUrl,
@@ -293,7 +300,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
return { channel: "discord", ...result }; return { channel: "discord", ...result };
}, },
sendPoll: async ({ to, poll, accountId }) => sendPoll: async ({ to, poll, accountId }) =>
await sendPollDiscord(to, poll, { await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
}), }),
}, },
@@ -317,7 +324,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
lastProbeAt: snapshot.lastProbeAt ?? null, lastProbeAt: snapshot.lastProbeAt ?? null,
}), }),
probeAccount: async ({ account, timeoutMs }) => probeAccount: async ({ account, timeoutMs }) =>
probeDiscord(account.token, timeoutMs, { includeApplication: true }), getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
includeApplication: true,
}),
auditAccount: async ({ account, timeoutMs, cfg }) => { auditAccount: async ({ account, timeoutMs, cfg }) => {
const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({ const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
cfg, cfg,
@@ -334,7 +343,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
elapsedMs: 0, elapsedMs: 0,
}; };
} }
const audit = await auditDiscordChannelPermissions({ const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({
token: botToken, token: botToken,
accountId: account.accountId, accountId: account.accountId,
channelIds, channelIds,
@@ -371,7 +380,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
const token = account.token.trim(); const token = account.token.trim();
let discordBotLabel = ""; let discordBotLabel = "";
try { try {
const probe = await probeDiscord(token, 2500, { const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, {
includeApplication: true, includeApplication: true,
}); });
const username = probe.ok ? probe.bot?.username?.trim() : null; const username = probe.ok ? probe.bot?.username?.trim() : null;
@@ -392,14 +401,12 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
); );
} }
} catch (err) { } catch (err) {
if (shouldLogVerbose()) { if (getDiscordRuntime().logging.shouldLogVerbose()) {
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
} }
} }
ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`); ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. return getDiscordRuntime().channel.discord.monitorDiscordProvider({
const { monitorDiscordProvider } = await import("../../discord/index.js");
return monitorDiscordProvider({
token, token,
accountId: account.accountId, accountId: account.accountId,
config: ctx.cfg, config: ctx.cfg,
+14
View 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
View 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
View 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 { import {
applyAccountNameToChannelSection, applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
getChatChannelMeta,
imessageOnboardingAdapter,
IMessageConfigSchema,
listIMessageAccountIds,
migrateBaseNameToDefaultAccount, migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js"; normalizeAccountId,
import type { ChannelPlugin } from "./types.js"; PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
resolveIMessageGroupRequireMention,
setAccountEnabledInConfigSection,
type ChannelPlugin,
type ResolvedIMessageAccount,
} from "clawdbot/plugin-sdk";
import { getIMessageRuntime } from "./runtime.js";
const meta = getChatChannelMeta("imessage"); const meta = getChatChannelMeta("imessage");
@@ -32,13 +28,17 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
id: "imessage", id: "imessage",
meta: { meta: {
...meta, ...meta,
aliases: ["imsg"],
showConfigured: false, showConfigured: false,
}, },
onboarding: imessageOnboardingAdapter, onboarding: imessageOnboardingAdapter,
pairing: { pairing: {
idLabel: "imessageSenderId", idLabel: "imessageSenderId",
notifyApproval: async ({ id }) => { notifyApproval: async ({ id }) => {
await sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE); await getIMessageRuntime().channel.imessage.sendMessageIMessage(
id,
PAIRING_APPROVED_MESSAGE,
);
}, },
}, },
capabilities: { capabilities: {
@@ -183,10 +183,10 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
}, },
outbound: { outbound: {
deliveryMode: "direct", deliveryMode: "direct",
chunker: chunkText, chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps }) => { sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage; const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({ const maxBytes = resolveChannelMediaMaxBytes({
cfg, cfg,
resolveChannelLimitMb: ({ cfg, accountId }) => resolveChannelLimitMb: ({ cfg, accountId }) =>
@@ -201,7 +201,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
return { channel: "imessage", ...result }; return { channel: "imessage", ...result };
}, },
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => { sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage; const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({ const maxBytes = resolveChannelMediaMaxBytes({
cfg, cfg,
resolveChannelLimitMb: ({ cfg, accountId }) => resolveChannelLimitMb: ({ cfg, accountId }) =>
@@ -251,7 +251,8 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
probe: snapshot.probe, probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null, lastProbeAt: snapshot.lastProbeAt ?? null,
}), }),
probeAccount: async ({ timeoutMs }) => probeIMessage(timeoutMs), probeAccount: async ({ timeoutMs }) =>
getIMessageRuntime().channel.imessage.probeIMessage(timeoutMs),
buildAccountSnapshot: ({ account, runtime, probe }) => ({ buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId, accountId: account.accountId,
name: account.name, name: account.name,
@@ -282,9 +283,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
ctx.log?.info( ctx.log?.info(
`[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`, `[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`,
); );
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. return getIMessageRuntime().channel.imessage.monitorIMessageProvider({
const { monitorIMessageProvider } = await import("../../imessage/index.js");
return monitorIMessageProvider({
accountId: account.accountId, accountId: account.accountId,
config: ctx.cfg, config: ctx.cfg,
runtime: ctx.runtime, runtime: ctx.runtime,
+14
View 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,22 @@
import { describe, expect, it } from "vitest"; import os from "node:os";
import { beforeEach, describe, expect, it } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "./types.js"; import type { CoreConfig } from "./types.js";
import { matrixPlugin } from "./channel.js"; import { matrixPlugin } from "./channel.js";
import { setMatrixRuntime } from "./runtime.js";
describe("matrix directory", () => { describe("matrix directory", () => {
beforeEach(() => {
setMatrixRuntime({
state: {
resolveStateDir: () => os.tmpdir(),
},
} as PluginRuntime);
});
it("lists peers and groups from config", async () => { it("lists peers and groups from config", async () => {
const cfg = { const cfg = {
channels: { channels: {
+5 -3
View File
@@ -15,7 +15,7 @@ import type {
RoomTopicEventContent, RoomTopicEventContent,
} from "matrix-js-sdk/lib/@types/state_events.js"; } from "matrix-js-sdk/lib/@types/state_events.js";
import { loadConfig } from "clawdbot/plugin-sdk"; import { getMatrixRuntime } from "../runtime.js";
import type { CoreConfig } from "../types.js"; import type { CoreConfig } from "../types.js";
import { getActiveMatrixClient } from "./active-client.js"; import { getActiveMatrixClient } from "./active-client.js";
import { import {
@@ -74,12 +74,14 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<M
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT); const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
if (shouldShareClient) { if (shouldShareClient) {
const client = await resolveSharedMatrixClient({ const client = await resolveSharedMatrixClient({
cfg: loadConfig() as CoreConfig, cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
timeoutMs: opts.timeoutMs, timeoutMs: opts.timeoutMs,
}); });
return { client, stopOnDone: false }; return { client, stopOnDone: false };
} }
const auth = await resolveMatrixAuth({ cfg: loadConfig() as CoreConfig }); const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
});
const client = await createMatrixClient({ const client = await createMatrixClient({
homeserver: auth.homeserver, homeserver: auth.homeserver,
userId: auth.userId, userId: auth.userId,
+3 -3
View File
@@ -1,7 +1,7 @@
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk"; import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
import { loadConfig } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "../types.js"; import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../runtime.js";
export type MatrixResolvedConfig = { export type MatrixResolvedConfig = {
homeserver: string; homeserver: string;
@@ -46,7 +46,7 @@ function clean(value?: string): string {
} }
export function resolveMatrixConfig( export function resolveMatrixConfig(
cfg: CoreConfig = loadConfig() as CoreConfig, cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig { ): MatrixResolvedConfig {
const matrix = cfg.channels?.matrix ?? {}; const matrix = cfg.channels?.matrix ?? {};
@@ -75,7 +75,7 @@ export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig; cfg?: CoreConfig;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
}): Promise<MatrixAuth> { }): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (loadConfig() as CoreConfig); const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env; const env = params?.env ?? process.env;
const resolved = resolveMatrixConfig(cfg, env); const resolved = resolveMatrixConfig(cfg, env);
if (!resolved.homeserver) { if (!resolved.homeserver) {
+5 -3
View File
@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { resolveStateDir } from "clawdbot/plugin-sdk"; import { getMatrixRuntime } from "../runtime.js";
export type MatrixStoredCredentials = { export type MatrixStoredCredentials = {
homeserver: string; homeserver: string;
@@ -16,9 +16,11 @@ const CREDENTIALS_FILENAME = "credentials.json";
export function resolveMatrixCredentialsDir( export function resolveMatrixCredentialsDir(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
stateDir: string = resolveStateDir(env, os.homedir), stateDir?: string,
): string { ): string {
return path.join(stateDir, "credentials", "matrix"); const resolvedStateDir =
stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
return path.join(resolvedStateDir, "credentials", "matrix");
} }
export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string { export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string {
+3 -2
View File
@@ -3,7 +3,8 @@ import path from "node:path";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { runCommandWithTimeout, type RuntimeEnv } from "clawdbot/plugin-sdk"; import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
const MATRIX_SDK_PACKAGE = "matrix-js-sdk"; const MATRIX_SDK_PACKAGE = "matrix-js-sdk";
@@ -40,7 +41,7 @@ export async function ensureMatrixSdkInstalled(params: {
? ["pnpm", "install"] ? ["pnpm", "install"]
: ["npm", "install", "--omit=dev", "--silent"]; : ["npm", "install", "--omit=dev", "--silent"];
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`); params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
const result = await runCommandWithTimeout(command, { const result = await getMatrixRuntime().system.runCommandWithTimeout(command, {
cwd: root, cwd: root,
timeoutMs: 300_000, timeoutMs: 300_000,
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" }, env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
@@ -1,8 +1,9 @@
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk"; import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk";
import { RoomMemberEvent } from "matrix-js-sdk"; import { RoomMemberEvent } from "matrix-js-sdk";
import { danger, logVerbose, type RuntimeEnv } from "clawdbot/plugin-sdk"; import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "../../types.js"; import type { CoreConfig } from "../../types.js";
import { getMatrixRuntime } from "../../runtime.js";
export function registerMatrixAutoJoin(params: { export function registerMatrixAutoJoin(params: {
client: MatrixClient; client: MatrixClient;
@@ -10,6 +11,11 @@ export function registerMatrixAutoJoin(params: {
runtime: RuntimeEnv; runtime: RuntimeEnv;
}) { }) {
const { client, cfg, runtime } = params; const { client, cfg, runtime } = params;
const core = getMatrixRuntime();
const logVerbose = (message: string) => {
if (!core.logging.shouldLogVerbose()) return;
runtime.log?.(message);
};
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always"; const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? []; const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
@@ -36,7 +42,7 @@ export function registerMatrixAutoJoin(params: {
await client.joinRoom(roomId); await client.joinRoom(roomId);
logVerbose(`matrix: joined room ${roomId}`); logVerbose(`matrix: joined room ${roomId}`);
} catch (err) { } catch (err) {
runtime.error?.(danger(`matrix: failed to join room ${roomId}: ${String(err)}`)); runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`);
} }
}); });
} }
+53 -69
View File
@@ -3,34 +3,9 @@ import { EventType, RelationType, RoomEvent } from "matrix-js-sdk";
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js"; import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
import { import {
buildMentionRegexes,
chunkMarkdownText,
createReplyDispatcherWithTyping,
danger,
dispatchReplyFromConfig,
enqueueSystemEvent,
finalizeInboundContext,
formatAgentEnvelope,
formatAllowlistMatchMeta, formatAllowlistMatchMeta,
getChildLogger,
hasControlCommand,
loadConfig,
logVerbose,
mergeAllowlist, mergeAllowlist,
matchesMentionPatterns,
readChannelAllowFromStore,
recordSessionMetaFromInbound,
resolveAgentRoute,
resolveCommandAuthorizedFromAuthorizers,
resolveEffectiveMessagesConfig,
resolveHumanDelayConfig,
resolveStorePath,
resolveTextChunkLimit,
shouldHandleTextCommands,
shouldLogVerbose,
summarizeMapping, summarizeMapping,
updateLastRoute,
upsertChannelPairingRequest,
type ReplyPayload, type ReplyPayload,
type RuntimeEnv, type RuntimeEnv,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
@@ -61,6 +36,7 @@ import { deliverMatrixReplies } from "./replies.js";
import { resolveMatrixRoomConfig } from "./rooms.js"; import { resolveMatrixRoomConfig } from "./rooms.js";
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js"; import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
import { resolveMatrixTargets } from "../../resolve-targets.js"; import { resolveMatrixTargets } from "../../resolve-targets.js";
import { getMatrixRuntime } from "../../runtime.js";
export type MonitorMatrixOpts = { export type MonitorMatrixOpts = {
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
@@ -76,7 +52,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
if (isBunRuntime()) { if (isBunRuntime()) {
throw new Error("Matrix provider requires Node (bun runtime not supported)"); throw new Error("Matrix provider requires Node (bun runtime not supported)");
} }
let cfg = loadConfig() as CoreConfig; const core = getMatrixRuntime();
let cfg = core.config.loadConfig() as CoreConfig;
if (cfg.channels?.matrix?.enabled === false) return; if (cfg.channels?.matrix?.enabled === false) return;
const runtime: RuntimeEnv = opts.runtime ?? { const runtime: RuntimeEnv = opts.runtime ?? {
@@ -207,8 +184,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}); });
setActiveMatrixClient(client); setActiveMatrixClient(client);
const mentionRegexes = buildMentionRegexes(cfg); const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
const logger = getChildLogger({ module: "matrix-auto-reply" }); const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
const logVerboseMessage = (message: string) => {
if (core.logging.shouldLogVerbose()) {
logger.debug(message);
}
};
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true; const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
@@ -219,8 +201,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const dmEnabled = dmConfig?.enabled ?? true; const dmEnabled = dmConfig?.enabled ?? true;
const dmPolicyRaw = dmConfig?.policy ?? "pairing"; const dmPolicyRaw = dmConfig?.policy ?? "pairing";
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw; const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
const allowFrom = dmConfig?.allowFrom ?? []; const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix");
const textLimit = resolveTextChunkLimit(cfg, "matrix");
const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB; const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024; const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
const startupMs = Date.now(); const startupMs = Date.now();
@@ -306,22 +287,22 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}`; }`;
if (roomConfigInfo.config && !roomConfigInfo.allowed) { if (roomConfigInfo.config && !roomConfigInfo.allowed) {
logVerbose(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
return; return;
} }
if (groupPolicy === "allowlist") { if (groupPolicy === "allowlist") {
if (!roomConfigInfo.allowlistConfigured) { if (!roomConfigInfo.allowlistConfigured) {
logVerbose(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
return; return;
} }
if (!roomConfigInfo.config) { if (!roomConfigInfo.config) {
logVerbose(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
return; return;
} }
} }
const senderName = room.getMember(senderId)?.name ?? senderId; const senderName = room.getMember(senderId)?.name ?? senderId;
const storeAllowFrom = await readChannelAllowFromStore("matrix").catch(() => []); const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]); const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
if (isDirectMessage) { if (isDirectMessage) {
@@ -335,13 +316,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (!allowMatch.allowed) { if (!allowMatch.allowed) {
if (dmPolicy === "pairing") { if (dmPolicy === "pairing") {
const { code, created } = await upsertChannelPairingRequest({ const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "matrix", channel: "matrix",
id: senderId, id: senderId,
meta: { name: senderName }, meta: { name: senderName },
}); });
if (created) { if (created) {
logVerbose( logVerboseMessage(
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
); );
try { try {
@@ -358,12 +339,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
{ client }, { client },
); );
} catch (err) { } catch (err) {
logVerbose(`matrix pairing reply failed for ${senderId}: ${String(err)}`); logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
} }
} }
} }
if (dmPolicy !== "pairing") { if (dmPolicy !== "pairing") {
logVerbose( logVerboseMessage(
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, `matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
); );
} }
@@ -379,7 +360,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
userName: senderName, userName: senderName,
}); });
if (!userMatch.allowed) { if (!userMatch.allowed) {
logVerbose( logVerboseMessage(
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta( `matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
userMatch, userMatch,
)})`, )})`,
@@ -388,7 +369,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
} }
} }
if (isRoom) { if (isRoom) {
logVerbose(`matrix: allow room ${roomId} (${roomMatchMeta})`); logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
} }
const rawBody = content.body.trim(); const rawBody = content.body.trim();
@@ -416,7 +397,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
maxBytes: mediaMaxBytes, maxBytes: mediaMaxBytes,
}); });
} catch (err) { } catch (err) {
logVerbose(`matrix: media download failed: ${String(err)}`); logVerboseMessage(`matrix: media download failed: ${String(err)}`);
} }
} }
@@ -429,7 +410,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
text: bodyText, text: bodyText,
mentionRegexes, mentionRegexes,
}); });
const allowTextCommands = shouldHandleTextCommands({ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg, cfg,
surface: "matrix", surface: "matrix",
}); });
@@ -439,14 +420,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
userId: senderId, userId: senderId,
userName: senderName, userName: senderName,
}); });
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
], ],
}); });
if (isRoom && allowTextCommands && hasControlCommand(bodyText, cfg) && !commandAuthorized) { if (
logVerbose(`matrix: drop control command from unauthorized sender ${senderId}`); isRoom &&
allowTextCommands &&
core.channel.text.hasControlCommand(bodyText, cfg) &&
!commandAuthorized
) {
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
return; return;
} }
const shouldRequireMention = isRoom const shouldRequireMention = isRoom
@@ -465,7 +451,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
!wasMentioned && !wasMentioned &&
!hasExplicitMention && !hasExplicitMention &&
commandAuthorized && commandAuthorized &&
hasControlCommand(bodyText); core.channel.text.hasControlCommand(bodyText);
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
logger.info({ roomId, reason: "no-mention" }, "skipping room message"); logger.info({ roomId, reason: "no-mention" }, "skipping room message");
return; return;
@@ -482,14 +468,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
const body = formatAgentEnvelope({ const body = core.channel.reply.formatAgentEnvelope({
channel: "Matrix", channel: "Matrix",
from: envelopeFrom, from: envelopeFrom,
timestamp: event.getTs() ?? undefined, timestamp: event.getTs() ?? undefined,
body: textWithId, body: textWithId,
}); });
const route = resolveAgentRoute({ const route = core.channel.routing.resolveAgentRoute({
cfg, cfg,
channel: "matrix", channel: "matrix",
peer: { peer: {
@@ -499,7 +485,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}); });
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined; const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
const ctxPayload = finalizeInboundContext({ const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body, Body: body,
RawBody: bodyText, RawBody: bodyText,
CommandBody: bodyText, CommandBody: bodyText,
@@ -531,10 +517,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
OriginatingTo: `room:${roomId}`, OriginatingTo: `room:${roomId}`,
}); });
const storePath = resolveStorePath(cfg.session?.store, { const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId, agentId: route.agentId,
}); });
void recordSessionMetaFromInbound({ void core.channel.session.recordSessionMetaFromInbound({
storePath, storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey, sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload, ctx: ctxPayload,
@@ -546,7 +532,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}); });
if (isDirectMessage) { if (isDirectMessage) {
await updateLastRoute({ await core.channel.session.updateLastRoute({
storePath, storePath,
sessionKey: route.mainSessionKey, sessionKey: route.mainSessionKey,
channel: "matrix", channel: "matrix",
@@ -556,10 +542,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}); });
} }
if (shouldLogVerbose()) { const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n"); logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
logVerbose(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
}
const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
@@ -577,20 +561,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}; };
if (shouldAckReaction() && messageId) { if (shouldAckReaction() && messageId) {
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => { reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
logVerbose(`matrix react failed for room ${roomId}: ${String(err)}`); logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
}); });
} }
const replyTarget = ctxPayload.To; const replyTarget = ctxPayload.To;
if (!replyTarget) { if (!replyTarget) {
runtime.error?.(danger("matrix: missing reply target")); runtime.error?.("matrix: missing reply target");
return; return;
} }
let didSendReply = false; let didSendReply = false;
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId), humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => { deliver: async (payload) => {
await deliverMatrixReplies({ await deliverMatrixReplies({
replies: [payload], replies: [payload],
@@ -604,13 +588,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
didSendReply = true; didSendReply = true;
}, },
onError: (err, info) => { onError: (err, info) => {
runtime.error?.(danger(`matrix ${info.kind} reply failed: ${String(err)}`)); runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
}, },
onReplyStart: () => sendTypingMatrix(roomId, true, undefined, client).catch(() => {}), onReplyStart: () => sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
onIdle: () => sendTypingMatrix(roomId, false, undefined, client).catch(() => {}), onIdle: () => sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
}); });
const { queuedFinal, counts } = await dispatchReplyFromConfig({ const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload, ctx: ctxPayload,
cfg, cfg,
dispatcher, dispatcher,
@@ -622,19 +606,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
markDispatchIdle(); markDispatchIdle();
if (!queuedFinal) return; if (!queuedFinal) return;
didSendReply = true; didSendReply = true;
if (shouldLogVerbose()) { const finalCount = counts.final;
const finalCount = counts.final; logVerboseMessage(
logVerbose(`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`); `matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
} );
if (didSendReply) { if (didSendReply) {
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160); const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, { core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, {
sessionKey: route.sessionKey, sessionKey: route.sessionKey,
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`, contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
}); });
} }
} catch (err) { } catch (err) {
runtime.error?.(danger(`matrix handler failed: ${String(err)}`)); runtime.error?.(`matrix handler failed: ${String(err)}`);
} }
}; };
@@ -1,6 +1,6 @@
import type { MatrixClient } from "matrix-js-sdk"; import type { MatrixClient } from "matrix-js-sdk";
import { saveMediaBuffer } from "clawdbot/plugin-sdk"; import { getMatrixRuntime } from "../../runtime.js";
async function fetchMatrixMediaBuffer(params: { async function fetchMatrixMediaBuffer(params: {
client: MatrixClient; client: MatrixClient;
@@ -49,7 +49,12 @@ export async function downloadMatrixMedia(params: {
}); });
if (!fetched) return null; if (!fetched) return null;
const headerType = fetched.headerType ?? params.contentType ?? undefined; const headerType = fetched.headerType ?? params.contentType ?? undefined;
const saved = await saveMediaBuffer(fetched.buffer, headerType, "inbound", params.maxBytes); const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
fetched.buffer,
headerType,
"inbound",
params.maxBytes,
);
return { return {
path: saved.path, path: saved.path,
contentType: saved.contentType, contentType: saved.contentType,
@@ -1,6 +1,6 @@
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js"; import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
import { matchesMentionPatterns } from "clawdbot/plugin-sdk"; import { getMatrixRuntime } from "../../runtime.js";
export function resolveMentions(params: { export function resolveMentions(params: {
content: RoomMessageEventContent; content: RoomMessageEventContent;
@@ -17,6 +17,9 @@ export function resolveMentions(params: {
const wasMentioned = const wasMentioned =
Boolean(mentions?.room) || Boolean(mentions?.room) ||
(params.userId ? mentionedUsers.has(params.userId) : false) || (params.userId ? mentionedUsers.has(params.userId) : false) ||
matchesMentionPatterns(params.text ?? "", params.mentionRegexes); getMatrixRuntime().channel.mentions.matchesMentionPatterns(
params.text ?? "",
params.mentionRegexes,
);
return { wasMentioned, hasExplicitMention: Boolean(mentions) }; return { wasMentioned, hasExplicitMention: Boolean(mentions) };
} }
@@ -1,13 +1,8 @@
import type { MatrixClient } from "matrix-js-sdk"; import type { MatrixClient } from "matrix-js-sdk";
import { import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
chunkMarkdownText,
danger,
logVerbose,
type ReplyPayload,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
import { sendMessageMatrix } from "../send.js"; import { sendMessageMatrix } from "../send.js";
import { getMatrixRuntime } from "../../runtime.js";
export async function deliverMatrixReplies(params: { export async function deliverMatrixReplies(params: {
replies: ReplyPayload[]; replies: ReplyPayload[];
@@ -18,6 +13,12 @@ export async function deliverMatrixReplies(params: {
replyToMode: "off" | "first" | "all"; replyToMode: "off" | "first" | "all";
threadId?: string; threadId?: string;
}): Promise<void> { }): Promise<void> {
const core = getMatrixRuntime();
const logVerbose = (message: string) => {
if (core.logging.shouldLogVerbose()) {
params.runtime.log?.(message);
}
};
const chunkLimit = Math.min(params.textLimit, 4000); const chunkLimit = Math.min(params.textLimit, 4000);
let hasReplied = false; let hasReplied = false;
for (const reply of params.replies) { for (const reply of params.replies) {
@@ -27,7 +28,7 @@ export async function deliverMatrixReplies(params: {
logVerbose("matrix reply has audioAsVoice without media/text; skipping"); logVerbose("matrix reply has audioAsVoice without media/text; skipping");
continue; continue;
} }
params.runtime.error?.(danger("matrix reply missing text/media")); params.runtime.error?.("matrix reply missing text/media");
continue; continue;
} }
const replyToIdRaw = reply.replyToId?.trim(); const replyToIdRaw = reply.replyToId?.trim();
@@ -42,7 +43,7 @@ export async function deliverMatrixReplies(params: {
Boolean(id) && (params.replyToMode === "all" || !hasReplied); Boolean(id) && (params.replyToMode === "all" || !hasReplied);
if (mediaList.length === 0) { if (mediaList.length === 0) {
for (const chunk of chunkMarkdownText(reply.text ?? "", chunkLimit)) { for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) {
const trimmed = chunk.trim(); const trimmed = chunk.trim();
if (!trimmed) continue; if (!trimmed) continue;
await sendMessageMatrix(params.roomId, trimmed, { await sendMessageMatrix(params.roomId, trimmed, {
+32 -15
View File
@@ -1,5 +1,8 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import { setMatrixRuntime } from "../runtime.js";
vi.mock("matrix-js-sdk", () => ({ vi.mock("matrix-js-sdk", () => ({
EventType: { EventType: {
Direct: "m.direct", Direct: "m.direct",
@@ -18,21 +21,33 @@ vi.mock("matrix-js-sdk", () => ({
}, },
})); }));
vi.mock("clawdbot/plugin-sdk", () => ({ const loadWebMediaMock = vi.fn().mockResolvedValue({
loadConfig: () => ({}), buffer: Buffer.from("media"),
resolveTextChunkLimit: () => 4000, fileName: "photo.png",
chunkMarkdownText: (text: string) => (text ? [text] : []), contentType: "image/png",
loadWebMedia: vi.fn().mockResolvedValue({ kind: "image",
buffer: Buffer.from("media"), });
fileName: "photo.png", const getImageMetadataMock = vi.fn().mockResolvedValue(null);
contentType: "image/png", const resizeToJpegMock = vi.fn();
kind: "image",
}), const runtimeStub = {
mediaKindFromMime: () => "image", config: {
isVoiceCompatibleAudio: () => false, loadConfig: () => ({}),
getImageMetadata: vi.fn().mockResolvedValue(null), },
resizeToJpeg: vi.fn(), media: {
})); loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
mediaKindFromMime: () => "image",
isVoiceCompatibleAudio: () => false,
getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args),
resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args),
},
channel: {
text: {
resolveTextChunkLimit: () => 4000,
chunkMarkdownText: (text: string) => (text ? [text] : []),
},
},
} as unknown as PluginRuntime;
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix; let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
@@ -50,11 +65,13 @@ const makeClient = () => {
describe("sendMessageMatrix media", () => { describe("sendMessageMatrix media", () => {
beforeAll(async () => { beforeAll(async () => {
setMatrixRuntime(runtimeStub);
({ sendMessageMatrix } = await import("./send.js")); ({ sendMessageMatrix } = await import("./send.js"));
}); });
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
setMatrixRuntime(runtimeStub);
}); });
it("uploads media with url payloads", async () => { it("uploads media with url payloads", async () => {
+13 -21
View File
@@ -5,17 +5,8 @@ import type {
ReactionEventContent, ReactionEventContent,
} from "matrix-js-sdk/lib/@types/events.js"; } from "matrix-js-sdk/lib/@types/events.js";
import { import type { PollInput } from "clawdbot/plugin-sdk";
chunkMarkdownText, import { getMatrixRuntime } from "../runtime.js";
getImageMetadata,
isVoiceCompatibleAudio,
loadConfig,
loadWebMedia,
mediaKindFromMime,
type PollInput,
resolveTextChunkLimit,
resizeToJpeg,
} from "clawdbot/plugin-sdk";
import { getActiveMatrixClient } from "./active-client.js"; import { getActiveMatrixClient } from "./active-client.js";
import { import {
createMatrixClient, createMatrixClient,
@@ -29,6 +20,7 @@ import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
import type { CoreConfig } from "../types.js"; import type { CoreConfig } from "../types.js";
const MATRIX_TEXT_LIMIT = 4000; const MATRIX_TEXT_LIMIT = 4000;
const getCore = () => getMatrixRuntime();
type MatrixDirectAccountData = AccountDataEvents[EventType.Direct]; type MatrixDirectAccountData = AccountDataEvents[EventType.Direct];
@@ -65,7 +57,7 @@ function ensureNodeRuntime() {
} }
function resolveMediaMaxBytes(): number | undefined { function resolveMediaMaxBytes(): number | undefined {
const cfg = loadConfig() as CoreConfig; const cfg = getCore().config.loadConfig() as CoreConfig;
if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") { if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024; return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
} }
@@ -224,7 +216,7 @@ function resolveMatrixMsgType(
contentType?: string, contentType?: string,
fileName?: string, fileName?: string,
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File { ): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
const kind = mediaKindFromMime(contentType ?? ""); const kind = getCore().media.mediaKindFromMime(contentType ?? "");
switch (kind) { switch (kind) {
case "image": case "image":
return MsgType.Image; return MsgType.Image;
@@ -243,7 +235,7 @@ function resolveMatrixVoiceDecision(opts: {
fileName?: string; fileName?: string;
}): { useVoice: boolean } { }): { useVoice: boolean } {
if (!opts.wantsVoice) return { useVoice: false }; if (!opts.wantsVoice) return { useVoice: false };
if (isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) { if (getCore().media.isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
return { useVoice: true }; return { useVoice: true };
} }
return { useVoice: false }; return { useVoice: false };
@@ -256,19 +248,19 @@ async function prepareImageInfo(params: {
buffer: Buffer; buffer: Buffer;
client: MatrixClient; client: MatrixClient;
}): Promise<MatrixImageInfo | undefined> { }): Promise<MatrixImageInfo | undefined> {
const meta = await getImageMetadata(params.buffer).catch(() => null); const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null);
if (!meta) return undefined; if (!meta) return undefined;
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height }; const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
const maxDim = Math.max(meta.width, meta.height); const maxDim = Math.max(meta.width, meta.height);
if (maxDim > THUMBNAIL_MAX_SIDE) { if (maxDim > THUMBNAIL_MAX_SIDE) {
try { try {
const thumbBuffer = await resizeToJpeg({ const thumbBuffer = await getCore().media.resizeToJpeg({
buffer: params.buffer, buffer: params.buffer,
maxSide: THUMBNAIL_MAX_SIDE, maxSide: THUMBNAIL_MAX_SIDE,
quality: THUMBNAIL_QUALITY, quality: THUMBNAIL_QUALITY,
withoutEnlargement: true, withoutEnlargement: true,
}); });
const thumbMeta = await getImageMetadata(thumbBuffer).catch(() => null); const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null);
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, { const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
type: "image/jpeg", type: "image/jpeg",
name: "thumbnail.jpg", name: "thumbnail.jpg",
@@ -352,10 +344,10 @@ export async function sendMessageMatrix(
}); });
try { try {
const roomId = await resolveMatrixRoomId(client, to); const roomId = await resolveMatrixRoomId(client, to);
const cfg = loadConfig(); const cfg = getCore().config.loadConfig();
const textLimit = resolveTextChunkLimit(cfg, "matrix"); const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit); const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
const threadId = normalizeThreadId(opts.threadId); const threadId = normalizeThreadId(opts.threadId);
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId); const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
const sendContent = (content: RoomMessageEventContent) => const sendContent = (content: RoomMessageEventContent) =>
@@ -364,7 +356,7 @@ export async function sendMessageMatrix(
let lastMessageId = ""; let lastMessageId = "";
if (opts.mediaUrl) { if (opts.mediaUrl) {
const maxBytes = resolveMediaMaxBytes(); const maxBytes = resolveMediaMaxBytes();
const media = await loadWebMedia(opts.mediaUrl, maxBytes); const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
const contentUri = await uploadFile(client, media.buffer, { const contentUri = await uploadFile(client, media.buffer, {
contentType: media.contentType, contentType: media.contentType,
filename: media.fileName, filename: media.fileName,
+3 -9
View File
@@ -1,11 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import {
createMemoryGetTool,
createMemorySearchTool,
registerMemoryCli,
} from "clawdbot/plugin-sdk";
const memoryCorePlugin = { const memoryCorePlugin = {
id: "memory-core", id: "memory-core",
name: "Memory (Core)", name: "Memory (Core)",
@@ -14,11 +8,11 @@ const memoryCorePlugin = {
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
api.registerTool( api.registerTool(
(ctx) => { (ctx) => {
const memorySearchTool = createMemorySearchTool({ const memorySearchTool = api.runtime.tools.createMemorySearchTool({
config: ctx.config, config: ctx.config,
agentSessionKey: ctx.sessionKey, agentSessionKey: ctx.sessionKey,
}); });
const memoryGetTool = createMemoryGetTool({ const memoryGetTool = api.runtime.tools.createMemoryGetTool({
config: ctx.config, config: ctx.config,
agentSessionKey: ctx.sessionKey, agentSessionKey: ctx.sessionKey,
}); });
@@ -30,7 +24,7 @@ const memoryCorePlugin = {
api.registerCli( api.registerCli(
({ program }) => { ({ program }) => {
registerMemoryCli(program); api.runtime.tools.registerMemoryCli(program);
}, },
{ commands: ["memory"] }, { commands: ["memory"] },
); );
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@clawdbot/memory-core",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot core memory search plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
},
"dependencies": {
"clawdbot": "workspace:*"
}
}
+102
View 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
View 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
View 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;
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@clawdbot/memory",
"version": "0.0.1",
"type": "module",
"description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture",
"dependencies": {
"@sinclair/typebox": "0.34.47",
"@lancedb/lancedb": "^0.15.0",
"openai": "^4.77.0"
},
"clawdbot": {
"extensions": ["./index.ts"]
}
}
+2
View File
@@ -1,12 +1,14 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { msteamsPlugin } from "./src/channel.js"; import { msteamsPlugin } from "./src/channel.js";
import { setMSTeamsRuntime } from "./src/runtime.js";
const plugin = { const plugin = {
id: "msteams", id: "msteams",
name: "Microsoft Teams", name: "Microsoft Teams",
description: "Microsoft Teams channel plugin (Bot Framework)", description: "Microsoft Teams channel plugin (Bot Framework)",
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setMSTeamsRuntime(api.runtime);
api.registerChannel({ plugin: msteamsPlugin }); api.registerChannel({ plugin: msteamsPlugin });
}, },
}; };
+14 -4
View File
@@ -1,15 +1,24 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import { setMSTeamsRuntime } from "./runtime.js";
const detectMimeMock = vi.fn(async () => "image/png"); const detectMimeMock = vi.fn(async () => "image/png");
const saveMediaBufferMock = vi.fn(async () => ({ const saveMediaBufferMock = vi.fn(async () => ({
path: "/tmp/saved.png", path: "/tmp/saved.png",
contentType: "image/png", contentType: "image/png",
})); }));
vi.mock("clawdbot/plugin-sdk", () => ({ const runtimeStub = {
detectMime: (...args: unknown[]) => detectMimeMock(...args), media: {
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args), detectMime: (...args: unknown[]) => detectMimeMock(...args),
})); },
channel: {
media: {
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
},
},
} as unknown as PluginRuntime;
describe("msteams attachments", () => { describe("msteams attachments", () => {
const load = async () => { const load = async () => {
@@ -19,6 +28,7 @@ describe("msteams attachments", () => {
beforeEach(() => { beforeEach(() => {
detectMimeMock.mockClear(); detectMimeMock.mockClear();
saveMediaBufferMock.mockClear(); saveMediaBufferMock.mockClear();
setMSTeamsRuntime(runtimeStub);
}); });
describe("buildMSTeamsAttachmentPlaceholder", () => { describe("buildMSTeamsAttachmentPlaceholder", () => {
@@ -1,4 +1,4 @@
import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk"; import { getMSTeamsRuntime } from "../runtime.js";
import { import {
extractInlineImageCandidates, extractInlineImageCandidates,
inferPlaceholder, inferPlaceholder,
@@ -141,7 +141,7 @@ export async function downloadMSTeamsImageAttachments(params: {
if (inline.kind !== "data") continue; if (inline.kind !== "data") continue;
if (inline.data.byteLength > params.maxBytes) continue; if (inline.data.byteLength > params.maxBytes) continue;
try { try {
const saved = await saveMediaBuffer( const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
inline.data, inline.data,
inline.contentType, inline.contentType,
"inbound", "inbound",
@@ -167,12 +167,12 @@ export async function downloadMSTeamsImageAttachments(params: {
if (!res.ok) continue; if (!res.ok) continue;
const buffer = Buffer.from(await res.arrayBuffer()); const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.byteLength > params.maxBytes) continue; if (buffer.byteLength > params.maxBytes) continue;
const mime = await detectMime({ const mime = await getMSTeamsRuntime().media.detectMime({
buffer, buffer,
headerMime: res.headers.get("content-type"), headerMime: res.headers.get("content-type"),
filePath: candidate.fileHint ?? candidate.url, filePath: candidate.fileHint ?? candidate.url,
}); });
const saved = await saveMediaBuffer( const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
buffer, buffer,
mime ?? candidate.contentTypeHint, mime ?? candidate.contentTypeHint,
"inbound", "inbound",
+3 -3
View File
@@ -1,4 +1,4 @@
import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk"; import { getMSTeamsRuntime } from "../runtime.js";
import { downloadMSTeamsImageAttachments } from "./download.js"; import { downloadMSTeamsImageAttachments } from "./download.js";
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js"; import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
import type { import type {
@@ -154,13 +154,13 @@ async function downloadGraphHostedImages(params: {
continue; continue;
} }
if (buffer.byteLength > params.maxBytes) continue; if (buffer.byteLength > params.maxBytes) continue;
const mime = await detectMime({ const mime = await getMSTeamsRuntime().media.detectMime({
buffer, buffer,
headerMime: item.contentType ?? undefined, headerMime: item.contentType ?? undefined,
}); });
if (mime && !mime.startsWith("image/")) continue; if (mime && !mime.startsWith("image/")) continue;
try { try {
const saved = await saveMediaBuffer( const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
buffer, buffer,
mime ?? item.contentType ?? undefined, mime ?? item.contentType ?? undefined,
"inbound", "inbound",
@@ -2,12 +2,29 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import type { StoredConversationReference } from "./conversation-store.js"; import type { StoredConversationReference } from "./conversation-store.js";
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js"; import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
import { setMSTeamsRuntime } from "./runtime.js";
const runtimeStub = {
state: {
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
const override = env.CLAWDBOT_STATE_DIR?.trim();
if (override) return override;
const resolvedHome = homedir ? homedir() : os.homedir();
return path.join(resolvedHome, ".clawdbot");
},
},
} as unknown as PluginRuntime;
describe("msteams conversation store (fs)", () => { describe("msteams conversation store (fs)", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
});
it("filters and prunes expired entries (but keeps legacy ones)", async () => { it("filters and prunes expired entries (but keeps legacy ones)", async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-msteams-store-")); const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-msteams-store-"));
+23 -2
View File
@@ -1,14 +1,35 @@
import { describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { SILENT_REPLY_TOKEN } from "clawdbot/plugin-sdk"; import { SILENT_REPLY_TOKEN, type PluginRuntime } from "clawdbot/plugin-sdk";
import type { StoredConversationReference } from "./conversation-store.js"; import type { StoredConversationReference } from "./conversation-store.js";
import { import {
type MSTeamsAdapter, type MSTeamsAdapter,
renderReplyPayloadsToMessages, renderReplyPayloadsToMessages,
sendMSTeamsMessages, sendMSTeamsMessages,
} from "./messenger.js"; } from "./messenger.js";
import { setMSTeamsRuntime } from "./runtime.js";
const runtimeStub = {
channel: {
text: {
chunkMarkdownText: (text: string, limit: number) => {
if (!text) return [];
if (limit <= 0 || text.length <= limit) return [text];
const chunks: string[] = [];
for (let index = 0; index < text.length; index += limit) {
chunks.push(text.slice(index, index + limit));
}
return chunks;
},
},
},
} as unknown as PluginRuntime;
describe("msteams messenger", () => { describe("msteams messenger", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
});
describe("renderReplyPayloadsToMessages", () => { describe("renderReplyPayloadsToMessages", () => {
it("filters silent replies", () => { it("filters silent replies", () => {
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], { const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
+2 -2
View File
@@ -1,5 +1,4 @@
import { import {
chunkMarkdownText,
isSilentReplyText, isSilentReplyText,
type MSTeamsReplyStyle, type MSTeamsReplyStyle,
type ReplyPayload, type ReplyPayload,
@@ -7,6 +6,7 @@ import {
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import type { StoredConversationReference } from "./conversation-store.js"; import type { StoredConversationReference } from "./conversation-store.js";
import { classifyMSTeamsSendError } from "./errors.js"; import { classifyMSTeamsSendError } from "./errors.js";
import { getMSTeamsRuntime } from "./runtime.js";
type SendContext = { type SendContext = {
sendActivity: (textOrActivity: string | object) => Promise<unknown>; sendActivity: (textOrActivity: string | object) => Promise<unknown>;
@@ -108,7 +108,7 @@ function pushTextMessages(
) { ) {
if (!text) return; if (!text) return;
if (opts.chunkText) { if (opts.chunkText) {
for (const chunk of chunkMarkdownText(text, opts.chunkLimit)) { for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
const trimmed = chunk.trim(); const trimmed = chunk.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue; if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
out.push(trimmed); out.push(trimmed);
+1 -2
View File
@@ -1,5 +1,4 @@
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
import { danger } from "clawdbot/plugin-sdk";
import type { MSTeamsConversationStore } from "./conversation-store.js"; import type { MSTeamsConversationStore } from "./conversation-store.js";
import type { MSTeamsAdapter } from "./messenger.js"; import type { MSTeamsAdapter } from "./messenger.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js"; import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
@@ -42,7 +41,7 @@ export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
try { try {
await handleTeamsMessage(context as MSTeamsTurnContext); await handleTeamsMessage(context as MSTeamsTurnContext);
} catch (err) { } catch (err) {
deps.runtime.error?.(danger(`msteams handler failed: ${String(err)}`)); deps.runtime.error?.(`msteams handler failed: ${String(err)}`);
} }
await next(); await next();
}); });
@@ -1,25 +1,10 @@
import { import {
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
clearHistoryEntries, clearHistoryEntries,
createInboundDebouncer,
danger,
DEFAULT_GROUP_HISTORY_LIMIT, DEFAULT_GROUP_HISTORY_LIMIT,
readChannelAllowFromStore,
recordSessionMetaFromInbound,
recordPendingHistoryEntry, recordPendingHistoryEntry,
resolveAgentRoute,
resolveCommandAuthorizedFromAuthorizers,
resolveInboundDebounceMs,
resolveMentionGating, resolveMentionGating,
resolveStorePath,
dispatchReplyFromConfig,
finalizeInboundContext,
formatAgentEnvelope,
formatAllowlistMatchMeta, formatAllowlistMatchMeta,
hasControlCommand,
logVerbose,
shouldLogVerbose,
upsertChannelPairingRequest,
type HistoryEntry, type HistoryEntry,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
@@ -50,6 +35,7 @@ import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js"; import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
import type { MSTeamsTurnContext } from "../sdk-types.js"; import type { MSTeamsTurnContext } from "../sdk-types.js";
import { resolveMSTeamsInboundMedia } from "./inbound-media.js"; import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
import { getMSTeamsRuntime } from "../runtime.js";
export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const { const {
@@ -64,6 +50,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
pollStore, pollStore,
log, log,
} = deps; } = deps;
const core = getMSTeamsRuntime();
const logVerboseMessage = (message: string) => {
if (core.logging.shouldLogVerbose()) {
log.debug(message);
}
};
const msteamsCfg = cfg.channels?.msteams; const msteamsCfg = cfg.channels?.msteams;
const historyLimit = Math.max( const historyLimit = Math.max(
0, 0,
@@ -72,7 +64,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
DEFAULT_GROUP_HISTORY_LIMIT, DEFAULT_GROUP_HISTORY_LIMIT,
); );
const conversationHistories = new Map<string, HistoryEntry[]>(); const conversationHistories = new Map<string, HistoryEntry[]>();
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "msteams" }); const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
cfg,
channel: "msteams",
});
type MSTeamsDebounceEntry = { type MSTeamsDebounceEntry = {
context: MSTeamsTurnContext; context: MSTeamsTurnContext;
@@ -126,7 +121,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const senderName = from.name ?? from.id; const senderName = from.name ?? from.id;
const senderId = from.aadObjectId ?? from.id; const senderId = from.aadObjectId ?? from.id;
const storedAllowFrom = await readChannelAllowFromStore("msteams").catch(() => []); const storedAllowFrom = await core.channel.pairing
.readAllowFromStore("msteams")
.catch(() => []);
const useAccessGroups = cfg.commands?.useAccessGroups !== false; const useAccessGroups = cfg.commands?.useAccessGroups !== false;
// Check DM policy for direct messages. // Check DM policy for direct messages.
@@ -151,7 +148,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
if (!allowMatch.allowed) { if (!allowMatch.allowed) {
if (dmPolicy === "pairing") { if (dmPolicy === "pairing") {
const request = await upsertChannelPairingRequest({ const request = await core.channel.pairing.upsertPairingRequest({
channel: "msteams", channel: "msteams",
id: senderId, id: senderId,
meta: { name: senderName }, meta: { name: senderName },
@@ -254,15 +251,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
senderId, senderId,
senderName, senderName,
}); });
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
], ],
}); });
if (hasControlCommand(text, cfg) && !commandAuthorized) { if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) {
logVerbose(`msteams: drop control command from unauthorized sender ${senderId}`); logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`);
return; return;
} }
@@ -329,7 +326,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
: `msteams:group:${conversationId}`; : `msteams:group:${conversationId}`;
const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`; const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`;
const route = resolveAgentRoute({ const route = core.channel.routing.resolveAgentRoute({
cfg, cfg,
channel: "msteams", channel: "msteams",
peer: { peer: {
@@ -343,7 +340,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
? `Teams DM from ${senderName}` ? `Teams DM from ${senderName}`
: `Teams message in ${conversationType} from ${senderName}`; : `Teams message in ${conversationType} from ${senderName}`;
enqueueSystemEvent(`${inboundLabel}: ${preview}`, { core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey: route.sessionKey, sessionKey: route.sessionKey,
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`, contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
}); });
@@ -409,7 +406,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const mediaPayload = buildMSTeamsMediaPayload(mediaList); const mediaPayload = buildMSTeamsMediaPayload(mediaList);
const envelopeFrom = isDirectMessage ? senderName : conversationType; const envelopeFrom = isDirectMessage ? senderName : conversationType;
const body = formatAgentEnvelope({ const body = core.channel.reply.formatAgentEnvelope({
channel: "Teams", channel: "Teams",
from: envelopeFrom, from: envelopeFrom,
timestamp, timestamp,
@@ -425,7 +422,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
limit: historyLimit, limit: historyLimit,
currentMessage: combinedBody, currentMessage: combinedBody,
formatEntry: (entry) => formatEntry: (entry) =>
formatAgentEnvelope({ core.channel.reply.formatAgentEnvelope({
channel: "Teams", channel: "Teams",
from: conversationType, from: conversationType,
timestamp: entry.timestamp, timestamp: entry.timestamp,
@@ -434,7 +431,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
}); });
} }
const ctxPayload = finalizeInboundContext({ const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
RawBody: rawBody, RawBody: rawBody,
CommandBody: rawBody, CommandBody: rawBody,
@@ -458,20 +455,18 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
...mediaPayload, ...mediaPayload,
}); });
const storePath = resolveStorePath(cfg.session?.store, { const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId, agentId: route.agentId,
}); });
void recordSessionMetaFromInbound({ void core.channel.session.recordSessionMetaFromInbound({
storePath, storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey, sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload, ctx: ctxPayload,
}).catch((err) => { }).catch((err) => {
logVerbose(`msteams: failed updating session meta: ${String(err)}`); logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
}); });
if (shouldLogVerbose()) { logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
logVerbose(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
}
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({ const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
cfg, cfg,
@@ -493,7 +488,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
log.info("dispatching to agent", { sessionKey: route.sessionKey }); log.info("dispatching to agent", { sessionKey: route.sessionKey });
try { try {
const { queuedFinal, counts } = await dispatchReplyFromConfig({ const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload, ctx: ctxPayload,
cfg, cfg,
dispatcher, dispatcher,
@@ -513,18 +508,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
} }
return; return;
} }
if (shouldLogVerbose()) { const finalCount = counts.final;
const finalCount = counts.final; logVerboseMessage(
logVerbose( `msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`, );
);
}
if (isRoomish && historyKey && historyLimit > 0) { if (isRoomish && historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: conversationHistories, historyKey }); clearHistoryEntries({ historyMap: conversationHistories, historyKey });
} }
} catch (err) { } catch (err) {
log.error("dispatch failed", { error: String(err) }); log.error("dispatch failed", { error: String(err) });
runtime.error?.(danger(`msteams dispatch failed: ${String(err)}`)); runtime.error?.(`msteams dispatch failed: ${String(err)}`);
try { try {
await context.sendActivity( await context.sendActivity(
`⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`, `⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -535,7 +528,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
} }
}; };
const inboundDebouncer = createInboundDebouncer<MSTeamsDebounceEntry>({ const inboundDebouncer = core.channel.debounce.createInboundDebouncer<MSTeamsDebounceEntry>({
debounceMs: inboundDebounceMs, debounceMs: inboundDebounceMs,
buildKey: (entry) => { buildKey: (entry) => {
const conversationId = normalizeMSTeamsConversationId( const conversationId = normalizeMSTeamsConversationId(
@@ -549,7 +542,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
shouldDebounce: (entry) => { shouldDebounce: (entry) => {
if (!entry.text.trim()) return false; if (!entry.text.trim()) return false;
if (entry.attachments.length > 0) return false; if (entry.attachments.length > 0) return false;
return !hasControlCommand(entry.text, cfg); return !core.channel.text.hasControlCommand(entry.text, cfg);
}, },
onFlush: async (entries) => { onFlush: async (entries) => {
const last = entries.at(-1); const last = entries.at(-1);
@@ -579,7 +572,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
}); });
}, },
onError: (err) => { onError: (err) => {
runtime.error?.(danger(`msteams debounce flush failed: ${String(err)}`)); runtime.error?.(`msteams debounce flush failed: ${String(err)}`);
}, },
}); });
+4 -5
View File
@@ -1,8 +1,6 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { import {
getChildLogger,
mergeAllowlist, mergeAllowlist,
resolveTextChunkLimit,
summarizeMapping, summarizeMapping,
type ClawdbotConfig, type ClawdbotConfig,
type RuntimeEnv, type RuntimeEnv,
@@ -19,8 +17,7 @@ import {
} from "./resolve-allowlist.js"; } from "./resolve-allowlist.js";
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js"; import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js"; import { resolveMSTeamsCredentials } from "./token.js";
import { getMSTeamsRuntime } from "./runtime.js";
const log = getChildLogger({ name: "msteams" });
export type MonitorMSTeamsOpts = { export type MonitorMSTeamsOpts = {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
@@ -38,6 +35,8 @@ export type MonitorMSTeamsResult = {
export async function monitorMSTeamsProvider( export async function monitorMSTeamsProvider(
opts: MonitorMSTeamsOpts, opts: MonitorMSTeamsOpts,
): Promise<MonitorMSTeamsResult> { ): Promise<MonitorMSTeamsResult> {
const core = getMSTeamsRuntime();
const log = core.logging.getChildLogger({ name: "msteams" });
let cfg = opts.cfg; let cfg = opts.cfg;
let msteamsCfg = cfg.channels?.msteams; let msteamsCfg = cfg.channels?.msteams;
if (!msteamsCfg?.enabled) { if (!msteamsCfg?.enabled) {
@@ -197,7 +196,7 @@ export async function monitorMSTeamsProvider(
}; };
const port = msteamsCfg.webhook?.port ?? 3978; const port = msteamsCfg.webhook?.port ?? 3978;
const textLimit = resolveTextChunkLimit(cfg, "msteams"); const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "msteams");
const MB = 1024 * 1024; const MB = 1024 * 1024;
const agentDefaults = cfg.agents?.defaults; const agentDefaults = cfg.agents?.defaults;
const mediaMaxBytes = const mediaMaxBytes =
+3 -2
View File
@@ -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 { createMSTeamsPollStoreFs } from "./polls.js";
import { getMSTeamsRuntime } from "./runtime.js";
import { sendMessageMSTeams, sendPollMSTeams } from "./send.js"; import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
export const msteamsOutbound: ChannelOutboundAdapter = { export const msteamsOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: chunkMarkdownText, chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
textChunkLimit: 4000, textChunkLimit: 4000,
pollMaxOptions: 12, pollMaxOptions: 12,
sendText: async ({ cfg, to, text, deps }) => { sendText: async ({ cfg, to, text, deps }) => {
+18 -1
View File
@@ -2,11 +2,28 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js"; 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", () => { describe("msteams polls", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
});
it("builds poll cards with fallback text", () => { it("builds poll cards with fallback text", () => {
const card = buildMSTeamsPollCard({ const card = buildMSTeamsPollCard({
question: "Lunch?", question: "Lunch?",
+13 -12
View File
@@ -1,11 +1,7 @@
import { import type {
createReplyDispatcherWithTyping, ClawdbotConfig,
danger, MSTeamsReplyStyle,
resolveEffectiveMessagesConfig, RuntimeEnv,
resolveHumanDelayConfig,
type ClawdbotConfig,
type MSTeamsReplyStyle,
type RuntimeEnv,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import type { StoredConversationReference } from "./conversation-store.js"; import type { StoredConversationReference } from "./conversation-store.js";
import { import {
@@ -20,6 +16,7 @@ import {
} from "./messenger.js"; } from "./messenger.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js"; import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import type { MSTeamsTurnContext } from "./sdk-types.js"; import type { MSTeamsTurnContext } from "./sdk-types.js";
import { getMSTeamsRuntime } from "./runtime.js";
export function createMSTeamsReplyDispatcher(params: { export function createMSTeamsReplyDispatcher(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
@@ -34,6 +31,7 @@ export function createMSTeamsReplyDispatcher(params: {
textLimit: number; textLimit: number;
onSentMessageIds?: (ids: string[]) => void; onSentMessageIds?: (ids: string[]) => void;
}) { }) {
const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => { const sendTypingIndicator = async () => {
try { try {
await params.context.sendActivities([{ type: "typing" }]); await params.context.sendActivities([{ type: "typing" }]);
@@ -42,9 +40,12 @@ export function createMSTeamsReplyDispatcher(params: {
} }
}; };
return createReplyDispatcherWithTyping({ return core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(params.cfg, params.agentId).responsePrefix, responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(
humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId), params.cfg,
params.agentId,
).responsePrefix,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
deliver: async (payload) => { deliver: async (payload) => {
const messages = renderReplyPayloadsToMessages([payload], { const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit, textChunkLimit: params.textLimit,
@@ -74,7 +75,7 @@ export function createMSTeamsReplyDispatcher(params: {
const classification = classifyMSTeamsSendError(err); const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification); const hint = formatMSTeamsSendErrorHint(classification);
params.runtime.error?.( 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", { params.log.error("reply failed", {
kind: info.kind, kind: info.kind,
+14
View 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;
}
+5 -4
View File
@@ -1,5 +1,4 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import type { getChildLogger as getChildLoggerFn } from "clawdbot/plugin-sdk";
import type { import type {
MSTeamsConversationStore, MSTeamsConversationStore,
StoredConversationReference, StoredConversationReference,
@@ -9,8 +8,10 @@ import type { MSTeamsAdapter } from "./messenger.js";
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js"; import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js"; import { resolveMSTeamsCredentials } from "./token.js";
let _log: ReturnType<typeof getChildLoggerFn> | undefined; type GetChildLogger = PluginRuntime["logging"]["getChildLogger"];
const getLog = async (): Promise<ReturnType<typeof getChildLoggerFn>> => {
let _log: ReturnType<GetChildLogger> | undefined;
const getLog = async (): Promise<ReturnType<GetChildLogger>> => {
if (_log) return _log; if (_log) return _log;
const { getChildLogger } = await import("../logging.js"); const { getChildLogger } = await import("../logging.js");
_log = getChildLogger({ name: "msteams:send" }); _log = getChildLogger({ name: "msteams:send" });
+4 -2
View File
@@ -1,6 +1,6 @@
import path from "node:path"; import path from "node:path";
import { resolveStateDir } from "clawdbot/plugin-sdk"; import { getMSTeamsRuntime } from "./runtime.js";
export type MSTeamsStorePathOptions = { export type MSTeamsStorePathOptions = {
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
@@ -15,6 +15,8 @@ export function resolveMSTeamsStorePath(params: MSTeamsStorePathOptions): string
if (params.stateDir) return path.join(params.stateDir, params.filename); if (params.stateDir) return path.join(params.stateDir, params.filename);
const env = params.env ?? process.env; 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); return path.join(stateDir, params.filename);
} }
+16
View 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
View 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 { import {
applyAccountNameToChannelSection, applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
getChatChannelMeta,
listSignalAccountIds,
looksLikeSignalTargetId,
migrateBaseNameToDefaultAccount, migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js"; normalizeAccountId,
import type { ChannelPlugin } from "./types.js"; 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"); const meta = getChatChannelMeta("signal");
@@ -39,7 +36,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
idLabel: "signalNumber", idLabel: "signalNumber",
normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""), normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""),
notifyApproval: async ({ id }) => { notifyApproval: async ({ id }) => {
await sendMessageSignal(id, PAIRING_APPROVED_MESSAGE); await getSignalRuntime().channel.signal.sendMessageSignal(id, PAIRING_APPROVED_MESSAGE);
}, },
}, },
capabilities: { capabilities: {
@@ -199,10 +196,10 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
}, },
outbound: { outbound: {
deliveryMode: "direct", deliveryMode: "direct",
chunker: chunkText, chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit),
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps }) => { sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal; const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({ const maxBytes = resolveChannelMediaMaxBytes({
cfg, cfg,
resolveChannelLimitMb: ({ cfg, accountId }) => resolveChannelLimitMb: ({ cfg, accountId }) =>
@@ -217,7 +214,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
return { channel: "signal", ...result }; return { channel: "signal", ...result };
}, },
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => { sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal; const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({ const maxBytes = resolveChannelMediaMaxBytes({
cfg, cfg,
resolveChannelLimitMb: ({ cfg, accountId }) => resolveChannelLimitMb: ({ cfg, accountId }) =>
@@ -266,7 +263,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
}), }),
probeAccount: async ({ account, timeoutMs }) => { probeAccount: async ({ account, timeoutMs }) => {
const baseUrl = account.baseUrl; const baseUrl = account.baseUrl;
return await probeSignal(baseUrl, timeoutMs); return await getSignalRuntime().channel.signal.probeSignal(baseUrl, timeoutMs);
}, },
buildAccountSnapshot: ({ account, runtime, probe }) => ({ buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId, accountId: account.accountId,
@@ -292,8 +289,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
}); });
ctx.log?.info(`[${account.accountId}] starting provider (${account.baseUrl})`); ctx.log?.info(`[${account.accountId}] starting provider (${account.baseUrl})`);
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
const { monitorSignalProvider } = await import("../../signal/index.js"); return getSignalRuntime().channel.signal.monitorSignalProvider({
return monitorSignalProvider({
accountId: account.accountId, accountId: account.accountId,
config: ctx.cfg, config: ctx.cfg,
runtime: ctx.runtime, runtime: ctx.runtime,
+14
View File
@@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setSignalRuntime(next: PluginRuntime) {
runtime = next;
}
export function getSignalRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Signal runtime not initialized");
}
return runtime;
}
+16
View File
@@ -0,0 +1,16 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { slackPlugin } from "./src/channel.js";
import { setSlackRuntime } from "./src/runtime.js";
const plugin = {
id: "slack",
name: "Slack",
description: "Slack channel plugin",
register(api: ClawdbotPluginApi) {
setSlackRuntime(api.runtime);
api.registerChannel({ plugin: slackPlugin });
},
};
export default plugin;
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@clawdbot/slack",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Slack channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
}
}
@@ -1,43 +1,34 @@
import { createActionGate, readNumberParam, readStringParam } from "../../agents/tools/common.js";
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
import { loadConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import {
listEnabledSlackAccounts,
listSlackAccountIds,
type ResolvedSlackAccount,
resolveDefaultSlackAccountId,
resolveSlackAccount,
} from "../../slack/accounts.js";
import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js";
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
import { probeSlack } from "../../slack/probe.js";
import { sendMessageSlack } from "../../slack/send.js";
import { getChatChannelMeta } from "../registry.js";
import { SlackConfigSchema } from "../../config/zod-schema.providers-core.js";
import { buildChannelConfigSchema } from "./config-schema.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "./config-helpers.js";
import { resolveSlackGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
import { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./normalize/slack.js";
import { slackOnboardingAdapter } from "./onboarding/slack.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import { import {
applyAccountNameToChannelSection, applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount, buildChannelConfigSchema,
} from "./setup-helpers.js"; createActionGate,
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js"; DEFAULT_ACCOUNT_ID,
import { deleteAccountFromConfigSection,
formatPairingApproveHint,
getChatChannelMeta,
listEnabledSlackAccounts,
listSlackAccountIds,
listSlackDirectoryGroupsFromConfig, listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig, listSlackDirectoryPeersFromConfig,
} from "./directory-config.js"; looksLikeSlackTargetId,
import { migrateBaseNameToDefaultAccount,
listSlackDirectoryGroupsLive, normalizeAccountId,
listSlackDirectoryPeersLive, normalizeSlackMessagingTarget,
} from "../../slack/directory-live.js"; PAIRING_APPROVED_MESSAGE,
readNumberParam,
readStringParam,
resolveDefaultSlackAccountId,
resolveSlackAccount,
resolveSlackGroupRequireMention,
setAccountEnabledInConfigSection,
slackOnboardingAdapter,
SlackConfigSchema,
type ChannelMessageActionName,
type ChannelPlugin,
type ResolvedSlackAccount,
} from "clawdbot/plugin-sdk";
import { getSlackRuntime } from "./runtime.js";
const meta = getChatChannelMeta("slack"); const meta = getChatChannelMeta("slack");
@@ -64,7 +55,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
idLabel: "slackUserId", idLabel: "slackUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""),
notifyApproval: async ({ id }) => { notifyApproval: async ({ id }) => {
const cfg = loadConfig(); const cfg = getSlackRuntime().config.loadConfig();
const account = resolveSlackAccount({ const account = resolveSlackAccount({
cfg, cfg,
accountId: DEFAULT_ACCOUNT_ID, accountId: DEFAULT_ACCOUNT_ID,
@@ -73,11 +64,11 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
const botToken = account.botToken?.trim(); const botToken = account.botToken?.trim();
const tokenOverride = token && token !== botToken ? token : undefined; const tokenOverride = token && token !== botToken ? token : undefined;
if (tokenOverride) { if (tokenOverride) {
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE, { await getSlackRuntime().channel.slack.sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE, {
token: tokenOverride, token: tokenOverride,
}); });
} else { } else {
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE); await getSlackRuntime().channel.slack.sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE);
} }
}, },
}, },
@@ -197,8 +188,9 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
self: async () => null, self: async () => null,
listPeers: async (params) => listSlackDirectoryPeersFromConfig(params), listPeers: async (params) => listSlackDirectoryPeersFromConfig(params),
listGroups: async (params) => listSlackDirectoryGroupsFromConfig(params), listGroups: async (params) => listSlackDirectoryGroupsFromConfig(params),
listPeersLive: async (params) => listSlackDirectoryPeersLive(params), listPeersLive: async (params) => getSlackRuntime().channel.slack.listDirectoryPeersLive(params),
listGroupsLive: async (params) => listSlackDirectoryGroupsLive(params), listGroupsLive: async (params) =>
getSlackRuntime().channel.slack.listDirectoryGroupsLive(params),
}, },
resolver: { resolver: {
resolveTargets: async ({ cfg, accountId, inputs, kind }) => { resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
@@ -212,7 +204,10 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
})); }));
} }
if (kind === "group") { if (kind === "group") {
const resolved = await resolveSlackChannelAllowlist({ token, entries: inputs }); const resolved = await getSlackRuntime().channel.slack.resolveChannelAllowlist({
token,
entries: inputs,
});
return resolved.map((entry) => ({ return resolved.map((entry) => ({
input: entry.input, input: entry.input,
resolved: entry.resolved, resolved: entry.resolved,
@@ -221,7 +216,10 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
note: entry.archived ? "archived" : undefined, note: entry.archived ? "archived" : undefined,
})); }));
} }
const resolved = await resolveSlackUserAllowlist({ token, entries: inputs }); const resolved = await getSlackRuntime().channel.slack.resolveUserAllowlist({
token,
entries: inputs,
});
return resolved.map((entry) => ({ return resolved.map((entry) => ({
input: entry.input, input: entry.input,
resolved: entry.resolved, resolved: entry.resolved,
@@ -240,10 +238,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
const isActionEnabled = (key: string, defaultValue = true) => { const isActionEnabled = (key: string, defaultValue = true) => {
for (const account of accounts) { for (const account of accounts) {
const gate = createActionGate( const gate = createActionGate(
(account.actions ?? cfg.channels?.slack?.actions) as Record< (account.actions ?? cfg.channels?.slack?.actions) as Record<string, boolean | undefined>,
string,
boolean | undefined
>,
); );
if (gate(key, defaultValue)) return true; if (gate(key, defaultValue)) return true;
} }
@@ -290,7 +285,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
const mediaUrl = readStringParam(params, "media", { trim: false }); const mediaUrl = readStringParam(params, "media", { trim: false });
const threadId = readStringParam(params, "threadId"); const threadId = readStringParam(params, "threadId");
const replyTo = readStringParam(params, "replyTo"); const replyTo = readStringParam(params, "replyTo");
return await handleSlackAction( return await getSlackRuntime().channel.slack.handleSlackAction(
{ {
action: "sendMessage", action: "sendMessage",
to, to,
@@ -310,7 +305,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
}); });
const emoji = readStringParam(params, "emoji", { allowEmpty: true }); const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined; const remove = typeof params.remove === "boolean" ? params.remove : undefined;
return await handleSlackAction( return await getSlackRuntime().channel.slack.handleSlackAction(
{ {
action: "react", action: "react",
channelId: resolveChannelId(), channelId: resolveChannelId(),
@@ -328,7 +323,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
required: true, required: true,
}); });
const limit = readNumberParam(params, "limit", { integer: true }); const limit = readNumberParam(params, "limit", { integer: true });
return await handleSlackAction( return await getSlackRuntime().channel.slack.handleSlackAction(
{ {
action: "reactions", action: "reactions",
channelId: resolveChannelId(), channelId: resolveChannelId(),
@@ -342,7 +337,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
if (action === "read") { if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true }); const limit = readNumberParam(params, "limit", { integer: true });
return await handleSlackAction( return await getSlackRuntime().channel.slack.handleSlackAction(
{ {
action: "readMessages", action: "readMessages",
channelId: resolveChannelId(), channelId: resolveChannelId(),
@@ -360,7 +355,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
required: true, required: true,
}); });
const content = readStringParam(params, "message", { required: true }); const content = readStringParam(params, "message", { required: true });
return await handleSlackAction( return await getSlackRuntime().channel.slack.handleSlackAction(
{ {
action: "editMessage", action: "editMessage",
channelId: resolveChannelId(), channelId: resolveChannelId(),
@@ -376,7 +371,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
const messageId = readStringParam(params, "messageId", { const messageId = readStringParam(params, "messageId", {
required: true, required: true,
}); });
return await handleSlackAction( return await getSlackRuntime().channel.slack.handleSlackAction(
{ {
action: "deleteMessage", action: "deleteMessage",
channelId: resolveChannelId(), channelId: resolveChannelId(),
@@ -392,7 +387,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
action === "list-pins" action === "list-pins"
? undefined ? undefined
: readStringParam(params, "messageId", { required: true }); : readStringParam(params, "messageId", { required: true });
return await handleSlackAction( return await getSlackRuntime().channel.slack.handleSlackAction(
{ {
action: action:
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
@@ -406,14 +401,14 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
if (action === "member-info") { if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true }); const userId = readStringParam(params, "userId", { required: true });
return await handleSlackAction( return await getSlackRuntime().channel.slack.handleSlackAction(
{ action: "memberInfo", userId, accountId: accountId ?? undefined }, { action: "memberInfo", userId, accountId: accountId ?? undefined },
cfg, cfg,
); );
} }
if (action === "emoji-list") { if (action === "emoji-list") {
return await handleSlackAction( return await getSlackRuntime().channel.slack.handleSlackAction(
{ action: "emojiList", accountId: accountId ?? undefined }, { action: "emojiList", accountId: accountId ?? undefined },
cfg, cfg,
); );
@@ -498,7 +493,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
chunker: null, chunker: null,
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => { sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => {
const send = deps?.sendSlack ?? sendMessageSlack; const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId }); const account = resolveSlackAccount({ cfg, accountId });
const token = getTokenForOperation(account, "write"); const token = getTokenForOperation(account, "write");
const botToken = account.botToken?.trim(); const botToken = account.botToken?.trim();
@@ -511,7 +506,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
return { channel: "slack", ...result }; return { channel: "slack", ...result };
}, },
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => { sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => {
const send = deps?.sendSlack ?? sendMessageSlack; const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId }); const account = resolveSlackAccount({ cfg, accountId });
const token = getTokenForOperation(account, "write"); const token = getTokenForOperation(account, "write");
const botToken = account.botToken?.trim(); const botToken = account.botToken?.trim();
@@ -547,7 +542,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
probeAccount: async ({ account, timeoutMs }) => { probeAccount: async ({ account, timeoutMs }) => {
const token = account.botToken?.trim(); const token = account.botToken?.trim();
if (!token) return { ok: false, error: "missing token" }; if (!token) return { ok: false, error: "missing token" };
return await probeSlack(token, timeoutMs); return await getSlackRuntime().channel.slack.probeSlack(token, timeoutMs);
}, },
buildAccountSnapshot: ({ account, runtime, probe }) => { buildAccountSnapshot: ({ account, runtime, probe }) => {
const configured = Boolean(account.botToken && account.appToken); const configured = Boolean(account.botToken && account.appToken);
@@ -574,9 +569,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
const botToken = account.botToken?.trim(); const botToken = account.botToken?.trim();
const appToken = account.appToken?.trim(); const appToken = account.appToken?.trim();
ctx.log?.info(`[${account.accountId}] starting provider`); ctx.log?.info(`[${account.accountId}] starting provider`);
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. return getSlackRuntime().channel.slack.monitorSlackProvider({
const { monitorSlackProvider } = await import("../../slack/index.js");
return monitorSlackProvider({
botToken: botToken ?? "", botToken: botToken ?? "",
appToken: appToken ?? "", appToken: appToken ?? "",
accountId: account.accountId, accountId: account.accountId,
+14
View File
@@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setSlackRuntime(next: PluginRuntime) {
runtime = next;
}
export function getSlackRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Slack runtime not initialized");
}
return runtime;
}
+16
View File
@@ -0,0 +1,16 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { telegramPlugin } from "./src/channel.js";
import { setTelegramRuntime } from "./src/runtime.js";
const plugin = {
id: "telegram",
name: "Telegram",
description: "Telegram channel plugin",
register(api: ClawdbotPluginApi) {
setTelegramRuntime(api.runtime);
api.registerChannel({ plugin: telegramPlugin });
},
};
export default plugin;
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@clawdbot/telegram",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Telegram channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
}
}
Loaded 100 of 342 files, more files were not shown because too many files have changed in this diff. Show more