Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ace906539f | ||
|
|
f039fdf8e9 | ||
|
|
858a5f48d8 | ||
|
|
20c26eb303 | ||
|
|
f3ef609839 | ||
|
|
234fe5b5cd | ||
|
|
e944f21ec0 | ||
|
|
ee6e534ccb | ||
|
|
676d41d415 | ||
|
|
a3a4996adb | ||
|
|
b015c7e5ad | ||
|
|
4de3c3a028 | ||
|
|
b739a3897f | ||
|
|
c5e19f5c67 | ||
|
|
9241e21114 | ||
|
|
65bed815a8 | ||
|
|
d776cfb4e1 | ||
|
|
c6e7e1821b | ||
|
|
f76ab69612 | ||
|
|
889db137b8 | ||
|
|
9db682750d | ||
|
|
9809b47d45 | ||
|
|
68d79e56c2 | ||
|
|
d3862ae30a | ||
|
|
e49a2952d9 | ||
|
|
8b57f519c3 | ||
|
|
ddcc05f5f4 | ||
|
|
8c0e290db1 | ||
|
|
7bfc77db25 | ||
|
|
8d74578ceb | ||
|
|
f7123ec30a | ||
|
|
ad4f4388f4 | ||
|
|
2a86504723 | ||
|
|
de3b68740a | ||
|
|
efaa73f543 | ||
|
|
1589c73697 | ||
|
|
359d2af8a8 | ||
|
|
fa897e5dfe | ||
|
|
7fa8ae56cb | ||
|
|
ec27c813cc | ||
|
|
3b24fe639a | ||
|
|
e5cca6e432 | ||
|
|
ae0b4c4990 | ||
|
|
49bd2d96fa | ||
|
|
ca350fc66c | ||
|
|
30338ce1a7 | ||
|
|
faa94f0168 | ||
|
|
f5c84768ff | ||
|
|
df752d4706 | ||
|
|
c9c9516206 | ||
|
|
d3b15c6afa | ||
|
|
f86b24c511 | ||
|
|
b5ddf08763 | ||
|
|
367826f6e4 | ||
|
|
f03c3b3f05 | ||
|
|
ebfeb7a6bf | ||
|
|
ac1b2d8c40 | ||
|
|
2087f0c6a1 | ||
|
|
bcfdcc6820 | ||
|
|
b65acfcbb7 | ||
|
|
f7fcfafb4c | ||
|
|
15606b4d88 | ||
|
|
bb8f08734a | ||
|
|
0b00e591e1 | ||
|
|
e39fd7dbb3 | ||
|
|
b8a82923e9 | ||
|
|
28f8b7bafa | ||
|
|
32dd052260 | ||
|
|
8f7f7ee7dc | ||
|
|
1d8614c7c2 | ||
|
|
436c5fd751 | ||
|
|
b594f5130d | ||
|
|
e2bb5eecf3 |
No files matched your search
@@ -1,4 +0,0 @@
|
|||||||
[submodule "Peekaboo"]
|
|
||||||
path = Peekaboo
|
|
||||||
url = https://github.com/steipete/Peekaboo.git
|
|
||||||
branch = main
|
|
||||||
+28
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
-1
Submodule Peekaboo deleted from 5c195f5e46.
@@ -13,7 +13,7 @@ let package = Package(
|
|||||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(path: "../Peekaboo/Commander"),
|
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
|
||||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
|
|||||||
+28
-73
@@ -1,6 +1,24 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
|
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "axorcist",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/steipete/AXorcist.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "c75d06f7f93e264a9786edc2b78c04973061cb2f",
|
||||||
|
"version" : "0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "commander",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/steipete/Commander.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
|
||||||
|
"version" : "0.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "elevenlabskit",
|
"identity" : "elevenlabskit",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -10,15 +28,6 @@
|
|||||||
"version" : "0.1.0"
|
"version" : "0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "eventsource",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/mattt/eventsource.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
|
|
||||||
"version" : "1.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "menubarextraaccess",
|
"identity" : "menubarextraaccess",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -28,6 +37,15 @@
|
|||||||
"version" : "1.2.2"
|
"version" : "1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "peekaboo",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/steipete/Peekaboo.git",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "main",
|
||||||
|
"revision" : "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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -28,3 +28,5 @@ clawdbot memory search "release checklist"
|
|||||||
## Options
|
## Options
|
||||||
|
|
||||||
- `--verbose`: emit debug logs during memory probes and indexing.
|
- `--verbose`: emit debug logs during memory probes and indexing.
|
||||||
|
- `--index-mode auto|batch|direct`: override batch usage when indexing (`direct` favors speed; `batch` favors OpenAI Batch pricing).
|
||||||
|
- `--progress auto|line|log|none`: progress output mode (`log` prints updates even without a TTY).
|
||||||
@@ -0,0 +1,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
@@ -89,7 +89,26 @@ OAuth only covers chat/completions and does **not** satisfy embeddings for
|
|||||||
memory search. When using a custom OpenAI-compatible endpoint, set
|
memory search. When using a custom OpenAI-compatible endpoint, set
|
||||||
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
||||||
|
|
||||||
If you want to use a **custom OpenAI-compatible endpoint** (like Gemini, OpenRouter, or a proxy),
|
If you want to use **Gemini embeddings** directly, set the provider to `gemini`:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
memorySearch: {
|
||||||
|
provider: "gemini",
|
||||||
|
model: "gemini-embedding-001", // default
|
||||||
|
remote: {
|
||||||
|
apiKey: "${GEMINI_API_KEY}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemini uses `GEMINI_API_KEY` (or `models.providers.google.apiKey`). Override
|
||||||
|
`memorySearch.remote.baseUrl` to point at a custom Gemini-compatible endpoint.
|
||||||
|
|
||||||
|
If you want to use a **custom OpenAI-compatible endpoint** (like OpenRouter or a proxy),
|
||||||
you can use the `remote` configuration:
|
you can use the `remote` configuration:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@@ -99,8 +118,8 @@ agents: {
|
|||||||
provider: "openai",
|
provider: "openai",
|
||||||
model: "text-embedding-3-small",
|
model: "text-embedding-3-small",
|
||||||
remote: {
|
remote: {
|
||||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
baseUrl: "https://proxy.example/v1",
|
||||||
apiKey: "YOUR_GEMINI_API_KEY",
|
apiKey: "YOUR_PROXY_KEY",
|
||||||
headers: { "X-Custom-Header": "value" }
|
headers: { "X-Custom-Header": "value" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ It does **not** rotate on every request. The pinned profile is reused until:
|
|||||||
Manual selection via `/model …@<profileId>` sets a **user override** for that session
|
Manual selection via `/model …@<profileId>` sets a **user override** for that session
|
||||||
and is not auto‑rotated until a new session starts.
|
and is not auto‑rotated until a new session starts.
|
||||||
|
|
||||||
|
Auto‑pinned profiles (selected by the session router) are treated as a **preference**:
|
||||||
|
they are tried first, but Clawdbot may rotate to another profile on rate limits/timeouts.
|
||||||
|
User‑pinned profiles stay locked to that profile; if it fails and model fallbacks
|
||||||
|
are configured, Clawdbot moves to the next model instead of switching profiles.
|
||||||
|
|
||||||
### Why OAuth can “look lost”
|
### Why OAuth can “look lost”
|
||||||
|
|
||||||
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
|
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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**:
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
@@ -14,15 +14,7 @@ Before building the app, ensure you have the following installed:
|
|||||||
1. **Xcode 26.2+**: Required for Swift development.
|
1. **Xcode 26.2+**: Required for Swift development.
|
||||||
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
|
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
|
||||||
|
|
||||||
## 1. Initialize Submodules
|
## 1. Install Dependencies
|
||||||
|
|
||||||
Clawdbot depends on several submodules (like `Peekaboo`). You must initialize these recursively:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git submodule update --init --recursive
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Install Dependencies
|
|
||||||
|
|
||||||
Install the project-wide dependencies:
|
Install the project-wide dependencies:
|
||||||
|
|
||||||
@@ -30,7 +22,7 @@ Install the project-wide dependencies:
|
|||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Build and Package the App
|
## 2. Build and Package the App
|
||||||
|
|
||||||
To build the macOS app and package it into `dist/Clawdbot.app`, run:
|
To build the macOS app and package it into `dist/Clawdbot.app`, run:
|
||||||
|
|
||||||
@@ -42,7 +34,7 @@ If you don't have an Apple Developer ID certificate, the script will automatical
|
|||||||
|
|
||||||
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
||||||
|
|
||||||
## 4. Install the CLI
|
## 3. Install the CLI
|
||||||
|
|
||||||
The macOS app expects a global `clawdbot` CLI install to manage background tasks.
|
The macOS app expects a global `clawdbot` CLI install to manage background tasks.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
summary: "PeekabooBridge integration for macOS UI automation"
|
summary: "PeekabooBridge integration for macOS UI automation"
|
||||||
read_when:
|
read_when:
|
||||||
- Hosting PeekabooBridge in Clawdbot.app
|
- Hosting PeekabooBridge in Clawdbot.app
|
||||||
- Integrating Peekaboo as a submodule
|
- Integrating Peekaboo via Swift Package Manager
|
||||||
- Changing PeekabooBridge protocol/paths
|
- Changing PeekabooBridge protocol/paths
|
||||||
---
|
---
|
||||||
# Peekaboo Bridge (macOS UI automation)
|
# Peekaboo Bridge (macOS UI automation)
|
||||||
|
|||||||
+18
-15
@@ -54,29 +54,32 @@ The macOS app presents itself as a node. Common commands:
|
|||||||
|
|
||||||
The node reports a `permissions` map so agents can decide what’s allowed.
|
The node reports a `permissions` map so agents can decide what’s allowed.
|
||||||
|
|
||||||
## Node run policy + allowlist
|
## Exec approvals (system.run)
|
||||||
|
|
||||||
`system.run` is controlled by the macOS app **Node Run Commands** policy:
|
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
|
||||||
|
Security + ask + allowlist are stored locally on the Mac in:
|
||||||
- `Always Ask`: prompt per command (default).
|
|
||||||
- `Always Allow`: run without prompts.
|
|
||||||
- `Never`: disable `system.run` (tool not advertised).
|
|
||||||
|
|
||||||
The policy + allowlist live on the Mac in:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.clawdbot/macos-node.json
|
~/.clawdbot/exec-approvals.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema:
|
Example:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"systemRun": {
|
"version": 1,
|
||||||
"policy": "ask",
|
"defaults": {
|
||||||
"allowlist": [
|
"security": "deny",
|
||||||
"[\"/bin/echo\",\"hello\"]"
|
"ask": "on-miss"
|
||||||
]
|
},
|
||||||
|
"agents": {
|
||||||
|
"main": {
|
||||||
|
"security": "allowlist",
|
||||||
|
"ask": "on-miss",
|
||||||
|
"allowlist": [
|
||||||
|
{ "pattern": "/opt/homebrew/bin/rg" }
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ Notes:
|
|||||||
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
||||||
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
|
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
|
||||||
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
||||||
|
- `host=node` can target a macOS companion app or a headless node host (`clawdbot node start`).
|
||||||
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
|
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
|
||||||
|
|
||||||
### `process`
|
### `process`
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ Skills can also refresh mid-session when the skills watcher is enabled or when a
|
|||||||
|
|
||||||
## Remote macOS nodes (Linux gateway)
|
## Remote macOS nodes (Linux gateway)
|
||||||
|
|
||||||
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Node Run Commands policy not set to "Never"), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`).
|
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Exec approvals security not set to `deny`), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`).
|
||||||
|
|
||||||
This relies on the node reporting its command support and on a bin probe via `system.run`. If the macOS node goes offline later, the skills remain visible; invocations may fail until the node reconnects.
|
This relies on the node reporting its command support and on a bin probe via `system.run`. If the macOS node goes offline later, the skills remain visible; invocations may fail until the node reconnects.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
import { enqueueSystemEvent, formatAgentEnvelope, type ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||||
@@ -836,7 +836,7 @@ async function processMessage(
|
|||||||
const fromLabel = message.isGroup
|
const fromLabel = message.isGroup
|
||||||
? `group:${peerId}`
|
? `group:${peerId}`
|
||||||
: message.senderName || `user:${message.senderId}`;
|
: message.senderName || `user:${message.senderId}`;
|
||||||
const body = formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "BlueBubbles",
|
channel: "BlueBubbles",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
@@ -1058,7 +1058,7 @@ async function processReaction(
|
|||||||
const senderLabel = reaction.senderName || reaction.senderId;
|
const senderLabel = reaction.senderName || reaction.senderId;
|
||||||
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
||||||
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`;
|
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`;
|
||||||
enqueueSystemEvent(text, {
|
core.system.enqueueSystemEvent(text, {
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { discordPlugin } from "./src/channel.js";
|
||||||
|
import { setDiscordRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "discord",
|
||||||
|
name: "Discord",
|
||||||
|
description: "Discord channel plugin",
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setDiscordRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: discordPlugin });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/discord",
|
||||||
|
"version": "2026.1.17-1",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot Discord channel plugin",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": ["./index.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1,43 @@
|
|||||||
import {
|
|
||||||
listDiscordAccountIds,
|
|
||||||
type ResolvedDiscordAccount,
|
|
||||||
resolveDefaultDiscordAccountId,
|
|
||||||
resolveDiscordAccount,
|
|
||||||
} from "../../discord/accounts.js";
|
|
||||||
import {
|
|
||||||
auditDiscordChannelPermissions,
|
|
||||||
collectDiscordAuditChannelIds,
|
|
||||||
} from "../../discord/audit.js";
|
|
||||||
import { probeDiscord } from "../../discord/probe.js";
|
|
||||||
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
|
|
||||||
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
|
|
||||||
import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js";
|
|
||||||
import { shouldLogVerbose } from "../../globals.js";
|
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
|
||||||
import { getChatChannelMeta } from "../registry.js";
|
|
||||||
import { DiscordConfigSchema } from "../../config/zod-schema.providers-core.js";
|
|
||||||
import { discordMessageActions } from "./actions/discord.js";
|
|
||||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
|
||||||
import {
|
|
||||||
deleteAccountFromConfigSection,
|
|
||||||
setAccountEnabledInConfigSection,
|
|
||||||
} from "./config-helpers.js";
|
|
||||||
import { resolveDiscordGroupRequireMention } from "./group-mentions.js";
|
|
||||||
import { formatPairingApproveHint } from "./helpers.js";
|
|
||||||
import { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget } from "./normalize/discord.js";
|
|
||||||
import { discordOnboardingAdapter } from "./onboarding/discord.js";
|
|
||||||
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
|
|
||||||
import {
|
import {
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
migrateBaseNameToDefaultAccount,
|
buildChannelConfigSchema,
|
||||||
} from "./setup-helpers.js";
|
collectDiscordAuditChannelIds,
|
||||||
import { collectDiscordStatusIssues } from "./status-issues/discord.js";
|
collectDiscordStatusIssues,
|
||||||
import type { ChannelPlugin } from "./types.js";
|
DEFAULT_ACCOUNT_ID,
|
||||||
import {
|
deleteAccountFromConfigSection,
|
||||||
|
discordOnboardingAdapter,
|
||||||
|
DiscordConfigSchema,
|
||||||
|
formatPairingApproveHint,
|
||||||
|
getChatChannelMeta,
|
||||||
|
listDiscordAccountIds,
|
||||||
listDiscordDirectoryGroupsFromConfig,
|
listDiscordDirectoryGroupsFromConfig,
|
||||||
listDiscordDirectoryPeersFromConfig,
|
listDiscordDirectoryPeersFromConfig,
|
||||||
} from "./directory-config.js";
|
looksLikeDiscordTargetId,
|
||||||
import {
|
migrateBaseNameToDefaultAccount,
|
||||||
listDiscordDirectoryGroupsLive,
|
normalizeAccountId,
|
||||||
listDiscordDirectoryPeersLive,
|
normalizeDiscordMessagingTarget,
|
||||||
} from "../../discord/directory-live.js";
|
PAIRING_APPROVED_MESSAGE,
|
||||||
|
resolveDiscordAccount,
|
||||||
|
resolveDefaultDiscordAccountId,
|
||||||
|
resolveDiscordGroupRequireMention,
|
||||||
|
setAccountEnabledInConfigSection,
|
||||||
|
type ChannelMessageActionAdapter,
|
||||||
|
type ChannelPlugin,
|
||||||
|
type ResolvedDiscordAccount,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { getDiscordRuntime } from "./runtime.js";
|
||||||
|
|
||||||
const meta = getChatChannelMeta("discord");
|
const meta = getChatChannelMeta("discord");
|
||||||
|
|
||||||
|
const discordMessageActions: ChannelMessageActionAdapter = {
|
||||||
|
listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions.listActions(ctx),
|
||||||
|
extractToolSend: (ctx) =>
|
||||||
|
getDiscordRuntime().channel.discord.messageActions.extractToolSend(ctx),
|
||||||
|
handleAction: async (ctx) =>
|
||||||
|
await getDiscordRuntime().channel.discord.messageActions.handleAction(ctx),
|
||||||
|
};
|
||||||
|
|
||||||
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||||
id: "discord",
|
id: "discord",
|
||||||
meta: {
|
meta: {
|
||||||
@@ -54,7 +48,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
idLabel: "discordUserId",
|
idLabel: "discordUserId",
|
||||||
normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""),
|
normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""),
|
||||||
notifyApproval: async ({ id }) => {
|
notifyApproval: async ({ id }) => {
|
||||||
await sendMessageDiscord(`user:${id}`, PAIRING_APPROVED_MESSAGE);
|
await getDiscordRuntime().channel.discord.sendMessageDiscord(
|
||||||
|
`user:${id}`,
|
||||||
|
PAIRING_APPROVED_MESSAGE,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
@@ -165,8 +162,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
self: async () => null,
|
self: async () => null,
|
||||||
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
|
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
|
||||||
listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),
|
listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),
|
||||||
listPeersLive: async (params) => listDiscordDirectoryPeersLive(params),
|
listPeersLive: async (params) =>
|
||||||
listGroupsLive: async (params) => listDiscordDirectoryGroupsLive(params),
|
getDiscordRuntime().channel.discord.listDirectoryPeersLive(params),
|
||||||
|
listGroupsLive: async (params) =>
|
||||||
|
getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params),
|
||||||
},
|
},
|
||||||
resolver: {
|
resolver: {
|
||||||
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
|
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
|
||||||
@@ -180,7 +179,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (kind === "group") {
|
if (kind === "group") {
|
||||||
const resolved = await resolveDiscordChannelAllowlist({ token, entries: inputs });
|
const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({
|
||||||
|
token,
|
||||||
|
entries: inputs,
|
||||||
|
});
|
||||||
return resolved.map((entry) => ({
|
return resolved.map((entry) => ({
|
||||||
input: entry.input,
|
input: entry.input,
|
||||||
resolved: entry.resolved,
|
resolved: entry.resolved,
|
||||||
@@ -192,7 +194,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
note: entry.note,
|
note: entry.note,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
const resolved = await resolveDiscordUserAllowlist({ token, entries: inputs });
|
const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({
|
||||||
|
token,
|
||||||
|
entries: inputs,
|
||||||
|
});
|
||||||
return resolved.map((entry) => ({
|
return resolved.map((entry) => ({
|
||||||
input: entry.input,
|
input: entry.input,
|
||||||
resolved: entry.resolved,
|
resolved: entry.resolved,
|
||||||
@@ -274,7 +279,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
textChunkLimit: 2000,
|
textChunkLimit: 2000,
|
||||||
pollMaxOptions: 10,
|
pollMaxOptions: 10,
|
||||||
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
||||||
const send = deps?.sendDiscord ?? sendMessageDiscord;
|
const send =
|
||||||
|
deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
replyTo: replyToId ?? undefined,
|
replyTo: replyToId ?? undefined,
|
||||||
@@ -283,7 +289,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
return { channel: "discord", ...result };
|
return { channel: "discord", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
||||||
const send = deps?.sendDiscord ?? sendMessageDiscord;
|
const send =
|
||||||
|
deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
@@ -293,7 +300,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
return { channel: "discord", ...result };
|
return { channel: "discord", ...result };
|
||||||
},
|
},
|
||||||
sendPoll: async ({ to, poll, accountId }) =>
|
sendPoll: async ({ to, poll, accountId }) =>
|
||||||
await sendPollDiscord(to, poll, {
|
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -317,7 +324,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
}),
|
}),
|
||||||
probeAccount: async ({ account, timeoutMs }) =>
|
probeAccount: async ({ account, timeoutMs }) =>
|
||||||
probeDiscord(account.token, timeoutMs, { includeApplication: true }),
|
getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
|
||||||
|
includeApplication: true,
|
||||||
|
}),
|
||||||
auditAccount: async ({ account, timeoutMs, cfg }) => {
|
auditAccount: async ({ account, timeoutMs, cfg }) => {
|
||||||
const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
|
const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -334,7 +343,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
elapsedMs: 0,
|
elapsedMs: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const audit = await auditDiscordChannelPermissions({
|
const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({
|
||||||
token: botToken,
|
token: botToken,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
channelIds,
|
channelIds,
|
||||||
@@ -371,7 +380,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
const token = account.token.trim();
|
const token = account.token.trim();
|
||||||
let discordBotLabel = "";
|
let discordBotLabel = "";
|
||||||
try {
|
try {
|
||||||
const probe = await probeDiscord(token, 2500, {
|
const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, {
|
||||||
includeApplication: true,
|
includeApplication: true,
|
||||||
});
|
});
|
||||||
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
||||||
@@ -392,14 +401,12 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (shouldLogVerbose()) {
|
if (getDiscordRuntime().logging.shouldLogVerbose()) {
|
||||||
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
|
ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
|
||||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
return getDiscordRuntime().channel.discord.monitorDiscordProvider({
|
||||||
const { monitorDiscordProvider } = await import("../../discord/index.js");
|
|
||||||
return monitorDiscordProvider({
|
|
||||||
token,
|
token,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
config: ctx.cfg,
|
config: ctx.cfg,
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
export function setDiscordRuntime(next: PluginRuntime) {
|
||||||
|
runtime = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDiscordRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("Discord runtime not initialized");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { imessagePlugin } from "./src/channel.js";
|
||||||
|
import { setIMessageRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "imessage",
|
||||||
|
name: "iMessage",
|
||||||
|
description: "iMessage channel plugin",
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setIMessageRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: imessagePlugin });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/imessage",
|
||||||
|
"version": "2026.1.17-1",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot iMessage channel plugin",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": ["./index.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,26 @@
|
|||||||
import { chunkText } from "../../auto-reply/chunk.js";
|
|
||||||
import {
|
|
||||||
listIMessageAccountIds,
|
|
||||||
type ResolvedIMessageAccount,
|
|
||||||
resolveDefaultIMessageAccountId,
|
|
||||||
resolveIMessageAccount,
|
|
||||||
} from "../../imessage/accounts.js";
|
|
||||||
import { probeIMessage } from "../../imessage/probe.js";
|
|
||||||
import { sendMessageIMessage } from "../../imessage/send.js";
|
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
|
||||||
import { getChatChannelMeta } from "../registry.js";
|
|
||||||
import { IMessageConfigSchema } from "../../config/zod-schema.providers-core.js";
|
|
||||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
|
||||||
import {
|
|
||||||
deleteAccountFromConfigSection,
|
|
||||||
setAccountEnabledInConfigSection,
|
|
||||||
} from "./config-helpers.js";
|
|
||||||
import { resolveIMessageGroupRequireMention } from "./group-mentions.js";
|
|
||||||
import { formatPairingApproveHint } from "./helpers.js";
|
|
||||||
import { resolveChannelMediaMaxBytes } from "./media-limits.js";
|
|
||||||
import { imessageOnboardingAdapter } from "./onboarding/imessage.js";
|
|
||||||
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
|
|
||||||
import {
|
import {
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
|
buildChannelConfigSchema,
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
deleteAccountFromConfigSection,
|
||||||
|
formatPairingApproveHint,
|
||||||
|
getChatChannelMeta,
|
||||||
|
imessageOnboardingAdapter,
|
||||||
|
IMessageConfigSchema,
|
||||||
|
listIMessageAccountIds,
|
||||||
migrateBaseNameToDefaultAccount,
|
migrateBaseNameToDefaultAccount,
|
||||||
} from "./setup-helpers.js";
|
normalizeAccountId,
|
||||||
import type { ChannelPlugin } from "./types.js";
|
PAIRING_APPROVED_MESSAGE,
|
||||||
|
resolveChannelMediaMaxBytes,
|
||||||
|
resolveDefaultIMessageAccountId,
|
||||||
|
resolveIMessageAccount,
|
||||||
|
resolveIMessageGroupRequireMention,
|
||||||
|
setAccountEnabledInConfigSection,
|
||||||
|
type ChannelPlugin,
|
||||||
|
type ResolvedIMessageAccount,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { getIMessageRuntime } from "./runtime.js";
|
||||||
|
|
||||||
const meta = getChatChannelMeta("imessage");
|
const meta = getChatChannelMeta("imessage");
|
||||||
|
|
||||||
@@ -32,13 +28,17 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
id: "imessage",
|
id: "imessage",
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
...meta,
|
||||||
|
aliases: ["imsg"],
|
||||||
showConfigured: false,
|
showConfigured: false,
|
||||||
},
|
},
|
||||||
onboarding: imessageOnboardingAdapter,
|
onboarding: imessageOnboardingAdapter,
|
||||||
pairing: {
|
pairing: {
|
||||||
idLabel: "imessageSenderId",
|
idLabel: "imessageSenderId",
|
||||||
notifyApproval: async ({ id }) => {
|
notifyApproval: async ({ id }) => {
|
||||||
await sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE);
|
await getIMessageRuntime().channel.imessage.sendMessageIMessage(
|
||||||
|
id,
|
||||||
|
PAIRING_APPROVED_MESSAGE,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
@@ -183,10 +183,10 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
},
|
},
|
||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: chunkText,
|
chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||||
const send = deps?.sendIMessage ?? sendMessageIMessage;
|
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
|
||||||
const maxBytes = resolveChannelMediaMaxBytes({
|
const maxBytes = resolveChannelMediaMaxBytes({
|
||||||
cfg,
|
cfg,
|
||||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||||
@@ -201,7 +201,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
return { channel: "imessage", ...result };
|
return { channel: "imessage", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
|
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
|
||||||
const send = deps?.sendIMessage ?? sendMessageIMessage;
|
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
|
||||||
const maxBytes = resolveChannelMediaMaxBytes({
|
const maxBytes = resolveChannelMediaMaxBytes({
|
||||||
cfg,
|
cfg,
|
||||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||||
@@ -251,7 +251,8 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
probe: snapshot.probe,
|
probe: snapshot.probe,
|
||||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
}),
|
}),
|
||||||
probeAccount: async ({ timeoutMs }) => probeIMessage(timeoutMs),
|
probeAccount: async ({ timeoutMs }) =>
|
||||||
|
getIMessageRuntime().channel.imessage.probeIMessage(timeoutMs),
|
||||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
@@ -282,9 +283,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
ctx.log?.info(
|
ctx.log?.info(
|
||||||
`[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`,
|
`[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`,
|
||||||
);
|
);
|
||||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
return getIMessageRuntime().channel.imessage.monitorIMessageProvider({
|
||||||
const { monitorIMessageProvider } = await import("../../imessage/index.js");
|
|
||||||
return monitorIMessageProvider({
|
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
config: ctx.cfg,
|
config: ctx.cfg,
|
||||||
runtime: ctx.runtime,
|
runtime: ctx.runtime,
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
export function setIMessageRuntime(next: PluginRuntime) {
|
||||||
|
runtime = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIMessageRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("iMessage runtime not initialized");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
@@ -1,10 +1,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: {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
RoomTopicEventContent,
|
RoomTopicEventContent,
|
||||||
} from "matrix-js-sdk/lib/@types/state_events.js";
|
} from "matrix-js-sdk/lib/@types/state_events.js";
|
||||||
|
|
||||||
import { loadConfig } from "clawdbot/plugin-sdk";
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
import type { CoreConfig } from "../types.js";
|
import type { CoreConfig } from "../types.js";
|
||||||
import { getActiveMatrixClient } from "./active-client.js";
|
import { getActiveMatrixClient } from "./active-client.js";
|
||||||
import {
|
import {
|
||||||
@@ -74,12 +74,14 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<M
|
|||||||
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
|
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
|
||||||
if (shouldShareClient) {
|
if (shouldShareClient) {
|
||||||
const client = await resolveSharedMatrixClient({
|
const client = await resolveSharedMatrixClient({
|
||||||
cfg: loadConfig() as CoreConfig,
|
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
});
|
});
|
||||||
return { client, stopOnDone: false };
|
return { client, stopOnDone: false };
|
||||||
}
|
}
|
||||||
const auth = await resolveMatrixAuth({ cfg: loadConfig() as CoreConfig });
|
const auth = await resolveMatrixAuth({
|
||||||
|
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||||
|
});
|
||||||
const client = await createMatrixClient({
|
const client = await createMatrixClient({
|
||||||
homeserver: auth.homeserver,
|
homeserver: auth.homeserver,
|
||||||
userId: auth.userId,
|
userId: auth.userId,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
||||||
|
|
||||||
import { loadConfig } from "clawdbot/plugin-sdk";
|
|
||||||
import type { CoreConfig } from "../types.js";
|
import type { CoreConfig } from "../types.js";
|
||||||
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
|
|
||||||
export type MatrixResolvedConfig = {
|
export type MatrixResolvedConfig = {
|
||||||
homeserver: string;
|
homeserver: string;
|
||||||
@@ -46,7 +46,7 @@ function clean(value?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveMatrixConfig(
|
export function resolveMatrixConfig(
|
||||||
cfg: CoreConfig = loadConfig() as CoreConfig,
|
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
): MatrixResolvedConfig {
|
): MatrixResolvedConfig {
|
||||||
const matrix = cfg.channels?.matrix ?? {};
|
const matrix = cfg.channels?.matrix ?? {};
|
||||||
@@ -75,7 +75,7 @@ export async function resolveMatrixAuth(params?: {
|
|||||||
cfg?: CoreConfig;
|
cfg?: CoreConfig;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
}): Promise<MatrixAuth> {
|
}): Promise<MatrixAuth> {
|
||||||
const cfg = params?.cfg ?? (loadConfig() as CoreConfig);
|
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||||
const env = params?.env ?? process.env;
|
const env = params?.env ?? process.env;
|
||||||
const resolved = resolveMatrixConfig(cfg, env);
|
const resolved = resolveMatrixConfig(cfg, env);
|
||||||
if (!resolved.homeserver) {
|
if (!resolved.homeserver) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { resolveStateDir } from "clawdbot/plugin-sdk";
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
|
|
||||||
export type MatrixStoredCredentials = {
|
export type MatrixStoredCredentials = {
|
||||||
homeserver: string;
|
homeserver: string;
|
||||||
@@ -16,9 +16,11 @@ const CREDENTIALS_FILENAME = "credentials.json";
|
|||||||
|
|
||||||
export function resolveMatrixCredentialsDir(
|
export function resolveMatrixCredentialsDir(
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
stateDir: string = resolveStateDir(env, os.homedir),
|
stateDir?: string,
|
||||||
): string {
|
): string {
|
||||||
return path.join(stateDir, "credentials", "matrix");
|
const resolvedStateDir =
|
||||||
|
stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||||
|
return path.join(resolvedStateDir, "credentials", "matrix");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string {
|
export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import path from "node:path";
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { runCommandWithTimeout, type RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
|
|
||||||
const MATRIX_SDK_PACKAGE = "matrix-js-sdk";
|
const MATRIX_SDK_PACKAGE = "matrix-js-sdk";
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ export async function ensureMatrixSdkInstalled(params: {
|
|||||||
? ["pnpm", "install"]
|
? ["pnpm", "install"]
|
||||||
: ["npm", "install", "--omit=dev", "--silent"];
|
: ["npm", "install", "--omit=dev", "--silent"];
|
||||||
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
|
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
|
||||||
const result = await runCommandWithTimeout(command, {
|
const result = await getMatrixRuntime().system.runCommandWithTimeout(command, {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
timeoutMs: 300_000,
|
timeoutMs: 300_000,
|
||||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk";
|
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk";
|
||||||
import { RoomMemberEvent } from "matrix-js-sdk";
|
import { RoomMemberEvent } from "matrix-js-sdk";
|
||||||
|
|
||||||
import { danger, logVerbose, type RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
import type { CoreConfig } from "../../types.js";
|
import type { CoreConfig } from "../../types.js";
|
||||||
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
export function registerMatrixAutoJoin(params: {
|
export function registerMatrixAutoJoin(params: {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -10,6 +11,11 @@ export function registerMatrixAutoJoin(params: {
|
|||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
}) {
|
}) {
|
||||||
const { client, cfg, runtime } = params;
|
const { client, cfg, runtime } = params;
|
||||||
|
const core = getMatrixRuntime();
|
||||||
|
const logVerbose = (message: string) => {
|
||||||
|
if (!core.logging.shouldLogVerbose()) return;
|
||||||
|
runtime.log?.(message);
|
||||||
|
};
|
||||||
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
|
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
|
||||||
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
|
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
|
||||||
|
|
||||||
@@ -36,7 +42,7 @@ export function registerMatrixAutoJoin(params: {
|
|||||||
await client.joinRoom(roomId);
|
await client.joinRoom(roomId);
|
||||||
logVerbose(`matrix: joined room ${roomId}`);
|
logVerbose(`matrix: joined room ${roomId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error?.(danger(`matrix: failed to join room ${roomId}: ${String(err)}`));
|
runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3,34 +3,9 @@ import { EventType, RelationType, RoomEvent } from "matrix-js-sdk";
|
|||||||
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildMentionRegexes,
|
|
||||||
chunkMarkdownText,
|
|
||||||
createReplyDispatcherWithTyping,
|
|
||||||
danger,
|
|
||||||
dispatchReplyFromConfig,
|
|
||||||
enqueueSystemEvent,
|
|
||||||
finalizeInboundContext,
|
|
||||||
formatAgentEnvelope,
|
|
||||||
formatAllowlistMatchMeta,
|
formatAllowlistMatchMeta,
|
||||||
getChildLogger,
|
|
||||||
hasControlCommand,
|
|
||||||
loadConfig,
|
|
||||||
logVerbose,
|
|
||||||
mergeAllowlist,
|
mergeAllowlist,
|
||||||
matchesMentionPatterns,
|
|
||||||
readChannelAllowFromStore,
|
|
||||||
recordSessionMetaFromInbound,
|
|
||||||
resolveAgentRoute,
|
|
||||||
resolveCommandAuthorizedFromAuthorizers,
|
|
||||||
resolveEffectiveMessagesConfig,
|
|
||||||
resolveHumanDelayConfig,
|
|
||||||
resolveStorePath,
|
|
||||||
resolveTextChunkLimit,
|
|
||||||
shouldHandleTextCommands,
|
|
||||||
shouldLogVerbose,
|
|
||||||
summarizeMapping,
|
summarizeMapping,
|
||||||
updateLastRoute,
|
|
||||||
upsertChannelPairingRequest,
|
|
||||||
type ReplyPayload,
|
type ReplyPayload,
|
||||||
type RuntimeEnv,
|
type RuntimeEnv,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
@@ -61,6 +36,7 @@ import { deliverMatrixReplies } from "./replies.js";
|
|||||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
import { resolveMatrixRoomConfig } from "./rooms.js";
|
||||||
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
||||||
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
||||||
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
export type MonitorMatrixOpts = {
|
export type MonitorMatrixOpts = {
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
@@ -76,7 +52,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
if (isBunRuntime()) {
|
if (isBunRuntime()) {
|
||||||
throw new Error("Matrix provider requires Node (bun runtime not supported)");
|
throw new Error("Matrix provider requires Node (bun runtime not supported)");
|
||||||
}
|
}
|
||||||
let cfg = loadConfig() as CoreConfig;
|
const core = getMatrixRuntime();
|
||||||
|
let cfg = core.config.loadConfig() as CoreConfig;
|
||||||
if (cfg.channels?.matrix?.enabled === false) return;
|
if (cfg.channels?.matrix?.enabled === false) return;
|
||||||
|
|
||||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||||
@@ -207,8 +184,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
});
|
});
|
||||||
setActiveMatrixClient(client);
|
setActiveMatrixClient(client);
|
||||||
|
|
||||||
const mentionRegexes = buildMentionRegexes(cfg);
|
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
|
||||||
const logger = getChildLogger({ module: "matrix-auto-reply" });
|
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
|
||||||
|
const logVerboseMessage = (message: string) => {
|
||||||
|
if (core.logging.shouldLogVerbose()) {
|
||||||
|
logger.debug(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
|
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
|
||||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||||
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
@@ -219,8 +201,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
const dmEnabled = dmConfig?.enabled ?? true;
|
const dmEnabled = dmConfig?.enabled ?? true;
|
||||||
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
|
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
|
||||||
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
|
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
|
||||||
const allowFrom = dmConfig?.allowFrom ?? [];
|
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "matrix");
|
|
||||||
const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
||||||
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
||||||
const startupMs = Date.now();
|
const startupMs = Date.now();
|
||||||
@@ -306,22 +287,22 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
}`;
|
}`;
|
||||||
|
|
||||||
if (roomConfigInfo.config && !roomConfigInfo.allowed) {
|
if (roomConfigInfo.config && !roomConfigInfo.allowed) {
|
||||||
logVerbose(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (groupPolicy === "allowlist") {
|
if (groupPolicy === "allowlist") {
|
||||||
if (!roomConfigInfo.allowlistConfigured) {
|
if (!roomConfigInfo.allowlistConfigured) {
|
||||||
logVerbose(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!roomConfigInfo.config) {
|
if (!roomConfigInfo.config) {
|
||||||
logVerbose(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const senderName = room.getMember(senderId)?.name ?? senderId;
|
const senderName = room.getMember(senderId)?.name ?? senderId;
|
||||||
const storeAllowFrom = await readChannelAllowFromStore("matrix").catch(() => []);
|
const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
|
||||||
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
|
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
|
||||||
|
|
||||||
if (isDirectMessage) {
|
if (isDirectMessage) {
|
||||||
@@ -335,13 +316,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
||||||
if (!allowMatch.allowed) {
|
if (!allowMatch.allowed) {
|
||||||
if (dmPolicy === "pairing") {
|
if (dmPolicy === "pairing") {
|
||||||
const { code, created } = await upsertChannelPairingRequest({
|
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
id: senderId,
|
id: senderId,
|
||||||
meta: { name: senderName },
|
meta: { name: senderName },
|
||||||
});
|
});
|
||||||
if (created) {
|
if (created) {
|
||||||
logVerbose(
|
logVerboseMessage(
|
||||||
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
|
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
@@ -358,12 +339,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
{ client },
|
{ client },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
|
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (dmPolicy !== "pairing") {
|
if (dmPolicy !== "pairing") {
|
||||||
logVerbose(
|
logVerboseMessage(
|
||||||
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -379,7 +360,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
userName: senderName,
|
userName: senderName,
|
||||||
});
|
});
|
||||||
if (!userMatch.allowed) {
|
if (!userMatch.allowed) {
|
||||||
logVerbose(
|
logVerboseMessage(
|
||||||
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
||||||
userMatch,
|
userMatch,
|
||||||
)})`,
|
)})`,
|
||||||
@@ -388,7 +369,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isRoom) {
|
if (isRoom) {
|
||||||
logVerbose(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawBody = content.body.trim();
|
const rawBody = content.body.trim();
|
||||||
@@ -416,7 +397,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(`matrix: media download failed: ${String(err)}`);
|
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +410,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
text: bodyText,
|
text: bodyText,
|
||||||
mentionRegexes,
|
mentionRegexes,
|
||||||
});
|
});
|
||||||
const allowTextCommands = shouldHandleTextCommands({
|
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||||
cfg,
|
cfg,
|
||||||
surface: "matrix",
|
surface: "matrix",
|
||||||
});
|
});
|
||||||
@@ -439,14 +420,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
userId: senderId,
|
userId: senderId,
|
||||||
userName: senderName,
|
userName: senderName,
|
||||||
});
|
});
|
||||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
authorizers: [
|
authorizers: [
|
||||||
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (isRoom && allowTextCommands && hasControlCommand(bodyText, cfg) && !commandAuthorized) {
|
if (
|
||||||
logVerbose(`matrix: drop control command from unauthorized sender ${senderId}`);
|
isRoom &&
|
||||||
|
allowTextCommands &&
|
||||||
|
core.channel.text.hasControlCommand(bodyText, cfg) &&
|
||||||
|
!commandAuthorized
|
||||||
|
) {
|
||||||
|
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const shouldRequireMention = isRoom
|
const shouldRequireMention = isRoom
|
||||||
@@ -465,7 +451,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
!wasMentioned &&
|
!wasMentioned &&
|
||||||
!hasExplicitMention &&
|
!hasExplicitMention &&
|
||||||
commandAuthorized &&
|
commandAuthorized &&
|
||||||
hasControlCommand(bodyText);
|
core.channel.text.hasControlCommand(bodyText);
|
||||||
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
|
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
|
||||||
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
|
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
|
||||||
return;
|
return;
|
||||||
@@ -482,14 +468,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
|
|
||||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||||
const body = formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Matrix",
|
channel: "Matrix",
|
||||||
from: envelopeFrom,
|
from: envelopeFrom,
|
||||||
timestamp: event.getTs() ?? undefined,
|
timestamp: event.getTs() ?? undefined,
|
||||||
body: textWithId,
|
body: textWithId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const route = resolveAgentRoute({
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
peer: {
|
peer: {
|
||||||
@@ -499,7 +485,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
});
|
});
|
||||||
|
|
||||||
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
||||||
const ctxPayload = finalizeInboundContext({
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
Body: body,
|
Body: body,
|
||||||
RawBody: bodyText,
|
RawBody: bodyText,
|
||||||
CommandBody: bodyText,
|
CommandBody: bodyText,
|
||||||
@@ -531,10 +517,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
OriginatingTo: `room:${roomId}`,
|
OriginatingTo: `room:${roomId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = resolveStorePath(cfg.session?.store, {
|
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
});
|
||||||
void recordSessionMetaFromInbound({
|
void core.channel.session.recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
@@ -546,7 +532,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isDirectMessage) {
|
if (isDirectMessage) {
|
||||||
await updateLastRoute({
|
await core.channel.session.updateLastRoute({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: route.mainSessionKey,
|
sessionKey: route.mainSessionKey,
|
||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
@@ -556,10 +542,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldLogVerbose()) {
|
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||||
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
||||||
logVerbose(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||||
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||||
@@ -577,20 +561,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
};
|
};
|
||||||
if (shouldAckReaction() && messageId) {
|
if (shouldAckReaction() && messageId) {
|
||||||
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
|
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
|
||||||
logVerbose(`matrix react failed for room ${roomId}: ${String(err)}`);
|
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const replyTarget = ctxPayload.To;
|
const replyTarget = ctxPayload.To;
|
||||||
if (!replyTarget) {
|
if (!replyTarget) {
|
||||||
runtime.error?.(danger("matrix: missing reply target"));
|
runtime.error?.("matrix: missing reply target");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let didSendReply = false;
|
let didSendReply = false;
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
await deliverMatrixReplies({
|
await deliverMatrixReplies({
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
@@ -604,13 +588,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
didSendReply = true;
|
didSendReply = true;
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(danger(`matrix ${info.kind} reply failed: ${String(err)}`));
|
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
|
||||||
},
|
},
|
||||||
onReplyStart: () => sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
|
onReplyStart: () => sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
|
||||||
onIdle: () => sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
|
onIdle: () => sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { queuedFinal, counts } = await dispatchReplyFromConfig({
|
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
@@ -622,19 +606,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
markDispatchIdle();
|
markDispatchIdle();
|
||||||
if (!queuedFinal) return;
|
if (!queuedFinal) return;
|
||||||
didSendReply = true;
|
didSendReply = true;
|
||||||
if (shouldLogVerbose()) {
|
const finalCount = counts.final;
|
||||||
const finalCount = counts.final;
|
logVerboseMessage(
|
||||||
logVerbose(`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`);
|
`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||||
}
|
);
|
||||||
if (didSendReply) {
|
if (didSendReply) {
|
||||||
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
||||||
enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, {
|
core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, {
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
|
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error?.(danger(`matrix handler failed: ${String(err)}`));
|
runtime.error?.(`matrix handler failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { MatrixClient } from "matrix-js-sdk";
|
import type { MatrixClient } from "matrix-js-sdk";
|
||||||
|
|
||||||
import { saveMediaBuffer } from "clawdbot/plugin-sdk";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
async function fetchMatrixMediaBuffer(params: {
|
async function fetchMatrixMediaBuffer(params: {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -49,7 +49,12 @@ export async function downloadMatrixMedia(params: {
|
|||||||
});
|
});
|
||||||
if (!fetched) return null;
|
if (!fetched) return null;
|
||||||
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
||||||
const saved = await saveMediaBuffer(fetched.buffer, headerType, "inbound", params.maxBytes);
|
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
||||||
|
fetched.buffer,
|
||||||
|
headerType,
|
||||||
|
"inbound",
|
||||||
|
params.maxBytes,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
contentType: saved.contentType,
|
contentType: saved.contentType,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
||||||
|
|
||||||
import { matchesMentionPatterns } from "clawdbot/plugin-sdk";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
export function resolveMentions(params: {
|
export function resolveMentions(params: {
|
||||||
content: RoomMessageEventContent;
|
content: RoomMessageEventContent;
|
||||||
@@ -17,6 +17,9 @@ export function resolveMentions(params: {
|
|||||||
const wasMentioned =
|
const wasMentioned =
|
||||||
Boolean(mentions?.room) ||
|
Boolean(mentions?.room) ||
|
||||||
(params.userId ? mentionedUsers.has(params.userId) : false) ||
|
(params.userId ? mentionedUsers.has(params.userId) : false) ||
|
||||||
matchesMentionPatterns(params.text ?? "", params.mentionRegexes);
|
getMatrixRuntime().channel.mentions.matchesMentionPatterns(
|
||||||
|
params.text ?? "",
|
||||||
|
params.mentionRegexes,
|
||||||
|
);
|
||||||
return { wasMentioned, hasExplicitMention: Boolean(mentions) };
|
return { wasMentioned, hasExplicitMention: Boolean(mentions) };
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
import type { MatrixClient } from "matrix-js-sdk";
|
import type { MatrixClient } from "matrix-js-sdk";
|
||||||
|
|
||||||
import {
|
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
chunkMarkdownText,
|
|
||||||
danger,
|
|
||||||
logVerbose,
|
|
||||||
type ReplyPayload,
|
|
||||||
type RuntimeEnv,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
|
||||||
import { sendMessageMatrix } from "../send.js";
|
import { sendMessageMatrix } from "../send.js";
|
||||||
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
export async function deliverMatrixReplies(params: {
|
export async function deliverMatrixReplies(params: {
|
||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
@@ -18,6 +13,12 @@ export async function deliverMatrixReplies(params: {
|
|||||||
replyToMode: "off" | "first" | "all";
|
replyToMode: "off" | "first" | "all";
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
const core = getMatrixRuntime();
|
||||||
|
const logVerbose = (message: string) => {
|
||||||
|
if (core.logging.shouldLogVerbose()) {
|
||||||
|
params.runtime.log?.(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||||
let hasReplied = false;
|
let hasReplied = false;
|
||||||
for (const reply of params.replies) {
|
for (const reply of params.replies) {
|
||||||
@@ -27,7 +28,7 @@ export async function deliverMatrixReplies(params: {
|
|||||||
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
|
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
params.runtime.error?.(danger("matrix reply missing text/media"));
|
params.runtime.error?.("matrix reply missing text/media");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const replyToIdRaw = reply.replyToId?.trim();
|
const replyToIdRaw = reply.replyToId?.trim();
|
||||||
@@ -42,7 +43,7 @@ export async function deliverMatrixReplies(params: {
|
|||||||
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
||||||
|
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
for (const chunk of chunkMarkdownText(reply.text ?? "", chunkLimit)) {
|
for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
await sendMessageMatrix(params.roomId, trimmed, {
|
await sendMessageMatrix(params.roomId, trimmed, {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
import { setMatrixRuntime } from "../runtime.js";
|
||||||
|
|
||||||
vi.mock("matrix-js-sdk", () => ({
|
vi.mock("matrix-js-sdk", () => ({
|
||||||
EventType: {
|
EventType: {
|
||||||
Direct: "m.direct",
|
Direct: "m.direct",
|
||||||
@@ -18,21 +21,33 @@ vi.mock("matrix-js-sdk", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("clawdbot/plugin-sdk", () => ({
|
const loadWebMediaMock = vi.fn().mockResolvedValue({
|
||||||
loadConfig: () => ({}),
|
buffer: Buffer.from("media"),
|
||||||
resolveTextChunkLimit: () => 4000,
|
fileName: "photo.png",
|
||||||
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
contentType: "image/png",
|
||||||
loadWebMedia: vi.fn().mockResolvedValue({
|
kind: "image",
|
||||||
buffer: Buffer.from("media"),
|
});
|
||||||
fileName: "photo.png",
|
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
|
||||||
contentType: "image/png",
|
const resizeToJpegMock = vi.fn();
|
||||||
kind: "image",
|
|
||||||
}),
|
const runtimeStub = {
|
||||||
mediaKindFromMime: () => "image",
|
config: {
|
||||||
isVoiceCompatibleAudio: () => false,
|
loadConfig: () => ({}),
|
||||||
getImageMetadata: vi.fn().mockResolvedValue(null),
|
},
|
||||||
resizeToJpeg: vi.fn(),
|
media: {
|
||||||
}));
|
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
||||||
|
mediaKindFromMime: () => "image",
|
||||||
|
isVoiceCompatibleAudio: () => false,
|
||||||
|
getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args),
|
||||||
|
resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args),
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
text: {
|
||||||
|
resolveTextChunkLimit: () => 4000,
|
||||||
|
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime;
|
||||||
|
|
||||||
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
||||||
|
|
||||||
@@ -50,11 +65,13 @@ const makeClient = () => {
|
|||||||
|
|
||||||
describe("sendMessageMatrix media", () => {
|
describe("sendMessageMatrix media", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
setMatrixRuntime(runtimeStub);
|
||||||
({ sendMessageMatrix } = await import("./send.js"));
|
({ sendMessageMatrix } = await import("./send.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
setMatrixRuntime(runtimeStub);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uploads media with url payloads", async () => {
|
it("uploads media with url payloads", async () => {
|
||||||
|
|||||||
@@ -5,17 +5,8 @@ import type {
|
|||||||
ReactionEventContent,
|
ReactionEventContent,
|
||||||
} from "matrix-js-sdk/lib/@types/events.js";
|
} from "matrix-js-sdk/lib/@types/events.js";
|
||||||
|
|
||||||
import {
|
import type { PollInput } from "clawdbot/plugin-sdk";
|
||||||
chunkMarkdownText,
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
getImageMetadata,
|
|
||||||
isVoiceCompatibleAudio,
|
|
||||||
loadConfig,
|
|
||||||
loadWebMedia,
|
|
||||||
mediaKindFromMime,
|
|
||||||
type PollInput,
|
|
||||||
resolveTextChunkLimit,
|
|
||||||
resizeToJpeg,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
|
||||||
import { getActiveMatrixClient } from "./active-client.js";
|
import { getActiveMatrixClient } from "./active-client.js";
|
||||||
import {
|
import {
|
||||||
createMatrixClient,
|
createMatrixClient,
|
||||||
@@ -29,6 +20,7 @@ import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
|
|||||||
import type { CoreConfig } from "../types.js";
|
import type { CoreConfig } from "../types.js";
|
||||||
|
|
||||||
const MATRIX_TEXT_LIMIT = 4000;
|
const MATRIX_TEXT_LIMIT = 4000;
|
||||||
|
const getCore = () => getMatrixRuntime();
|
||||||
|
|
||||||
type MatrixDirectAccountData = AccountDataEvents[EventType.Direct];
|
type MatrixDirectAccountData = AccountDataEvents[EventType.Direct];
|
||||||
|
|
||||||
@@ -65,7 +57,7 @@ function ensureNodeRuntime() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveMediaMaxBytes(): number | undefined {
|
function resolveMediaMaxBytes(): number | undefined {
|
||||||
const cfg = loadConfig() as CoreConfig;
|
const cfg = getCore().config.loadConfig() as CoreConfig;
|
||||||
if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
|
if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
|
||||||
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
|
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
|
||||||
}
|
}
|
||||||
@@ -224,7 +216,7 @@ function resolveMatrixMsgType(
|
|||||||
contentType?: string,
|
contentType?: string,
|
||||||
fileName?: string,
|
fileName?: string,
|
||||||
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
|
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
|
||||||
const kind = mediaKindFromMime(contentType ?? "");
|
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "image":
|
case "image":
|
||||||
return MsgType.Image;
|
return MsgType.Image;
|
||||||
@@ -243,7 +235,7 @@ function resolveMatrixVoiceDecision(opts: {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
}): { useVoice: boolean } {
|
}): { useVoice: boolean } {
|
||||||
if (!opts.wantsVoice) return { useVoice: false };
|
if (!opts.wantsVoice) return { useVoice: false };
|
||||||
if (isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
|
if (getCore().media.isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
|
||||||
return { useVoice: true };
|
return { useVoice: true };
|
||||||
}
|
}
|
||||||
return { useVoice: false };
|
return { useVoice: false };
|
||||||
@@ -256,19 +248,19 @@ async function prepareImageInfo(params: {
|
|||||||
buffer: Buffer;
|
buffer: Buffer;
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
}): Promise<MatrixImageInfo | undefined> {
|
}): Promise<MatrixImageInfo | undefined> {
|
||||||
const meta = await getImageMetadata(params.buffer).catch(() => null);
|
const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null);
|
||||||
if (!meta) return undefined;
|
if (!meta) return undefined;
|
||||||
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
|
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
|
||||||
const maxDim = Math.max(meta.width, meta.height);
|
const maxDim = Math.max(meta.width, meta.height);
|
||||||
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
||||||
try {
|
try {
|
||||||
const thumbBuffer = await resizeToJpeg({
|
const thumbBuffer = await getCore().media.resizeToJpeg({
|
||||||
buffer: params.buffer,
|
buffer: params.buffer,
|
||||||
maxSide: THUMBNAIL_MAX_SIDE,
|
maxSide: THUMBNAIL_MAX_SIDE,
|
||||||
quality: THUMBNAIL_QUALITY,
|
quality: THUMBNAIL_QUALITY,
|
||||||
withoutEnlargement: true,
|
withoutEnlargement: true,
|
||||||
});
|
});
|
||||||
const thumbMeta = await getImageMetadata(thumbBuffer).catch(() => null);
|
const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null);
|
||||||
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
|
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
|
||||||
type: "image/jpeg",
|
type: "image/jpeg",
|
||||||
name: "thumbnail.jpg",
|
name: "thumbnail.jpg",
|
||||||
@@ -352,10 +344,10 @@ export async function sendMessageMatrix(
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const roomId = await resolveMatrixRoomId(client, to);
|
const roomId = await resolveMatrixRoomId(client, to);
|
||||||
const cfg = loadConfig();
|
const cfg = getCore().config.loadConfig();
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "matrix");
|
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||||
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
||||||
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
|
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
|
||||||
const threadId = normalizeThreadId(opts.threadId);
|
const threadId = normalizeThreadId(opts.threadId);
|
||||||
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
|
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
|
||||||
const sendContent = (content: RoomMessageEventContent) =>
|
const sendContent = (content: RoomMessageEventContent) =>
|
||||||
@@ -364,7 +356,7 @@ export async function sendMessageMatrix(
|
|||||||
let lastMessageId = "";
|
let lastMessageId = "";
|
||||||
if (opts.mediaUrl) {
|
if (opts.mediaUrl) {
|
||||||
const maxBytes = resolveMediaMaxBytes();
|
const maxBytes = resolveMediaMaxBytes();
|
||||||
const media = await loadWebMedia(opts.mediaUrl, maxBytes);
|
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
|
||||||
const contentUri = await uploadFile(client, media.buffer, {
|
const contentUri = await uploadFile(client, media.buffer, {
|
||||||
contentType: media.contentType,
|
contentType: media.contentType,
|
||||||
filename: media.fileName,
|
filename: media.fileName,
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import {
|
|
||||||
createMemoryGetTool,
|
|
||||||
createMemorySearchTool,
|
|
||||||
registerMemoryCli,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
const memoryCorePlugin = {
|
const memoryCorePlugin = {
|
||||||
id: "memory-core",
|
id: "memory-core",
|
||||||
name: "Memory (Core)",
|
name: "Memory (Core)",
|
||||||
@@ -14,11 +8,11 @@ const memoryCorePlugin = {
|
|||||||
register(api: ClawdbotPluginApi) {
|
register(api: ClawdbotPluginApi) {
|
||||||
api.registerTool(
|
api.registerTool(
|
||||||
(ctx) => {
|
(ctx) => {
|
||||||
const memorySearchTool = createMemorySearchTool({
|
const memorySearchTool = api.runtime.tools.createMemorySearchTool({
|
||||||
config: ctx.config,
|
config: ctx.config,
|
||||||
agentSessionKey: ctx.sessionKey,
|
agentSessionKey: ctx.sessionKey,
|
||||||
});
|
});
|
||||||
const memoryGetTool = createMemoryGetTool({
|
const memoryGetTool = api.runtime.tools.createMemoryGetTool({
|
||||||
config: ctx.config,
|
config: ctx.config,
|
||||||
agentSessionKey: ctx.sessionKey,
|
agentSessionKey: ctx.sessionKey,
|
||||||
});
|
});
|
||||||
@@ -30,7 +24,7 @@ const memoryCorePlugin = {
|
|||||||
|
|
||||||
api.registerCli(
|
api.registerCli(
|
||||||
({ program }) => {
|
({ program }) => {
|
||||||
registerMemoryCli(program);
|
api.runtime.tools.registerMemoryCli(program);
|
||||||
},
|
},
|
||||||
{ commands: ["memory"] },
|
{ commands: ["memory"] },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,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:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export type MemoryConfig = {
|
||||||
|
embedding: {
|
||||||
|
provider: "openai";
|
||||||
|
model?: string;
|
||||||
|
apiKey: string;
|
||||||
|
};
|
||||||
|
dbPath?: string;
|
||||||
|
autoCapture?: boolean;
|
||||||
|
autoRecall?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MEMORY_CATEGORIES = ["preference", "fact", "decision", "entity", "other"] as const;
|
||||||
|
export type MemoryCategory = (typeof MEMORY_CATEGORIES)[number];
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = "text-embedding-3-small";
|
||||||
|
const DEFAULT_DB_PATH = join(homedir(), ".clawdbot", "memory", "lancedb");
|
||||||
|
|
||||||
|
const EMBEDDING_DIMENSIONS: Record<string, number> = {
|
||||||
|
"text-embedding-3-small": 1536,
|
||||||
|
"text-embedding-3-large": 3072,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function vectorDimsForModel(model: string): number {
|
||||||
|
const dims = EMBEDDING_DIMENSIONS[model];
|
||||||
|
if (!dims) {
|
||||||
|
throw new Error(`Unsupported embedding model: ${model}`);
|
||||||
|
}
|
||||||
|
return dims;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEnvVars(value: string): string {
|
||||||
|
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
|
||||||
|
const envValue = process.env[envVar];
|
||||||
|
if (!envValue) {
|
||||||
|
throw new Error(`Environment variable ${envVar} is not set`);
|
||||||
|
}
|
||||||
|
return envValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEmbeddingModel(embedding: Record<string, unknown>): string {
|
||||||
|
const model = typeof embedding.model === "string" ? embedding.model : DEFAULT_MODEL;
|
||||||
|
vectorDimsForModel(model);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const memoryConfigSchema = {
|
||||||
|
parse(value: unknown): MemoryConfig {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new Error("memory config required");
|
||||||
|
}
|
||||||
|
const cfg = value as Record<string, unknown>;
|
||||||
|
|
||||||
|
const embedding = cfg.embedding as Record<string, unknown> | undefined;
|
||||||
|
if (!embedding || typeof embedding.apiKey !== "string") {
|
||||||
|
throw new Error("embedding.apiKey is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = resolveEmbeddingModel(embedding);
|
||||||
|
|
||||||
|
return {
|
||||||
|
embedding: {
|
||||||
|
provider: "openai",
|
||||||
|
model,
|
||||||
|
apiKey: resolveEnvVars(embedding.apiKey),
|
||||||
|
},
|
||||||
|
dbPath: typeof cfg.dbPath === "string" ? cfg.dbPath : DEFAULT_DB_PATH,
|
||||||
|
autoCapture: cfg.autoCapture !== false,
|
||||||
|
autoRecall: cfg.autoRecall !== false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
uiHints: {
|
||||||
|
"embedding.apiKey": {
|
||||||
|
label: "OpenAI API Key",
|
||||||
|
sensitive: true,
|
||||||
|
placeholder: "sk-proj-...",
|
||||||
|
help: "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})",
|
||||||
|
},
|
||||||
|
"embedding.model": {
|
||||||
|
label: "Embedding Model",
|
||||||
|
placeholder: DEFAULT_MODEL,
|
||||||
|
help: "OpenAI embedding model to use",
|
||||||
|
},
|
||||||
|
dbPath: {
|
||||||
|
label: "Database Path",
|
||||||
|
placeholder: "~/.clawdbot/memory/lancedb",
|
||||||
|
advanced: true,
|
||||||
|
},
|
||||||
|
autoCapture: {
|
||||||
|
label: "Auto-Capture",
|
||||||
|
help: "Automatically capture important information from conversations",
|
||||||
|
},
|
||||||
|
autoRecall: {
|
||||||
|
label: "Auto-Recall",
|
||||||
|
help: "Automatically inject relevant memories into context",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* Memory Plugin E2E Tests
|
||||||
|
*
|
||||||
|
* Tests the memory plugin functionality including:
|
||||||
|
* - Plugin registration and configuration
|
||||||
|
* - Memory storage and retrieval
|
||||||
|
* - Auto-recall via hooks
|
||||||
|
* - Auto-capture filtering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
|
||||||
|
// Skip if no OpenAI API key
|
||||||
|
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||||
|
const describeWithKey = OPENAI_API_KEY ? describe : describe.skip;
|
||||||
|
|
||||||
|
describeWithKey("memory plugin e2e", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let dbPath: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-memory-test-"));
|
||||||
|
dbPath = path.join(tmpDir, "lancedb");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (tmpDir) {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("memory plugin registers and initializes correctly", async () => {
|
||||||
|
// Dynamic import to avoid loading LanceDB when not testing
|
||||||
|
const { default: memoryPlugin } = await import("./index.js");
|
||||||
|
|
||||||
|
expect(memoryPlugin.id).toBe("memory");
|
||||||
|
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
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
import { setMSTeamsRuntime } from "./runtime.js";
|
||||||
|
|
||||||
const detectMimeMock = vi.fn(async () => "image/png");
|
const detectMimeMock = vi.fn(async () => "image/png");
|
||||||
const saveMediaBufferMock = vi.fn(async () => ({
|
const saveMediaBufferMock = vi.fn(async () => ({
|
||||||
path: "/tmp/saved.png",
|
path: "/tmp/saved.png",
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("clawdbot/plugin-sdk", () => ({
|
const runtimeStub = {
|
||||||
detectMime: (...args: unknown[]) => detectMimeMock(...args),
|
media: {
|
||||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
|
detectMime: (...args: unknown[]) => detectMimeMock(...args),
|
||||||
}));
|
},
|
||||||
|
channel: {
|
||||||
|
media: {
|
||||||
|
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime;
|
||||||
|
|
||||||
describe("msteams attachments", () => {
|
describe("msteams attachments", () => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -19,6 +28,7 @@ describe("msteams attachments", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
detectMimeMock.mockClear();
|
detectMimeMock.mockClear();
|
||||||
saveMediaBufferMock.mockClear();
|
saveMediaBufferMock.mockClear();
|
||||||
|
setMSTeamsRuntime(runtimeStub);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildMSTeamsAttachmentPlaceholder", () => {
|
describe("buildMSTeamsAttachmentPlaceholder", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk";
|
import { getMSTeamsRuntime } from "../runtime.js";
|
||||||
import {
|
import {
|
||||||
extractInlineImageCandidates,
|
extractInlineImageCandidates,
|
||||||
inferPlaceholder,
|
inferPlaceholder,
|
||||||
@@ -141,7 +141,7 @@ export async function downloadMSTeamsImageAttachments(params: {
|
|||||||
if (inline.kind !== "data") continue;
|
if (inline.kind !== "data") continue;
|
||||||
if (inline.data.byteLength > params.maxBytes) continue;
|
if (inline.data.byteLength > params.maxBytes) continue;
|
||||||
try {
|
try {
|
||||||
const saved = await saveMediaBuffer(
|
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||||
inline.data,
|
inline.data,
|
||||||
inline.contentType,
|
inline.contentType,
|
||||||
"inbound",
|
"inbound",
|
||||||
@@ -167,12 +167,12 @@ export async function downloadMSTeamsImageAttachments(params: {
|
|||||||
if (!res.ok) continue;
|
if (!res.ok) continue;
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
if (buffer.byteLength > params.maxBytes) continue;
|
if (buffer.byteLength > params.maxBytes) continue;
|
||||||
const mime = await detectMime({
|
const mime = await getMSTeamsRuntime().media.detectMime({
|
||||||
buffer,
|
buffer,
|
||||||
headerMime: res.headers.get("content-type"),
|
headerMime: res.headers.get("content-type"),
|
||||||
filePath: candidate.fileHint ?? candidate.url,
|
filePath: candidate.fileHint ?? candidate.url,
|
||||||
});
|
});
|
||||||
const saved = await saveMediaBuffer(
|
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||||
buffer,
|
buffer,
|
||||||
mime ?? candidate.contentTypeHint,
|
mime ?? candidate.contentTypeHint,
|
||||||
"inbound",
|
"inbound",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk";
|
import { getMSTeamsRuntime } from "../runtime.js";
|
||||||
import { downloadMSTeamsImageAttachments } from "./download.js";
|
import { downloadMSTeamsImageAttachments } from "./download.js";
|
||||||
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -154,13 +154,13 @@ async function downloadGraphHostedImages(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (buffer.byteLength > params.maxBytes) continue;
|
if (buffer.byteLength > params.maxBytes) continue;
|
||||||
const mime = await detectMime({
|
const mime = await getMSTeamsRuntime().media.detectMime({
|
||||||
buffer,
|
buffer,
|
||||||
headerMime: item.contentType ?? undefined,
|
headerMime: item.contentType ?? undefined,
|
||||||
});
|
});
|
||||||
if (mime && !mime.startsWith("image/")) continue;
|
if (mime && !mime.startsWith("image/")) continue;
|
||||||
try {
|
try {
|
||||||
const saved = await saveMediaBuffer(
|
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||||
buffer,
|
buffer,
|
||||||
mime ?? item.contentType ?? undefined,
|
mime ?? item.contentType ?? undefined,
|
||||||
"inbound",
|
"inbound",
|
||||||
|
|||||||
@@ -2,12 +2,29 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
import type { StoredConversationReference } from "./conversation-store.js";
|
import type { StoredConversationReference } from "./conversation-store.js";
|
||||||
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
||||||
|
import { setMSTeamsRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
const runtimeStub = {
|
||||||
|
state: {
|
||||||
|
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
|
||||||
|
const override = env.CLAWDBOT_STATE_DIR?.trim();
|
||||||
|
if (override) return override;
|
||||||
|
const resolvedHome = homedir ? homedir() : os.homedir();
|
||||||
|
return path.join(resolvedHome, ".clawdbot");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime;
|
||||||
|
|
||||||
describe("msteams conversation store (fs)", () => {
|
describe("msteams conversation store (fs)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setMSTeamsRuntime(runtimeStub);
|
||||||
|
});
|
||||||
|
|
||||||
it("filters and prunes expired entries (but keeps legacy ones)", async () => {
|
it("filters and prunes expired entries (but keeps legacy ones)", async () => {
|
||||||
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-msteams-store-"));
|
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-msteams-store-"));
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { SILENT_REPLY_TOKEN } from "clawdbot/plugin-sdk";
|
import { SILENT_REPLY_TOKEN, type PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
import type { StoredConversationReference } from "./conversation-store.js";
|
import type { StoredConversationReference } from "./conversation-store.js";
|
||||||
import {
|
import {
|
||||||
type MSTeamsAdapter,
|
type MSTeamsAdapter,
|
||||||
renderReplyPayloadsToMessages,
|
renderReplyPayloadsToMessages,
|
||||||
sendMSTeamsMessages,
|
sendMSTeamsMessages,
|
||||||
} from "./messenger.js";
|
} from "./messenger.js";
|
||||||
|
import { setMSTeamsRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
const runtimeStub = {
|
||||||
|
channel: {
|
||||||
|
text: {
|
||||||
|
chunkMarkdownText: (text: string, limit: number) => {
|
||||||
|
if (!text) return [];
|
||||||
|
if (limit <= 0 || text.length <= limit) return [text];
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let index = 0; index < text.length; index += limit) {
|
||||||
|
chunks.push(text.slice(index, index + limit));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime;
|
||||||
|
|
||||||
describe("msteams messenger", () => {
|
describe("msteams messenger", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setMSTeamsRuntime(runtimeStub);
|
||||||
|
});
|
||||||
|
|
||||||
describe("renderReplyPayloadsToMessages", () => {
|
describe("renderReplyPayloadsToMessages", () => {
|
||||||
it("filters silent replies", () => {
|
it("filters silent replies", () => {
|
||||||
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
|
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
chunkMarkdownText,
|
|
||||||
isSilentReplyText,
|
isSilentReplyText,
|
||||||
type MSTeamsReplyStyle,
|
type MSTeamsReplyStyle,
|
||||||
type ReplyPayload,
|
type ReplyPayload,
|
||||||
@@ -7,6 +6,7 @@ import {
|
|||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
import type { StoredConversationReference } from "./conversation-store.js";
|
import type { StoredConversationReference } from "./conversation-store.js";
|
||||||
import { classifyMSTeamsSendError } from "./errors.js";
|
import { classifyMSTeamsSendError } from "./errors.js";
|
||||||
|
import { getMSTeamsRuntime } from "./runtime.js";
|
||||||
|
|
||||||
type SendContext = {
|
type SendContext = {
|
||||||
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
||||||
@@ -108,7 +108,7 @@ function pushTextMessages(
|
|||||||
) {
|
) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (opts.chunkText) {
|
if (opts.chunkText) {
|
||||||
for (const chunk of chunkMarkdownText(text, opts.chunkLimit)) {
|
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
||||||
out.push(trimmed);
|
out.push(trimmed);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
import { danger } from "clawdbot/plugin-sdk";
|
|
||||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||||
import type { MSTeamsAdapter } from "./messenger.js";
|
import type { MSTeamsAdapter } from "./messenger.js";
|
||||||
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
|
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
|
||||||
@@ -42,7 +41,7 @@ export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
|
|||||||
try {
|
try {
|
||||||
await handleTeamsMessage(context as MSTeamsTurnContext);
|
await handleTeamsMessage(context as MSTeamsTurnContext);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
deps.runtime.error?.(danger(`msteams handler failed: ${String(err)}`));
|
deps.runtime.error?.(`msteams handler failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,25 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
clearHistoryEntries,
|
clearHistoryEntries,
|
||||||
createInboundDebouncer,
|
|
||||||
danger,
|
|
||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
readChannelAllowFromStore,
|
|
||||||
recordSessionMetaFromInbound,
|
|
||||||
recordPendingHistoryEntry,
|
recordPendingHistoryEntry,
|
||||||
resolveAgentRoute,
|
|
||||||
resolveCommandAuthorizedFromAuthorizers,
|
|
||||||
resolveInboundDebounceMs,
|
|
||||||
resolveMentionGating,
|
resolveMentionGating,
|
||||||
resolveStorePath,
|
|
||||||
dispatchReplyFromConfig,
|
|
||||||
finalizeInboundContext,
|
|
||||||
formatAgentEnvelope,
|
|
||||||
formatAllowlistMatchMeta,
|
formatAllowlistMatchMeta,
|
||||||
hasControlCommand,
|
|
||||||
logVerbose,
|
|
||||||
shouldLogVerbose,
|
|
||||||
upsertChannelPairingRequest,
|
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
@@ -50,6 +35,7 @@ import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
|
|||||||
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
|
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
|
||||||
import type { MSTeamsTurnContext } from "../sdk-types.js";
|
import type { MSTeamsTurnContext } from "../sdk-types.js";
|
||||||
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
|
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
|
||||||
|
import { getMSTeamsRuntime } from "../runtime.js";
|
||||||
|
|
||||||
export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||||
const {
|
const {
|
||||||
@@ -64,6 +50,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
pollStore,
|
pollStore,
|
||||||
log,
|
log,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
const core = getMSTeamsRuntime();
|
||||||
|
const logVerboseMessage = (message: string) => {
|
||||||
|
if (core.logging.shouldLogVerbose()) {
|
||||||
|
log.debug(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
const msteamsCfg = cfg.channels?.msteams;
|
const msteamsCfg = cfg.channels?.msteams;
|
||||||
const historyLimit = Math.max(
|
const historyLimit = Math.max(
|
||||||
0,
|
0,
|
||||||
@@ -72,7 +64,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
);
|
);
|
||||||
const conversationHistories = new Map<string, HistoryEntry[]>();
|
const conversationHistories = new Map<string, HistoryEntry[]>();
|
||||||
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "msteams" });
|
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
||||||
|
cfg,
|
||||||
|
channel: "msteams",
|
||||||
|
});
|
||||||
|
|
||||||
type MSTeamsDebounceEntry = {
|
type MSTeamsDebounceEntry = {
|
||||||
context: MSTeamsTurnContext;
|
context: MSTeamsTurnContext;
|
||||||
@@ -126,7 +121,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
|
|
||||||
const senderName = from.name ?? from.id;
|
const senderName = from.name ?? from.id;
|
||||||
const senderId = from.aadObjectId ?? from.id;
|
const senderId = from.aadObjectId ?? from.id;
|
||||||
const storedAllowFrom = await readChannelAllowFromStore("msteams").catch(() => []);
|
const storedAllowFrom = await core.channel.pairing
|
||||||
|
.readAllowFromStore("msteams")
|
||||||
|
.catch(() => []);
|
||||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||||
|
|
||||||
// Check DM policy for direct messages.
|
// Check DM policy for direct messages.
|
||||||
@@ -151,7 +148,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
|
|
||||||
if (!allowMatch.allowed) {
|
if (!allowMatch.allowed) {
|
||||||
if (dmPolicy === "pairing") {
|
if (dmPolicy === "pairing") {
|
||||||
const request = await upsertChannelPairingRequest({
|
const request = await core.channel.pairing.upsertPairingRequest({
|
||||||
channel: "msteams",
|
channel: "msteams",
|
||||||
id: senderId,
|
id: senderId,
|
||||||
meta: { name: senderName },
|
meta: { name: senderName },
|
||||||
@@ -254,15 +251,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
senderId,
|
senderId,
|
||||||
senderName,
|
senderName,
|
||||||
});
|
});
|
||||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
authorizers: [
|
authorizers: [
|
||||||
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
||||||
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (hasControlCommand(text, cfg) && !commandAuthorized) {
|
if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) {
|
||||||
logVerbose(`msteams: drop control command from unauthorized sender ${senderId}`);
|
logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +326,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
: `msteams:group:${conversationId}`;
|
: `msteams:group:${conversationId}`;
|
||||||
const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`;
|
const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`;
|
||||||
|
|
||||||
const route = resolveAgentRoute({
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "msteams",
|
channel: "msteams",
|
||||||
peer: {
|
peer: {
|
||||||
@@ -343,7 +340,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
? `Teams DM from ${senderName}`
|
? `Teams DM from ${senderName}`
|
||||||
: `Teams message in ${conversationType} from ${senderName}`;
|
: `Teams message in ${conversationType} from ${senderName}`;
|
||||||
|
|
||||||
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
|
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
|
||||||
});
|
});
|
||||||
@@ -409,7 +406,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
|
|
||||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||||
const body = formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Teams",
|
channel: "Teams",
|
||||||
from: envelopeFrom,
|
from: envelopeFrom,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -425,7 +422,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
limit: historyLimit,
|
limit: historyLimit,
|
||||||
currentMessage: combinedBody,
|
currentMessage: combinedBody,
|
||||||
formatEntry: (entry) =>
|
formatEntry: (entry) =>
|
||||||
formatAgentEnvelope({
|
core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Teams",
|
channel: "Teams",
|
||||||
from: conversationType,
|
from: conversationType,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
@@ -434,7 +431,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctxPayload = finalizeInboundContext({
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
Body: combinedBody,
|
Body: combinedBody,
|
||||||
RawBody: rawBody,
|
RawBody: rawBody,
|
||||||
CommandBody: rawBody,
|
CommandBody: rawBody,
|
||||||
@@ -458,20 +455,18 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
...mediaPayload,
|
...mediaPayload,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = resolveStorePath(cfg.session?.store, {
|
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
});
|
||||||
void recordSessionMetaFromInbound({
|
void core.channel.session.recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
logVerbose(`msteams: failed updating session meta: ${String(err)}`);
|
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (shouldLogVerbose()) {
|
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
||||||
logVerbose(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
|
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -493,7 +488,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
|
|
||||||
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
||||||
try {
|
try {
|
||||||
const { queuedFinal, counts } = await dispatchReplyFromConfig({
|
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
@@ -513,18 +508,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (shouldLogVerbose()) {
|
const finalCount = counts.final;
|
||||||
const finalCount = counts.final;
|
logVerboseMessage(
|
||||||
logVerbose(
|
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
||||||
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isRoomish && historyKey && historyLimit > 0) {
|
if (isRoomish && historyKey && historyLimit > 0) {
|
||||||
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
|
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("dispatch failed", { error: String(err) });
|
log.error("dispatch failed", { error: String(err) });
|
||||||
runtime.error?.(danger(`msteams dispatch failed: ${String(err)}`));
|
runtime.error?.(`msteams dispatch failed: ${String(err)}`);
|
||||||
try {
|
try {
|
||||||
await context.sendActivity(
|
await context.sendActivity(
|
||||||
`⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`,
|
`⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
@@ -535,7 +528,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const inboundDebouncer = createInboundDebouncer<MSTeamsDebounceEntry>({
|
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<MSTeamsDebounceEntry>({
|
||||||
debounceMs: inboundDebounceMs,
|
debounceMs: inboundDebounceMs,
|
||||||
buildKey: (entry) => {
|
buildKey: (entry) => {
|
||||||
const conversationId = normalizeMSTeamsConversationId(
|
const conversationId = normalizeMSTeamsConversationId(
|
||||||
@@ -549,7 +542,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
shouldDebounce: (entry) => {
|
shouldDebounce: (entry) => {
|
||||||
if (!entry.text.trim()) return false;
|
if (!entry.text.trim()) return false;
|
||||||
if (entry.attachments.length > 0) return false;
|
if (entry.attachments.length > 0) return false;
|
||||||
return !hasControlCommand(entry.text, cfg);
|
return !core.channel.text.hasControlCommand(entry.text, cfg);
|
||||||
},
|
},
|
||||||
onFlush: async (entries) => {
|
onFlush: async (entries) => {
|
||||||
const last = entries.at(-1);
|
const last = entries.at(-1);
|
||||||
@@ -579,7 +572,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
runtime.error?.(danger(`msteams debounce flush failed: ${String(err)}`));
|
runtime.error?.(`msteams debounce flush failed: ${String(err)}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import {
|
import {
|
||||||
getChildLogger,
|
|
||||||
mergeAllowlist,
|
mergeAllowlist,
|
||||||
resolveTextChunkLimit,
|
|
||||||
summarizeMapping,
|
summarizeMapping,
|
||||||
type ClawdbotConfig,
|
type ClawdbotConfig,
|
||||||
type RuntimeEnv,
|
type RuntimeEnv,
|
||||||
@@ -19,8 +17,7 @@ import {
|
|||||||
} from "./resolve-allowlist.js";
|
} from "./resolve-allowlist.js";
|
||||||
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||||
import { resolveMSTeamsCredentials } from "./token.js";
|
import { resolveMSTeamsCredentials } from "./token.js";
|
||||||
|
import { getMSTeamsRuntime } from "./runtime.js";
|
||||||
const log = getChildLogger({ name: "msteams" });
|
|
||||||
|
|
||||||
export type MonitorMSTeamsOpts = {
|
export type MonitorMSTeamsOpts = {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
@@ -38,6 +35,8 @@ export type MonitorMSTeamsResult = {
|
|||||||
export async function monitorMSTeamsProvider(
|
export async function monitorMSTeamsProvider(
|
||||||
opts: MonitorMSTeamsOpts,
|
opts: MonitorMSTeamsOpts,
|
||||||
): Promise<MonitorMSTeamsResult> {
|
): Promise<MonitorMSTeamsResult> {
|
||||||
|
const core = getMSTeamsRuntime();
|
||||||
|
const log = core.logging.getChildLogger({ name: "msteams" });
|
||||||
let cfg = opts.cfg;
|
let cfg = opts.cfg;
|
||||||
let msteamsCfg = cfg.channels?.msteams;
|
let msteamsCfg = cfg.channels?.msteams;
|
||||||
if (!msteamsCfg?.enabled) {
|
if (!msteamsCfg?.enabled) {
|
||||||
@@ -197,7 +196,7 @@ export async function monitorMSTeamsProvider(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const port = msteamsCfg.webhook?.port ?? 3978;
|
const port = msteamsCfg.webhook?.port ?? 3978;
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "msteams");
|
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "msteams");
|
||||||
const MB = 1024 * 1024;
|
const MB = 1024 * 1024;
|
||||||
const agentDefaults = cfg.agents?.defaults;
|
const agentDefaults = cfg.agents?.defaults;
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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?",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
export function setMSTeamsRuntime(next: PluginRuntime) {
|
||||||
|
runtime = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMSTeamsRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("MSTeams runtime not initialized");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { 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" });
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
Reference in new issue
Block a user