Compare commits

..

3 Commits

Author SHA1 Message Date
Peter Steinberger
ad28889a3d docs: expand acp usage 2026-01-18 06:34:23 +00:00
Peter Steinberger
3bd7615c4f refactor: split acp mappers 2026-01-18 06:22:33 +00:00
Peter Steinberger
41fbcc405f feat: add acp bridge 2026-01-18 06:07:00 +00:00
336 changed files with 2263 additions and 14162 deletions

4
.gitmodules vendored Normal file
View File

@@ -0,0 +1,4 @@
[submodule "Peekaboo"]
path = Peekaboo
url = https://github.com/steipete/Peekaboo.git
branch = main

View File

@@ -2,44 +2,18 @@
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.
- Matrix: avoid redeclaring `allowFrom` in the monitor provider. (#1176) — thanks @sibbl.
## 2026.1.18-3
### Changes
- Exec: add host/security/ask routing for gateway + node exec.
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
- macOS: add approvals socket UI server + node exec lifecycle events.
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
- Nodes: add node daemon service install/status/start/stop/restart.
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
- Agents: auto-inject local image references for vision models and avoid reloading history images. (#1098) — thanks @tyler6204.
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
- CLI: add `clawdbot acp` ACP bridge for IDE integrations.
### 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.
- 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

1
Peekaboo Submodule

Submodule Peekaboo added at 5c195f5e46

View File

@@ -13,7 +13,7 @@ let package = Package(
.executable(name: "swabble", targets: ["SwabbleCLI"]),
],
dependencies: [
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
.package(path: "../Peekaboo/Commander"),
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
],
targets: [

View File

@@ -1,24 +1,6 @@
{
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
"pins" : [
{
"identity" : "axorcist",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/AXorcist.git",
"state" : {
"revision" : "c75d06f7f93e264a9786edc2b78c04973061cb2f",
"version" : "0.1.0"
}
},
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
"version" : "0.2.1"
}
},
{
"identity" : "elevenlabskit",
"kind" : "remoteSourceControl",
@@ -28,6 +10,15 @@
"version" : "0.1.0"
}
},
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattt/eventsource.git",
"state" : {
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
"version" : "1.3.0"
}
},
{
"identity" : "menubarextraaccess",
"kind" : "remoteSourceControl",
@@ -37,15 +28,6 @@
"version" : "1.2.2"
}
},
{
"identity" : "peekaboo",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Peekaboo.git",
"state" : {
"branch" : "main",
"revision" : "b2d0384d9f0f45b945d5f718f8a865bd574d83c2"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
@@ -64,6 +46,33 @@
"version" : "1.2.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
"version" : "1.5.1"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms",
"state" : {
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
"version" : "1.1.1"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"branch" : "main",
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
@@ -73,6 +82,24 @@
"version" : "1.3.2"
}
},
{
"identity" : "swift-configuration",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-configuration",
"state" : {
"revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749",
"version" : "1.0.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
"version" : "4.2.0"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
@@ -91,6 +118,24 @@
"version" : "1.1.1"
}
},
{
"identity" : "swift-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/modelcontextprotocol/swift-sdk.git",
"state" : {
"revision" : "c0407a0b52677cb395d824cac2879b963075ba8c",
"version" : "0.10.2"
}
},
{
"identity" : "swift-service-lifecycle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle",
"state" : {
"revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
"version" : "2.9.1"
}
},
{
"identity" : "swift-subprocess",
"kind" : "remoteSourceControl",

View File

@@ -20,9 +20,10 @@ let package = Package(
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
.package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"),
.package(path: "../shared/ClawdbotKit"),
.package(path: "../../Swabble"),
.package(path: "../../Peekaboo/Core/PeekabooCore"),
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
],
targets: [
.target(
@@ -60,8 +61,8 @@ let package = Package(
.product(name: "Subprocess", package: "swift-subprocess"),
.product(name: "Logging", package: "swift-log"),
.product(name: "Sparkle", package: "Sparkle"),
.product(name: "PeekabooBridge", package: "Peekaboo"),
.product(name: "PeekabooAutomationKit", package: "Peekaboo"),
.product(name: "PeekabooBridge", package: "PeekabooCore"),
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
],
exclude: [
"Resources/Info.plist",

View File

@@ -162,7 +162,7 @@ enum ExecApprovalsStore {
let data = try Data(contentsOf: url)
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
if decoded.version != 1 {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
return ExecApprovalsFile(version: 1, socket: decoded.socket, defaults: decoded.defaults, agents: decoded.agents)
}
return decoded
} catch {
@@ -204,7 +204,7 @@ enum ExecApprovalsStore {
}
static func resolve(agentId: String?) -> ExecApprovalsResolved {
let file = self.ensureFile()
var file = self.ensureFile()
let defaults = file.defaults ?? ExecApprovalsDefaults()
let resolvedDefaults = ExecApprovalsResolvedDefaults(
security: defaults.security ?? self.defaultSecurity,
@@ -397,32 +397,11 @@ struct ExecCommandResolution: Sendable {
let executableName: String
let cwd: String?
static func resolve(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?
) -> ExecCommandResolution? {
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
}
return self.resolve(command: command, cwd: cwd, env: env)
}
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
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 expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
let resolvedPath: String? = {
if hasPathSeparator {
@@ -440,20 +419,6 @@ struct ExecCommandResolution: Sendable {
return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
}
private static func parseFirstToken(_ command: String) -> String? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let first = trimmed.first else { return nil }
if first == "\"" || first == "'" {
let rest = trimmed.dropFirst()
if let end = rest.firstIndex(of: first) {
return String(rest[..<end])
}
return String(rest)
}
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
}
private static func searchPaths(from env: [String: String]?) -> [String] {
let raw = env?["PATH"]
if let raw, !raw.isEmpty {
@@ -474,12 +439,6 @@ enum ExecCommandFormatter {
return "\"\(escaped)\""
}.joined(separator: " ")
}
static func displayString(for argv: [String], rawCommand: String?) -> String {
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty { return trimmed }
return self.displayString(for: argv)
}
}
enum ExecAllowlistMatcher {
@@ -563,7 +522,7 @@ struct ExecEventPayload: Codable, Sendable {
guard !trimmed.isEmpty else { return nil }
if trimmed.count <= maxChars { return trimmed }
let suffix = trimmed.suffix(maxChars)
return "... (truncated) \(suffix)"
return " (truncated) \(suffix)"
}
}

View File

@@ -157,10 +157,9 @@ final class ExecApprovalsPromptServer {
}
}
enum ExecApprovalsPromptPresenter {
private enum ExecApprovalsPromptPresenter {
@MainActor
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow this command?"
@@ -206,7 +205,7 @@ enum ExecApprovalsPromptPresenter {
}
}
private final class ExecApprovalsSocketServer: @unchecked Sendable {
private final class ExecApprovalsSocketServer {
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
private let socketPath: String
private let token: String

View File

@@ -16,10 +16,6 @@ enum GatewayLaunchAgentManager {
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
_ = bundlePath
guard !CommandResolver.connectionModeIsRemote() else {
self.logger.info("launchd change skipped (remote mode)")
return nil
}
if enabled, self.isLaunchAgentWriteDisabled() {
self.logger.info("launchd enable skipped (disable marker set)")
return nil
@@ -116,9 +112,7 @@ extension GatewayLaunchAgentManager {
{
let command = CommandResolver.clawdbotCommand(
subcommand: "daemon",
extraArgs: self.withJsonFlag(args),
// Launchd management must always run locally, even if remote mode is configured.
configRoot: ["gateway": ["mode": "local"]])
extraArgs: self.withJsonFlag(args))
var env = ProcessInfo.processInfo.environment
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)

View File

@@ -114,9 +114,6 @@ final class GatewayProcessManager {
self.lastFailureReason = nil
self.status = .stopped
self.logger.info("gateway stop requested")
if CommandResolver.connectionModeIsRemote() {
return
}
let bundlePath = Bundle.main.bundleURL.path
Task {
_ = await GatewayLaunchAgentManager.set(

View File

@@ -432,7 +432,6 @@ actor MacNodeRuntime {
guard !command.isEmpty else {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
}
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: params.rawCommand)
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
@@ -445,12 +444,7 @@ actor MacNodeRuntime {
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
: self.mainSessionKey
let runId = UUID().uuidString
let env = Self.sanitizedEnv(params.env)
let resolution = ExecCommandResolution.resolve(
command: command,
rawCommand: params.rawCommand,
cwd: params.cwd,
env: env)
let resolution = ExecCommandResolution.resolve(command: command, cwd: params.cwd, env: params.env)
let allowlistMatch = security == .allowlist
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
: nil
@@ -469,7 +463,7 @@ actor MacNodeRuntime {
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
command: ExecCommandFormatter.displayString(for: command),
reason: "security=deny"))
return Self.errorResponse(
req,
@@ -483,11 +477,12 @@ actor MacNodeRuntime {
return false
}()
var approvedByAsk = false
if requiresAsk {
let decision: ExecApprovalDecision? = await ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: displayCommand,
let decision = await ExecApprovalsSocketClient.requestDecision(
socketPath: approvals.socketPath,
token: approvals.token,
request: ExecApprovalPromptRequest(
command: ExecCommandFormatter.displayString(for: command),
cwd: params.cwd,
host: "node",
security: security.rawValue,
@@ -503,40 +498,21 @@ actor MacNodeRuntime {
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
command: ExecCommandFormatter.displayString(for: command),
reason: "user-denied"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied")
case nil:
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 {
if askFallback == .deny || (askFallback == .allowlist && allowlistMatch == nil && !skillAllow) {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
command: ExecCommandFormatter.displayString(for: command),
reason: "approval-required"))
return Self.errorResponse(
req,
@@ -544,7 +520,6 @@ actor MacNodeRuntime {
message: "SYSTEM_RUN_DENIED: approval required")
}
case .allowAlways?:
approvedByAsk = true
if security == .allowlist {
let pattern = resolution?.resolvedPath ??
resolution?.rawExecutable ??
@@ -555,33 +530,20 @@ actor MacNodeRuntime {
}
}
case .allowOnce?:
approvedByAsk = true
break
}
}
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "allowlist-miss"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: allowlist miss")
}
if let match = allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: agentId,
pattern: match.pattern,
command: displayCommand,
command: ExecCommandFormatter.displayString(for: command),
resolvedPath: resolution?.resolvedPath)
}
let env = Self.sanitizedEnv(params.env)
if params.needsScreenRecording == true {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
@@ -592,7 +554,7 @@ actor MacNodeRuntime {
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
command: ExecCommandFormatter.displayString(for: command),
reason: "permission:screenRecording"))
return Self.errorResponse(
req,
@@ -608,23 +570,20 @@ actor MacNodeRuntime {
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand))
command: ExecCommandFormatter.displayString(for: command)))
let result = await ShellExecutor.runDetailed(
command: command,
cwd: params.cwd,
env: env,
timeout: timeoutSec)
let combined = [result.stdout, result.stderr, result.errorMessage]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: "\n")
let combined = [result.stdout, result.stderr, result.errorMessage].filter { !$0.isEmpty }.joined(separator: "\n")
await self.emitExecEvent(
"exec.finished",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
command: ExecCommandFormatter.displayString(for: command),
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,

View File

@@ -13,16 +13,18 @@ struct SystemRunSettingsView: View {
Text("Exec approvals")
.font(.body)
Spacer(minLength: 0)
Picker("Agent", selection: Binding(
get: { self.model.selectedAgentId },
set: { self.model.selectAgent($0) }))
{
ForEach(self.model.agentPickerIds, id: \.self) { id in
Text(self.model.label(for: id)).tag(id)
if self.model.agentIds.count > 1 {
Picker("Agent", selection: Binding(
get: { self.model.selectedAgentId },
set: { self.model.selectAgent($0) }))
{
ForEach(self.model.agentIds, id: \.self) { id in
Text(id).tag(id)
}
}
.pickerStyle(.menu)
.frame(width: 160, alignment: .trailing)
}
.pickerStyle(.menu)
.frame(width: 180, alignment: .trailing)
}
Picker("", selection: self.$tab) {
@@ -80,9 +82,7 @@ struct SystemRunSettingsView: View {
.labelsHidden()
.pickerStyle(.menu)
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.")
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.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -101,37 +101,31 @@ struct SystemRunSettingsView: View {
.foregroundStyle(.secondary)
}
if self.model.isDefaultsScope {
Text("Allowlists are per-agent. Select an agent to edit its allowlist.")
HStack(spacing: 8) {
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
.textFieldStyle(.roundedBorder)
Button("Add") {
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !pattern.isEmpty else { return }
self.model.addEntry(pattern)
self.newPattern = ""
}
.buttonStyle(.bordered)
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
if self.model.entries.isEmpty {
Text("No allowlisted commands yet.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
HStack(spacing: 8) {
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
.textFieldStyle(.roundedBorder)
Button("Add") {
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !pattern.isEmpty else { return }
self.model.addEntry(pattern)
self.newPattern = ""
}
.buttonStyle(.bordered)
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
if self.model.entries.isEmpty {
Text("No allowlisted commands yet.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
ExecAllowlistRow(
entry: Binding(
get: { self.model.entries[index] },
set: { self.model.updateEntry($0, at: index) }),
onRemove: { self.model.removeEntry(at: index) })
}
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) })
}
}
}
@@ -183,16 +177,8 @@ struct ExecAllowlistRow: View {
Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))")
.font(.caption)
.foregroundStyle(.secondary)
}
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)")
} else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
Text("Last used: \(lastUsedCommand)")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -215,7 +201,6 @@ struct ExecAllowlistRow: View {
@MainActor
@Observable
final class ExecApprovalsSettingsModel {
private static let defaultsScopeId = "__defaults__"
var agentIds: [String] = []
var selectedAgentId: String = "main"
var defaultAgentId: String = "main"
@@ -226,19 +211,6 @@ final class ExecApprovalsSettingsModel {
var entries: [ExecAllowlistEntry] = []
var skillBins: [String] = []
var agentPickerIds: [String] {
[Self.defaultsScopeId] + self.agentIds
}
var isDefaultsScope: Bool {
self.selectedAgentId == Self.defaultsScopeId
}
func label(for id: String) -> String {
if id == Self.defaultsScopeId { return "Defaults" }
return id
}
func refresh() async {
await self.refreshAgents()
self.loadSettings(for: self.selectedAgentId)
@@ -270,9 +242,6 @@ final class ExecApprovalsSettingsModel {
}
self.agentIds = ids
self.defaultAgentId = defaultId ?? "main"
if self.selectedAgentId == Self.defaultsScopeId {
return
}
if !self.agentIds.contains(self.selectedAgentId) {
self.selectedAgentId = self.defaultAgentId
}
@@ -285,15 +254,6 @@ final class ExecApprovalsSettingsModel {
}
func loadSettings(for agentId: String) {
if agentId == Self.defaultsScopeId {
let defaults = ExecApprovalsStore.resolveDefaults()
self.security = defaults.security
self.ask = defaults.ask
self.askFallback = defaults.askFallback
self.autoAllowSkills = defaults.autoAllowSkills
self.entries = []
return
}
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
self.security = resolved.agent.security
self.ask = resolved.agent.ask
@@ -305,61 +265,36 @@ final class ExecApprovalsSettingsModel {
func setSecurity(_ security: ExecSecurity) {
self.security = security
if self.isDefaultsScope {
ExecApprovalsStore.updateDefaults { defaults in
defaults.security = security
}
} else {
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.security = security
}
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.security = security
}
self.syncQuickMode()
}
func setAsk(_ ask: ExecAsk) {
self.ask = ask
if self.isDefaultsScope {
ExecApprovalsStore.updateDefaults { defaults in
defaults.ask = ask
}
} else {
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.ask = ask
}
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.ask = ask
}
self.syncQuickMode()
}
func setAskFallback(_ mode: ExecSecurity) {
self.askFallback = mode
if self.isDefaultsScope {
ExecApprovalsStore.updateDefaults { defaults in
defaults.askFallback = mode
}
} else {
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.askFallback = mode
}
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.askFallback = mode
}
}
func setAutoAllowSkills(_ enabled: Bool) {
self.autoAllowSkills = enabled
if self.isDefaultsScope {
ExecApprovalsStore.updateDefaults { defaults in
defaults.autoAllowSkills = enabled
}
} else {
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.autoAllowSkills = enabled
}
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.autoAllowSkills = enabled
}
Task { await self.refreshSkillBins(force: enabled) }
}
func addEntry(_ pattern: String) {
guard !self.isDefaultsScope else { return }
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
@@ -367,14 +302,12 @@ final class ExecApprovalsSettingsModel {
}
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
guard !self.isDefaultsScope else { return }
guard self.entries.indices.contains(index) else { return }
self.entries[index] = entry
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func removeEntry(at index: Int) {
guard !self.isDefaultsScope else { return }
guard self.entries.indices.contains(index) else { return }
self.entries.remove(at: index)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
@@ -390,10 +323,6 @@ final class ExecApprovalsSettingsModel {
}
private func syncQuickMode() {
if self.isDefaultsScope {
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
return
}
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
}

View File

@@ -760,10 +760,6 @@ public struct SessionsPatchParams: Codable, Sendable {
public let reasoninglevel: AnyCodable?
public let responseusage: 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 spawnedby: AnyCodable?
public let sendpolicy: AnyCodable?
@@ -777,10 +773,6 @@ public struct SessionsPatchParams: Codable, Sendable {
reasoninglevel: AnyCodable?,
responseusage: AnyCodable?,
elevatedlevel: AnyCodable?,
exechost: AnyCodable?,
execsecurity: AnyCodable?,
execask: AnyCodable?,
execnode: AnyCodable?,
model: AnyCodable?,
spawnedby: AnyCodable?,
sendpolicy: AnyCodable?,
@@ -793,10 +785,6 @@ public struct SessionsPatchParams: Codable, Sendable {
self.reasoninglevel = reasoninglevel
self.responseusage = responseusage
self.elevatedlevel = elevatedlevel
self.exechost = exechost
self.execsecurity = execsecurity
self.execask = execask
self.execnode = execnode
self.model = model
self.spawnedby = spawnedby
self.sendpolicy = sendpolicy
@@ -810,10 +798,6 @@ public struct SessionsPatchParams: Codable, Sendable {
case reasoninglevel = "reasoningLevel"
case responseusage = "responseUsage"
case elevatedlevel = "elevatedLevel"
case exechost = "execHost"
case execsecurity = "execSecurity"
case execask = "execAsk"
case execnode = "execNode"
case model
case spawnedby = "spawnedBy"
case sendpolicy = "sendPolicy"
@@ -1632,51 +1616,6 @@ public struct LogsTailResult: Codable, Sendable {
}
}
public struct ExecApprovalsGetParams: Codable, Sendable {
}
public struct ExecApprovalsSetParams: Codable, Sendable {
public let file: [String: AnyCodable]
public let basehash: String?
public init(
file: [String: AnyCodable],
basehash: String?
) {
self.file = file
self.basehash = basehash
}
private enum CodingKeys: String, CodingKey {
case file
case basehash = "baseHash"
}
}
public struct ExecApprovalsSnapshot: Codable, Sendable {
public let path: String
public let exists: Bool
public let hash: String
public let file: [String: AnyCodable]
public init(
path: String,
exists: Bool,
hash: String,
file: [String: AnyCodable]
) {
self.path = path
self.exists = exists
self.hash = hash
self.file = file
}
private enum CodingKeys: String, CodingKey {
case path
case exists
case hash
case file
}
}
public struct ChatHistoryParams: Codable, Sendable {
public let sessionkey: String
public let limit: Int?

View File

@@ -134,27 +134,4 @@ import Testing
#expect(script.contains("CLI="))
}
}
@Test func configRootLocalOverridesRemoteDefaults() async throws {
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
defaults.set("clawd@example.com:2222", forKey: remoteTargetKey)
let tmp = try makeTempDir()
CommandResolver.setProjectRoot(tmp.path)
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
try self.makeExec(at: clawdbotPath)
let cmd = CommandResolver.clawdbotCommand(
subcommand: "daemon",
defaults: defaults,
configRoot: ["gateway": ["mode": "local"]])
#expect(cmd.first == clawdbotPath.path)
#expect(cmd.count >= 2)
if cmd.count >= 2 {
#expect(cmd[1] == "daemon")
}
}
}

View File

@@ -20,7 +20,6 @@ public enum ClawdbotNotificationDelivery: String, Codable, Sendable {
public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
public var command: [String]
public var rawCommand: String?
public var cwd: String?
public var env: [String: String]?
public var timeoutMs: Int?
@@ -30,7 +29,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
public init(
command: [String],
rawCommand: String? = nil,
cwd: String? = nil,
env: [String: String]? = nil,
timeoutMs: Int? = nil,
@@ -39,7 +37,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
sessionKey: String? = nil)
{
self.command = command
self.rawCommand = rawCommand
self.cwd = cwd
self.env = env
self.timeoutMs = timeoutMs

View File

@@ -30,21 +30,6 @@ clawdbot acp --session-label "support inbox"
clawdbot acp --session agent:main:main --reset-session
```
## ACP client (debug)
Use the built-in ACP client to sanity-check the bridge without an IDE.
It spawns the ACP bridge and lets you type prompts interactively.
```bash
clawdbot acp client
# Point the spawned bridge at a remote Gateway
clawdbot acp client --server-args --url wss://gateway-host:18789 --token <token>
# Override the server command (default: clawdbot)
clawdbot acp client --server "node" --server-args dist/entry.js acp --url ws://127.0.0.1:19001
```
## How to use this
Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want
@@ -156,11 +141,3 @@ Learn more about session keys at [/concepts/session](/concepts/session).
- `--reset-session`: reset the session key before first use.
- `--no-prefix-cwd`: do not prefix prompts with the working directory.
- `--verbose, -v`: verbose logging to stderr.
### `acp client` options
- `--cwd <dir>`: working directory for the ACP session.
- `--server <command>`: ACP server command (default: `clawdbot`).
- `--server-args <args...>`: extra arguments passed to the ACP server.
- `--server-verbose`: enable verbose logging on the ACP server.
- `--verbose, -v`: verbose client logging.

View File

@@ -15,7 +15,6 @@ the configure wizard (same as `clawdbot configure`).
clawdbot config get browser.executablePath
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
clawdbot config set agents.defaults.heartbeat.every "2h"
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
clawdbot config unset tools.web.search.apiKey
```
@@ -28,13 +27,6 @@ clawdbot config get agents.defaults.workspace
clawdbot config get agents.list[0].id
```
Use the agent list index to target a specific agent:
```bash
clawdbot config get agents.list
clawdbot config set agents.list[1].tools.exec.node "node-id-or-name"
```
## Values
Values are parsed as JSON5 when possible; otherwise they are treated as strings.

View File

@@ -29,12 +29,11 @@ List all discovered hooks from workspace, managed, and bundled directories.
**Example output:**
```
Hooks (3/3 ready)
Hooks (2/2 ready)
Ready:
📝 command-logger ✓ - Log all command events to a centralized audit file
💾 session-memory ✓ - Save session context to memory when /new command is issued
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
```
**Example (verbose):**
@@ -272,4 +271,4 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
clawdbot hooks enable soul-evil
```
**See:** [SOUL Evil Hook](/hooks/soul-evil)
**See:** [soul-evil documentation](/hooks#soul-evil)

View File

@@ -33,7 +33,6 @@ This page describes the current CLI behavior. If commands change, update this do
- [`models`](/cli/models)
- [`memory`](/cli/memory)
- [`nodes`](/cli/nodes)
- [`node`](/cli/node)
- [`sandbox`](/cli/sandbox)
- [`tui`](/cli/tui)
- [`browser`](/cli/browser)
@@ -171,15 +170,21 @@ clawdbot [--dev] [--profile <name>] <command>
runs
run
nodes
node
start
daemon
status
install
uninstall
start
stop
restart
status
describe
list
pending
approve
reject
rename
invoke
run
notify
camera list|snap|clip
canvas snapshot|present|hide|navigate|eval
canvas a2ui push|reset
screen record
location get
browser
status
start
@@ -774,20 +779,6 @@ Subcommands:
All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
## Node host
`node` runs a **headless node host** or manages it as a background service. See
[`clawdbot node`](/cli/node).
Subcommands:
- `node start --host <gateway-host> --port 18790`
- `node 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` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).
@@ -804,7 +795,7 @@ Subcommands:
- `nodes reject <requestId>`
- `nodes rename --node <id|name|ip> --name <displayName>`
- `nodes invoke --node <id|name|ip> --command <command> [--params <json>] [--invoke-timeout <ms>] [--idempotency-key <key>]`
- `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac node or headless node host)
- `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (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:

View File

@@ -28,5 +28,3 @@ clawdbot memory search "release checklist"
## Options
- `--verbose`: emit debug logs during memory probes and indexing.
- `--index-mode auto|batch|direct`: override batch usage when indexing (`direct` favors speed; `batch` favors OpenAI Batch pricing).
- `--progress auto|line|log|none`: progress output mode (`log` prints updates even without a TTY).

View File

@@ -1,85 +0,0 @@
---
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 peragent 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)

View File

@@ -89,26 +89,7 @@ OAuth only covers chat/completions and does **not** satisfy embeddings for
memory search. When using a custom OpenAI-compatible endpoint, set
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
If you want to use **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),
If you want to use a **custom OpenAI-compatible endpoint** (like Gemini, OpenRouter, or a proxy),
you can use the `remote` configuration:
```json5
@@ -118,8 +99,8 @@ agents: {
provider: "openai",
model: "text-embedding-3-small",
remote: {
baseUrl: "https://proxy.example/v1",
apiKey: "YOUR_PROXY_KEY",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
apiKey: "YOUR_GEMINI_API_KEY",
headers: { "X-Custom-Header": "value" }
}
}

View File

@@ -59,11 +59,6 @@ It does **not** rotate on every request. The pinned profile is reused until:
Manual selection via `/model …@<profileId>` sets a **user override** for that session
and is not autorotated until a new session starts.
Autopinned 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.
Userpinned profiles stay locked to that profile; if it fails and model fallbacks
are configured, Clawdbot moves to the next model instead of switching profiles.
### Why OAuth can “look lost”
If you have both an OAuth profile and an API key profile for the same provider, roundrobin can switch between them across messages unless pinned. To force a single profile:

View File

@@ -54,12 +54,8 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
- Node bridge runs: `node-<nodeId>`
## Lifecycle
- 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).
## Lifecyle
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
- 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.
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
@@ -97,18 +93,7 @@ Send these as standalone messages so they register.
identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"]
},
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 }
},
idleMinutes: 120,
resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
mainKey: "main",

View File

@@ -77,7 +77,6 @@ What this does:
- Seeds the workspace files if missing:
`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`.
- Default identity: **C3PO** (protocol droid).
- Skips channel providers in dev mode (`CLAWDBOT_SKIP_CHANNELS=1`).
Reset flow (fresh start):

View File

@@ -956,8 +956,6 @@
{
"group": "Automation & Hooks",
"pages": [
"hooks",
"hooks/soul-evil",
"automation/auth-monitoring",
"automation/webhook",
"automation/gmail-pubsub",

View File

@@ -46,8 +46,8 @@ When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
## Frames
Client → Gateway:
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake, skills.bins)
- `event`: node signals (voice transcript, agent request, chat subscribe, exec lifecycle)
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake)
- `event`: node signals (voice transcript, agent request, chat subscribe)
Gateway → Client:
- `invoke` / `invoke-res`: node commands (`canvas.*`, `camera.*`, `screen.record`,
@@ -57,18 +57,6 @@ Gateway → Client:
Exact allowlist is enforced in `src/gateway/server-bridge.ts`.
## Exec lifecycle events
Nodes can emit `exec.started`, `exec.finished`, or `exec.denied` events to surface
system.run activity. These are mapped to system events in the gateway.
Payload fields (all optional unless noted):
- `sessionKey` (required): agent session to receive the system event.
- `runId`: unique exec id for grouping.
- `command`: raw or formatted command string.
- `exitCode`, `timedOut`, `success`, `output`: completion details (finished only).
- `reason`: denial reason (denied only).
## Tailnet usage
- Bind the bridge to a tailnet IP: `bridge.bind: "tailnet"` in

View File

@@ -146,11 +146,7 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
// Session behavior
session: {
scope: "per-sender",
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 60
},
idleMinutes: 60,
heartbeatIdleMinutes: 120,
resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/default/sessions/sessions.json",
@@ -261,9 +257,10 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
ackMaxChars: 300
},
memorySearch: {
provider: "gemini",
model: "gemini-embedding-001",
provider: "openai",
model: "text-embedding-004",
remote: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
apiKey: "${GEMINI_API_KEY}"
}
},

View File

@@ -2416,7 +2416,7 @@ Notes:
### `session`
Controls session scoping, reset policy, reset triggers, and where the session store is written.
Controls session scoping, idle expiry, reset triggers, and where the session store is written.
```json5
{
@@ -2426,16 +2426,7 @@ Controls session scoping, reset policy, reset triggers, and where the session st
identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"]
},
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 60
},
resetByType: {
thread: { mode: "daily", atHour: 4 },
dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 }
},
idleMinutes: 60,
resetTriggers: ["/new", "/reset"],
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
// You can override with {agentId} templating:
@@ -2446,12 +2437,12 @@ Controls session scoping, reset policy, reset triggers, and where the session st
// Max ping-pong reply turns between requester/target (05).
maxPingPongTurns: 5
},
sendPolicy: {
rules: [
sendPolicy: {
rules: [
{ action: "deny", match: { channel: "discord", chatType: "group" } }
],
default: "allow"
}
],
default: "allow"
}
}
}
```
@@ -2465,13 +2456,6 @@ Fields:
- `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`.
- 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 (05, default 5).
- `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.

View File

@@ -65,8 +65,8 @@ stronger isolation between agents, run them under separate OS users or separate
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
- Requires node pairing (approval + token).
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
- If you dont want remote execution, set security to **deny** and remove node pairing for that Mac.
- Controlled on the Mac via **Settings → "Node Run Commands"**: "Always Ask" (default), "Always Allow", or "Never".
- If you dont want remote execution, set the policy to "Never" and remove node pairing for that Mac.
## Dynamic skills (watcher / remote nodes)

View File

@@ -239,15 +239,11 @@ Known issue: When you send an image with ONLY a mention (no other text), WhatsAp
ls -la ~/.clawdbot/agents/<agentId>/sessions/
```
**Check 2:** Is the reset window too short?
**Check 2:** Is `idleMinutes` too short?
```json
{
"session": {
"reset": {
"mode": "daily",
"atHour": 4,
"idleMinutes": 10080 // 7 days
}
"idleMinutes": 10080 // 7 days
}
}
```

View File

@@ -37,11 +37,10 @@ The hooks system allows you to:
### Bundled Hooks
Clawdbot ships with three bundled hooks that are automatically discovered:
Clawdbot ships with two bundled hooks that are automatically discovered:
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
List available hooks:
@@ -512,8 +511,6 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
**Events**: `agent:bootstrap`
**Docs**: [SOUL Evil Hook](/hooks/soul-evil)
**Output**: No files written; swaps happen in-memory only.
**Enable**:

View File

@@ -1,68 +0,0 @@
---
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 01): 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)

View File

@@ -147,10 +147,9 @@ Notes:
- The permission prompt must be accepted on the Android device before the capability is advertised.
- Wi-Fi-only devices without telephony will not advertise `sms.send`.
## System commands (node host / mac node)
## System commands (mac node)
The macOS node exposes `system.run` and `system.notify`. The headless node host
exposes `system.run` and `system.which`.
The macOS node exposes `system.run` and `system.notify`.
Examples:
@@ -164,58 +163,12 @@ Notes:
- `system.notify` respects notification permission state on the macOS app.
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
- 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
```
- `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`.
## Permissions map
Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted).
## Headless node host (cross-platform)
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
bridge and exposes `system.run` / `system.which`. This is useful on Linux/Windows
or for running a minimal node alongside a server.
Start it:
```bash
clawdbot node start --host <gateway-host> --port 18790
```
Notes:
- Pairing is still required (the Gateway will show a node approval prompt).
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
(see [Exec approvals](/tools/exec-approvals)).
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
## Mac node mode
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).

View File

@@ -14,7 +14,15 @@ Before building the app, ensure you have the following installed:
1. **Xcode 26.2+**: Required for Swift development.
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
## 1. Install Dependencies
## 1. Initialize Submodules
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:
@@ -22,7 +30,7 @@ Install the project-wide dependencies:
pnpm install
```
## 2. Build and Package the App
## 3. Build and Package the App
To build the macOS app and package it into `dist/Clawdbot.app`, run:
@@ -34,7 +42,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.
## 3. Install the CLI
## 4. Install the CLI
The macOS app expects a global `clawdbot` CLI install to manage background tasks.

View File

@@ -2,7 +2,7 @@
summary: "PeekabooBridge integration for macOS UI automation"
read_when:
- Hosting PeekabooBridge in Clawdbot.app
- Integrating Peekaboo via Swift Package Manager
- Integrating Peekaboo as a submodule
- Changing PeekabooBridge protocol/paths
---
# Peekaboo Bridge (macOS UI automation)

View File

@@ -54,32 +54,29 @@ The macOS app presents itself as a node. Common commands:
The node reports a `permissions` map so agents can decide whats allowed.
## Exec approvals (system.run)
## Node run policy + allowlist
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
Security + ask + allowlist are stored locally on the Mac in:
`system.run` is controlled by the macOS app **Node Run Commands** policy:
- `Always Ask`: prompt per command (default).
- `Always Allow`: run without prompts.
- `Never`: disable `system.run` (tool not advertised).
The policy + allowlist live on the Mac in:
```
~/.clawdbot/exec-approvals.json
~/.clawdbot/macos-node.json
```
Example:
Schema:
```json
{
"version": 1,
"defaults": {
"security": "deny",
"ask": "on-miss"
},
"agents": {
"main": {
"security": "allowlist",
"ask": "on-miss",
"allowlist": [
{ "pattern": "/opt/homebrew/bin/rg" }
]
}
"systemRun": {
"policy": "ask",
"allowlist": [
"[\"/bin/echo\",\"hello\"]"
]
}
}
```

View File

@@ -29,7 +29,6 @@ read_when:
- **Runner:** headless system service; UI app hosts a Unix socket for approvals.
- **Node identity:** use existing `nodeId`.
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
## Key concepts
### Host
@@ -169,9 +168,9 @@ If UI missing:
- Stored in the gateway in-memory queue (`enqueueSystemEvent`).
### Event text
- `Exec started (node=<id>, id=<runId>)`
- `Exec finished (node=<id>, id=<runId>, code=<code>)` + optional output tail
- `Exec denied (node=<id>, id=<runId>, <reason>)`
- `Exec started (host=node, node=<id>, id=<runId>)`
- `Exec finished (exit=<code>, tail=<...>)`
- `Exec denied (policy=<...>, reason=<...>)`
### Transport
Option A (recommended):

View File

@@ -82,8 +82,7 @@ Each `sessionKey` points at a current `sessionId` (the transcript file that cont
Rules of thumb:
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
- **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.
- **Idle expiry** (`session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window.
Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`.

View File

@@ -160,11 +160,7 @@ Example:
session: {
scope: "per-sender",
resetTriggers: ["/new", "/reset"],
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 10080
}
idleMinutes: 10080
}
}
```

View File

@@ -60,7 +60,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Remote gateways + nodes](#remote-gateways-nodes)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
- [Can I run a headless node host without the macOS app?](#can-i-run-a-headless-node-host-without-the-macos-app)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
- [Whats 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)
@@ -406,7 +405,7 @@ You have three supported patterns:
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
**Option B - use a macOS node (no SSH).**
Run the Gateway on Linux, pair a macOS node (menubar app), and 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.
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.
**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.
@@ -743,23 +742,6 @@ to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
### Can I run a headless node host without the macOS app?
Yes. The headless node host is a **command-only** node that exposes `system.run` / `system.which`
without any UI. It has no screen/camera/notify support (use the macOS app for those).
Start it:
```bash
clawdbot node start --host <gateway-host> --port 18790
```
Notes:
- Pairing is still required (`clawdbot nodes pending` → `clawdbot nodes approve <requestId>`).
- Exec approvals still apply via `~/.clawdbot/exec-approvals.json`.
- If prompts are enabled but no companion UI is reachable, `askFallback` decides (default: deny).
Docs: [Node CLI](/cli/node), [Nodes](/nodes), [Exec approvals](/tools/exec-approvals).
### Is there an API / RPC way to apply config?
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
@@ -898,19 +880,14 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce
### Do sessions reset automatically if I never send `/new`?
Yes. By default sessions reset daily at **4:00 AM local time** on the gateway host.
You can also add an idle window; when both daily and idle resets are configured,
whichever expires first starts a new session id on the next message. This does
not delete transcripts — it just starts a new session.
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next**
message starts a fresh session id for that chat key. This does not delete
transcripts — it just starts a new session.
```json5
{
session: {
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 240
}
idleMinutes: 240
}
}
```

View File

@@ -8,7 +8,7 @@ read_when:
# Exec approvals
Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run
Exec approvals are the **companion app guardrail** for letting a sandboxed agent run
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
commands are allowed only when policy + allowlist + (optional) user approval all agree.
Exec approvals are **in addition** to tool policy and elevated gating.
@@ -20,11 +20,11 @@ resolved by the **ask fallback** (default: deny).
Exec approvals are enforced locally on the execution host:
- **gateway host** → `clawdbot` process on the gateway machine
- **node host** → node runner (macOS companion app or headless node host)
- **node host** → node runner (macOS companion app or headless node)
## Settings and storage
Approvals live in a local JSON file on the execution host:
Approvals live in a local JSON file:
`~/.clawdbot/exec-approvals.json`
@@ -97,18 +97,8 @@ Each allowlist entry tracks:
## Auto-allow skill CLIs
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
gateway for the skill bin list. Disable this if you want strict manual allowlists.
## Control UI editing
Use the **Control UI → Nodes → Exec approvals** card to edit defaults, peragent
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.
are treated as allowlisted (node hosts only). Disable this if you want strict
manual allowlists.
## Approval flow

View File

@@ -30,7 +30,7 @@ Notes:
- `host` defaults to `sandbox`.
- `elevated` is ignored when sandboxing is off (exec already runs on the host).
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
- `node` requires a paired node (companion app or headless node host).
- `node` requires a paired node (macOS companion app).
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
## Config
@@ -41,26 +41,7 @@ Notes:
- `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset)
Per-agent node binding (use the agent list index in config):
```bash
clawdbot config get agents.list
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
```
Control UI: the Nodes tab includes a small “Exec node binding” panel for the same settings.
## Session overrides (`/exec`)
Use `/exec` to set **per-session** defaults for `host`, `security`, `ask`, and `node`.
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)
## Exec approvals (macOS app)
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.

View File

@@ -181,7 +181,6 @@ Notes:
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
- `elevated` only changes behavior when the agent is sandboxed (otherwise its a no-op).
- `host=node` can target a macOS companion app or a headless node host (`clawdbot node start`).
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
### `process`

View File

@@ -187,7 +187,7 @@ Skills can also refresh mid-session when the skills watcher is enabled or when a
## Remote macOS nodes (Linux gateway)
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`).
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`).
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.

View File

@@ -12,7 +12,7 @@ The host-only bash chat command uses `! <cmd>` (with `/bash <cmd>` as an alias).
There are two related systems:
- **Commands**: standalone `/...` messages.
- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/exec`, `/model`, `/queue`.
- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/model`, `/queue`.
- 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 directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement.
@@ -77,7 +77,6 @@ Text + native (when enabled):
- `/verbose on|full|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/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`)
- `/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)

View File

@@ -36,7 +36,6 @@ The onboarding wizard generates a gateway token by default, so paste it here on
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
- Skills: status, enable/disable, install, API key updates (`skills.*`)
- Nodes: list + caps (`node.list`)
- Exec approvals: edit allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
- Config: apply + restart with validation (`config.apply`) and wake the last active session
- Config writes include a base-hash guard to prevent clobbering concurrent edits

View File

@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { enqueueSystemEvent, formatAgentEnvelope, type ClawdbotConfig } from "clawdbot/plugin-sdk";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { downloadBlueBubblesAttachment } from "./attachments.js";
@@ -836,7 +836,7 @@ async function processMessage(
const fromLabel = message.isGroup
? `group:${peerId}`
: message.senderName || `user:${message.senderId}`;
const body = core.channel.reply.formatAgentEnvelope({
const body = formatAgentEnvelope({
channel: "BlueBubbles",
from: fromLabel,
timestamp: message.timestamp,
@@ -1058,7 +1058,7 @@ async function processReaction(
const senderLabel = reaction.senderName || reaction.senderId;
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`;
core.system.enqueueSystemEvent(text, {
enqueueSystemEvent(text, {
sessionKey: route.sessionKey,
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
});

View File

@@ -1,16 +0,0 @@
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;

View File

@@ -1,9 +0,0 @@
{
"name": "@clawdbot/discord",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Discord channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
}
}

View File

@@ -1,14 +0,0 @@
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;
}

View File

@@ -1,16 +0,0 @@
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;

View File

@@ -1,9 +0,0 @@
{
"name": "@clawdbot/imessage",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot iMessage channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
}
}

View File

@@ -1,14 +0,0 @@
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;
}

View File

@@ -1,16 +1,10 @@
import { beforeEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "./types.js";
import { matrixPlugin } from "./channel.js";
import { setMatrixRuntime } from "./runtime.js";
describe("matrix directory", () => {
beforeEach(() => {
setMatrixRuntime({} as PluginRuntime);
});
it("lists peers and groups from config", async () => {
const cfg = {
channels: {

View File

@@ -15,7 +15,7 @@ import type {
RoomTopicEventContent,
} from "matrix-js-sdk/lib/@types/state_events.js";
import { getMatrixRuntime } from "../runtime.js";
import { loadConfig } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "../types.js";
import { getActiveMatrixClient } from "./active-client.js";
import {
@@ -74,14 +74,12 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<M
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
if (shouldShareClient) {
const client = await resolveSharedMatrixClient({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
cfg: loadConfig() as CoreConfig,
timeoutMs: opts.timeoutMs,
});
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
});
const auth = await resolveMatrixAuth({ cfg: loadConfig() as CoreConfig });
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,

View File

@@ -1,7 +1,7 @@
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
import { loadConfig } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../runtime.js";
export type MatrixResolvedConfig = {
homeserver: string;
@@ -46,7 +46,7 @@ function clean(value?: string): string {
}
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
cfg: CoreConfig = loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = cfg.channels?.matrix ?? {};
@@ -75,7 +75,7 @@ export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
}): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const cfg = params?.cfg ?? (loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const resolved = resolveMatrixConfig(cfg, env);
if (!resolved.homeserver) {

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getMatrixRuntime } from "../runtime.js";
import { resolveStateDir } from "clawdbot/plugin-sdk";
export type MatrixStoredCredentials = {
homeserver: string;
@@ -16,11 +16,9 @@ const CREDENTIALS_FILENAME = "credentials.json";
export function resolveMatrixCredentialsDir(
env: NodeJS.ProcessEnv = process.env,
stateDir?: string,
stateDir: string = resolveStateDir(env, os.homedir),
): string {
const resolvedStateDir =
stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
return path.join(resolvedStateDir, "credentials", "matrix");
return path.join(stateDir, "credentials", "matrix");
}
export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string {

View File

@@ -3,8 +3,7 @@ import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
import { runCommandWithTimeout, type RuntimeEnv } from "clawdbot/plugin-sdk";
const MATRIX_SDK_PACKAGE = "matrix-js-sdk";
@@ -41,7 +40,7 @@ export async function ensureMatrixSdkInstalled(params: {
? ["pnpm", "install"]
: ["npm", "install", "--omit=dev", "--silent"];
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
const result = await getMatrixRuntime().system.runCommandWithTimeout(command, {
const result = await runCommandWithTimeout(command, {
cwd: root,
timeoutMs: 300_000,
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },

View File

@@ -1,9 +1,8 @@
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk";
import { RoomMemberEvent } from "matrix-js-sdk";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import { danger, logVerbose, type RuntimeEnv } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "../../types.js";
import { getMatrixRuntime } from "../../runtime.js";
export function registerMatrixAutoJoin(params: {
client: MatrixClient;
@@ -11,11 +10,6 @@ export function registerMatrixAutoJoin(params: {
runtime: RuntimeEnv;
}) {
const { client, cfg, runtime } = params;
const core = getMatrixRuntime();
const logVerbose = (message: string) => {
if (!core.logging.shouldLogVerbose()) return;
runtime.log?.(message);
};
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
@@ -42,7 +36,7 @@ export function registerMatrixAutoJoin(params: {
await client.joinRoom(roomId);
logVerbose(`matrix: joined room ${roomId}`);
} catch (err) {
runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`);
runtime.error?.(danger(`matrix: failed to join room ${roomId}: ${String(err)}`));
}
});
}

View File

@@ -3,9 +3,34 @@ import { EventType, RelationType, RoomEvent } from "matrix-js-sdk";
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
import {
buildMentionRegexes,
chunkMarkdownText,
createReplyDispatcherWithTyping,
danger,
dispatchReplyFromConfig,
enqueueSystemEvent,
finalizeInboundContext,
formatAgentEnvelope,
formatAllowlistMatchMeta,
getChildLogger,
hasControlCommand,
loadConfig,
logVerbose,
mergeAllowlist,
matchesMentionPatterns,
readChannelAllowFromStore,
recordSessionMetaFromInbound,
resolveAgentRoute,
resolveCommandAuthorizedFromAuthorizers,
resolveEffectiveMessagesConfig,
resolveHumanDelayConfig,
resolveStorePath,
resolveTextChunkLimit,
shouldHandleTextCommands,
shouldLogVerbose,
summarizeMapping,
updateLastRoute,
upsertChannelPairingRequest,
type ReplyPayload,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
@@ -36,7 +61,6 @@ import { deliverMatrixReplies } from "./replies.js";
import { resolveMatrixRoomConfig } from "./rooms.js";
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
import { resolveMatrixTargets } from "../../resolve-targets.js";
import { getMatrixRuntime } from "../../runtime.js";
export type MonitorMatrixOpts = {
runtime?: RuntimeEnv;
@@ -52,8 +76,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
if (isBunRuntime()) {
throw new Error("Matrix provider requires Node (bun runtime not supported)");
}
const core = getMatrixRuntime();
let cfg = core.config.loadConfig() as CoreConfig;
let cfg = loadConfig() as CoreConfig;
if (cfg.channels?.matrix?.enabled === false) return;
const runtime: RuntimeEnv = opts.runtime ?? {
@@ -184,13 +207,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
setActiveMatrixClient(client);
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
const logVerboseMessage = (message: string) => {
if (core.logging.shouldLogVerbose()) {
logger.debug(message);
}
};
const mentionRegexes = buildMentionRegexes(cfg);
const logger = getChildLogger({ module: "matrix-auto-reply" });
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
@@ -201,7 +219,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const dmEnabled = dmConfig?.enabled ?? true;
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix");
const allowFrom = dmConfig?.allowFrom ?? [];
const textLimit = resolveTextChunkLimit(cfg, "matrix");
const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
const startupMs = Date.now();
@@ -287,22 +306,22 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}`;
if (roomConfigInfo.config && !roomConfigInfo.allowed) {
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
logVerbose(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
return;
}
if (groupPolicy === "allowlist") {
if (!roomConfigInfo.allowlistConfigured) {
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
logVerbose(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
return;
}
if (!roomConfigInfo.config) {
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
logVerbose(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
return;
}
}
const senderName = room.getMember(senderId)?.name ?? senderId;
const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
const storeAllowFrom = await readChannelAllowFromStore("matrix").catch(() => []);
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
if (isDirectMessage) {
@@ -316,13 +335,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (!allowMatch.allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
const { code, created } = await upsertChannelPairingRequest({
channel: "matrix",
id: senderId,
meta: { name: senderName },
});
if (created) {
logVerboseMessage(
logVerbose(
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
);
try {
@@ -339,12 +358,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
{ client },
);
} catch (err) {
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
logVerbose(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
}
}
}
if (dmPolicy !== "pairing") {
logVerboseMessage(
logVerbose(
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
}
@@ -360,7 +379,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
userName: senderName,
});
if (!userMatch.allowed) {
logVerboseMessage(
logVerbose(
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
userMatch,
)})`,
@@ -369,7 +388,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}
}
if (isRoom) {
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
logVerbose(`matrix: allow room ${roomId} (${roomMatchMeta})`);
}
const rawBody = content.body.trim();
@@ -397,7 +416,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
maxBytes: mediaMaxBytes,
});
} catch (err) {
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
logVerbose(`matrix: media download failed: ${String(err)}`);
}
}
@@ -410,7 +429,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
text: bodyText,
mentionRegexes,
});
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: "matrix",
});
@@ -420,19 +439,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
userId: senderId,
userName: senderName,
});
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
});
if (
isRoom &&
allowTextCommands &&
core.channel.text.hasControlCommand(bodyText, cfg) &&
!commandAuthorized
) {
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
if (isRoom && allowTextCommands && hasControlCommand(bodyText, cfg) && !commandAuthorized) {
logVerbose(`matrix: drop control command from unauthorized sender ${senderId}`);
return;
}
const shouldRequireMention = isRoom
@@ -451,7 +465,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
!wasMentioned &&
!hasExplicitMention &&
commandAuthorized &&
core.channel.text.hasControlCommand(bodyText);
hasControlCommand(bodyText);
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
return;
@@ -468,14 +482,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
const body = core.channel.reply.formatAgentEnvelope({
const body = formatAgentEnvelope({
channel: "Matrix",
from: envelopeFrom,
timestamp: event.getTs() ?? undefined,
body: textWithId,
});
const route = core.channel.routing.resolveAgentRoute({
const route = resolveAgentRoute({
cfg,
channel: "matrix",
peer: {
@@ -485,7 +499,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
const ctxPayload = finalizeInboundContext({
Body: body,
RawBody: bodyText,
CommandBody: bodyText,
@@ -517,10 +531,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
OriginatingTo: `room:${roomId}`,
});
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
void core.channel.session.recordSessionMetaFromInbound({
void recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
@@ -532,7 +546,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
if (isDirectMessage) {
await core.channel.session.updateLastRoute({
await updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
channel: "matrix",
@@ -542,8 +556,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
}
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
if (shouldLogVerbose()) {
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
logVerbose(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
}
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
@@ -561,20 +577,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
};
if (shouldAckReaction() && messageId) {
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
logVerbose(`matrix react failed for room ${roomId}: ${String(err)}`);
});
}
const replyTarget = ctxPayload.To;
if (!replyTarget) {
runtime.error?.("matrix: missing reply target");
runtime.error?.(danger("matrix: missing reply target"));
return;
}
let didSendReply = false;
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
await deliverMatrixReplies({
replies: [payload],
@@ -588,13 +604,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
didSendReply = true;
},
onError: (err, info) => {
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
runtime.error?.(danger(`matrix ${info.kind} reply failed: ${String(err)}`));
},
onReplyStart: () => sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
onIdle: () => sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
});
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
const { queuedFinal, counts } = await dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
@@ -606,19 +622,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
markDispatchIdle();
if (!queuedFinal) return;
didSendReply = true;
const finalCount = counts.final;
logVerboseMessage(
`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
);
if (shouldLogVerbose()) {
const finalCount = counts.final;
logVerbose(`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`);
}
if (didSendReply) {
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, {
enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, {
sessionKey: route.sessionKey,
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
});
}
} catch (err) {
runtime.error?.(`matrix handler failed: ${String(err)}`);
runtime.error?.(danger(`matrix handler failed: ${String(err)}`));
}
};

View File

@@ -1,6 +1,6 @@
import type { MatrixClient } from "matrix-js-sdk";
import { getMatrixRuntime } from "../../runtime.js";
import { saveMediaBuffer } from "clawdbot/plugin-sdk";
async function fetchMatrixMediaBuffer(params: {
client: MatrixClient;
@@ -49,12 +49,7 @@ export async function downloadMatrixMedia(params: {
});
if (!fetched) return null;
const headerType = fetched.headerType ?? params.contentType ?? undefined;
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
fetched.buffer,
headerType,
"inbound",
params.maxBytes,
);
const saved = await saveMediaBuffer(fetched.buffer, headerType, "inbound", params.maxBytes);
return {
path: saved.path,
contentType: saved.contentType,

View File

@@ -1,6 +1,6 @@
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
import { getMatrixRuntime } from "../../runtime.js";
import { matchesMentionPatterns } from "clawdbot/plugin-sdk";
export function resolveMentions(params: {
content: RoomMessageEventContent;
@@ -17,9 +17,6 @@ export function resolveMentions(params: {
const wasMentioned =
Boolean(mentions?.room) ||
(params.userId ? mentionedUsers.has(params.userId) : false) ||
getMatrixRuntime().channel.mentions.matchesMentionPatterns(
params.text ?? "",
params.mentionRegexes,
);
matchesMentionPatterns(params.text ?? "", params.mentionRegexes);
return { wasMentioned, hasExplicitMention: Boolean(mentions) };
}

View File

@@ -1,8 +1,13 @@
import type { MatrixClient } from "matrix-js-sdk";
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
import {
chunkMarkdownText,
danger,
logVerbose,
type ReplyPayload,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
import { sendMessageMatrix } from "../send.js";
import { getMatrixRuntime } from "../../runtime.js";
export async function deliverMatrixReplies(params: {
replies: ReplyPayload[];
@@ -13,12 +18,6 @@ export async function deliverMatrixReplies(params: {
replyToMode: "off" | "first" | "all";
threadId?: string;
}): Promise<void> {
const core = getMatrixRuntime();
const logVerbose = (message: string) => {
if (core.logging.shouldLogVerbose()) {
params.runtime.log?.(message);
}
};
const chunkLimit = Math.min(params.textLimit, 4000);
let hasReplied = false;
for (const reply of params.replies) {
@@ -28,7 +27,7 @@ export async function deliverMatrixReplies(params: {
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
continue;
}
params.runtime.error?.("matrix reply missing text/media");
params.runtime.error?.(danger("matrix reply missing text/media"));
continue;
}
const replyToIdRaw = reply.replyToId?.trim();
@@ -43,7 +42,7 @@ export async function deliverMatrixReplies(params: {
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
if (mediaList.length === 0) {
for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) {
for (const chunk of chunkMarkdownText(reply.text ?? "", chunkLimit)) {
const trimmed = chunk.trim();
if (!trimmed) continue;
await sendMessageMatrix(params.roomId, trimmed, {

View File

@@ -1,8 +1,5 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import { setMatrixRuntime } from "../runtime.js";
vi.mock("matrix-js-sdk", () => ({
EventType: {
Direct: "m.direct",
@@ -21,33 +18,21 @@ vi.mock("matrix-js-sdk", () => ({
},
}));
const loadWebMediaMock = vi.fn().mockResolvedValue({
buffer: Buffer.from("media"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
});
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
const resizeToJpegMock = vi.fn();
const runtimeStub = {
config: {
loadConfig: () => ({}),
},
media: {
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
mediaKindFromMime: () => "image",
isVoiceCompatibleAudio: () => false,
getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args),
resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args),
},
channel: {
text: {
resolveTextChunkLimit: () => 4000,
chunkMarkdownText: (text: string) => (text ? [text] : []),
},
},
} as unknown as PluginRuntime;
vi.mock("clawdbot/plugin-sdk", () => ({
loadConfig: () => ({}),
resolveTextChunkLimit: () => 4000,
chunkMarkdownText: (text: string) => (text ? [text] : []),
loadWebMedia: vi.fn().mockResolvedValue({
buffer: Buffer.from("media"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
}),
mediaKindFromMime: () => "image",
isVoiceCompatibleAudio: () => false,
getImageMetadata: vi.fn().mockResolvedValue(null),
resizeToJpeg: vi.fn(),
}));
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
@@ -65,13 +50,11 @@ const makeClient = () => {
describe("sendMessageMatrix media", () => {
beforeAll(async () => {
setMatrixRuntime(runtimeStub);
({ sendMessageMatrix } = await import("./send.js"));
});
beforeEach(() => {
vi.clearAllMocks();
setMatrixRuntime(runtimeStub);
});
it("uploads media with url payloads", async () => {

View File

@@ -5,8 +5,17 @@ import type {
ReactionEventContent,
} from "matrix-js-sdk/lib/@types/events.js";
import type { PollInput } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
import {
chunkMarkdownText,
getImageMetadata,
isVoiceCompatibleAudio,
loadConfig,
loadWebMedia,
mediaKindFromMime,
type PollInput,
resolveTextChunkLimit,
resizeToJpeg,
} from "clawdbot/plugin-sdk";
import { getActiveMatrixClient } from "./active-client.js";
import {
createMatrixClient,
@@ -20,7 +29,6 @@ import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
import type { CoreConfig } from "../types.js";
const MATRIX_TEXT_LIMIT = 4000;
const getCore = () => getMatrixRuntime();
type MatrixDirectAccountData = AccountDataEvents[EventType.Direct];
@@ -57,7 +65,7 @@ function ensureNodeRuntime() {
}
function resolveMediaMaxBytes(): number | undefined {
const cfg = getCore().config.loadConfig() as CoreConfig;
const cfg = loadConfig() as CoreConfig;
if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
}
@@ -216,7 +224,7 @@ function resolveMatrixMsgType(
contentType?: string,
fileName?: string,
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
const kind = mediaKindFromMime(contentType ?? "");
switch (kind) {
case "image":
return MsgType.Image;
@@ -235,7 +243,7 @@ function resolveMatrixVoiceDecision(opts: {
fileName?: string;
}): { useVoice: boolean } {
if (!opts.wantsVoice) return { useVoice: false };
if (getCore().media.isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
if (isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
return { useVoice: true };
}
return { useVoice: false };
@@ -248,19 +256,19 @@ async function prepareImageInfo(params: {
buffer: Buffer;
client: MatrixClient;
}): Promise<MatrixImageInfo | undefined> {
const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null);
const meta = await getImageMetadata(params.buffer).catch(() => null);
if (!meta) return undefined;
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
const maxDim = Math.max(meta.width, meta.height);
if (maxDim > THUMBNAIL_MAX_SIDE) {
try {
const thumbBuffer = await getCore().media.resizeToJpeg({
const thumbBuffer = await resizeToJpeg({
buffer: params.buffer,
maxSide: THUMBNAIL_MAX_SIDE,
quality: THUMBNAIL_QUALITY,
withoutEnlargement: true,
});
const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null);
const thumbMeta = await getImageMetadata(thumbBuffer).catch(() => null);
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
type: "image/jpeg",
name: "thumbnail.jpg",
@@ -344,10 +352,10 @@ export async function sendMessageMatrix(
});
try {
const roomId = await resolveMatrixRoomId(client, to);
const cfg = getCore().config.loadConfig();
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
const cfg = loadConfig();
const textLimit = resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
const threadId = normalizeThreadId(opts.threadId);
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
const sendContent = (content: RoomMessageEventContent) =>
@@ -356,7 +364,7 @@ export async function sendMessageMatrix(
let lastMessageId = "";
if (opts.mediaUrl) {
const maxBytes = resolveMediaMaxBytes();
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
const media = await loadWebMedia(opts.mediaUrl, maxBytes);
const contentUri = await uploadFile(client, media.buffer, {
contentType: media.contentType,
filename: media.fileName,

View File

@@ -1,5 +1,11 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import {
createMemoryGetTool,
createMemorySearchTool,
registerMemoryCli,
} from "clawdbot/plugin-sdk";
const memoryCorePlugin = {
id: "memory-core",
name: "Memory (Core)",
@@ -8,11 +14,11 @@ const memoryCorePlugin = {
register(api: ClawdbotPluginApi) {
api.registerTool(
(ctx) => {
const memorySearchTool = api.runtime.tools.createMemorySearchTool({
const memorySearchTool = createMemorySearchTool({
config: ctx.config,
agentSessionKey: ctx.sessionKey,
});
const memoryGetTool = api.runtime.tools.createMemoryGetTool({
const memoryGetTool = createMemoryGetTool({
config: ctx.config,
agentSessionKey: ctx.sessionKey,
});
@@ -24,7 +30,7 @@ const memoryCorePlugin = {
api.registerCli(
({ program }) => {
api.runtime.tools.registerMemoryCli(program);
registerMemoryCli(program);
},
{ commands: ["memory"] },
);

View File

@@ -1,14 +0,0 @@
{
"name": "@clawdbot/memory-core",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot core memory search plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
},
"dependencies": {
"clawdbot": "workspace:*"
}
}

View File

@@ -1,102 +0,0 @@
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",
},
},
};

View File

@@ -1,282 +0,0 @@
/**
* 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
});

View File

@@ -1,588 +0,0 @@
/**
* 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;

View File

@@ -1,14 +0,0 @@
{
"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"]
}
}

View File

@@ -1,14 +1,12 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { msteamsPlugin } from "./src/channel.js";
import { setMSTeamsRuntime } from "./src/runtime.js";
const plugin = {
id: "msteams",
name: "Microsoft Teams",
description: "Microsoft Teams channel plugin (Bot Framework)",
register(api: ClawdbotPluginApi) {
setMSTeamsRuntime(api.runtime);
api.registerChannel({ plugin: msteamsPlugin });
},
};

View File

@@ -1,24 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import { setMSTeamsRuntime } from "./runtime.js";
const detectMimeMock = vi.fn(async () => "image/png");
const saveMediaBufferMock = vi.fn(async () => ({
path: "/tmp/saved.png",
contentType: "image/png",
}));
const runtimeStub = {
media: {
detectMime: (...args: unknown[]) => detectMimeMock(...args),
},
channel: {
media: {
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
},
},
} as unknown as PluginRuntime;
vi.mock("clawdbot/plugin-sdk", () => ({
detectMime: (...args: unknown[]) => detectMimeMock(...args),
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
}));
describe("msteams attachments", () => {
const load = async () => {
@@ -28,7 +19,6 @@ describe("msteams attachments", () => {
beforeEach(() => {
detectMimeMock.mockClear();
saveMediaBufferMock.mockClear();
setMSTeamsRuntime(runtimeStub);
});
describe("buildMSTeamsAttachmentPlaceholder", () => {

View File

@@ -1,4 +1,4 @@
import { getMSTeamsRuntime } from "../runtime.js";
import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk";
import {
extractInlineImageCandidates,
inferPlaceholder,
@@ -141,7 +141,7 @@ export async function downloadMSTeamsImageAttachments(params: {
if (inline.kind !== "data") continue;
if (inline.data.byteLength > params.maxBytes) continue;
try {
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
const saved = await saveMediaBuffer(
inline.data,
inline.contentType,
"inbound",
@@ -167,12 +167,12 @@ export async function downloadMSTeamsImageAttachments(params: {
if (!res.ok) continue;
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.byteLength > params.maxBytes) continue;
const mime = await getMSTeamsRuntime().media.detectMime({
const mime = await detectMime({
buffer,
headerMime: res.headers.get("content-type"),
filePath: candidate.fileHint ?? candidate.url,
});
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
const saved = await saveMediaBuffer(
buffer,
mime ?? candidate.contentTypeHint,
"inbound",

View File

@@ -1,4 +1,4 @@
import { getMSTeamsRuntime } from "../runtime.js";
import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk";
import { downloadMSTeamsImageAttachments } from "./download.js";
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
import type {
@@ -154,13 +154,13 @@ async function downloadGraphHostedImages(params: {
continue;
}
if (buffer.byteLength > params.maxBytes) continue;
const mime = await getMSTeamsRuntime().media.detectMime({
const mime = await detectMime({
buffer,
headerMime: item.contentType ?? undefined,
});
if (mime && !mime.startsWith("image/")) continue;
try {
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
const saved = await saveMediaBuffer(
buffer,
mime ?? item.contentType ?? undefined,
"inbound",

View File

@@ -2,29 +2,12 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import type { StoredConversationReference } from "./conversation-store.js";
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
import { setMSTeamsRuntime } from "./runtime.js";
const runtimeStub = {
state: {
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
const override = env.CLAWDBOT_STATE_DIR?.trim();
if (override) return override;
const resolvedHome = homedir ? homedir() : os.homedir();
return path.join(resolvedHome, ".clawdbot");
},
},
} as unknown as PluginRuntime;
describe("msteams conversation store (fs)", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
});
it("filters and prunes expired entries (but keeps legacy ones)", async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-msteams-store-"));

View File

@@ -1,35 +1,14 @@
import { beforeEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import { SILENT_REPLY_TOKEN, type PluginRuntime } from "clawdbot/plugin-sdk";
import { SILENT_REPLY_TOKEN } from "clawdbot/plugin-sdk";
import type { StoredConversationReference } from "./conversation-store.js";
import {
type MSTeamsAdapter,
renderReplyPayloadsToMessages,
sendMSTeamsMessages,
} from "./messenger.js";
import { setMSTeamsRuntime } from "./runtime.js";
const runtimeStub = {
channel: {
text: {
chunkMarkdownText: (text: string, limit: number) => {
if (!text) return [];
if (limit <= 0 || text.length <= limit) return [text];
const chunks: string[] = [];
for (let index = 0; index < text.length; index += limit) {
chunks.push(text.slice(index, index + limit));
}
return chunks;
},
},
},
} as unknown as PluginRuntime;
describe("msteams messenger", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
});
describe("renderReplyPayloadsToMessages", () => {
it("filters silent replies", () => {
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {

View File

@@ -1,4 +1,5 @@
import {
chunkMarkdownText,
isSilentReplyText,
type MSTeamsReplyStyle,
type ReplyPayload,
@@ -6,7 +7,6 @@ import {
} from "clawdbot/plugin-sdk";
import type { StoredConversationReference } from "./conversation-store.js";
import { classifyMSTeamsSendError } from "./errors.js";
import { getMSTeamsRuntime } from "./runtime.js";
type SendContext = {
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
@@ -108,7 +108,7 @@ function pushTextMessages(
) {
if (!text) return;
if (opts.chunkText) {
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
for (const chunk of chunkMarkdownText(text, opts.chunkLimit)) {
const trimmed = chunk.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
out.push(trimmed);

View File

@@ -1,4 +1,5 @@
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
import { danger } from "clawdbot/plugin-sdk";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
@@ -41,7 +42,7 @@ export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
try {
await handleTeamsMessage(context as MSTeamsTurnContext);
} catch (err) {
deps.runtime.error?.(`msteams handler failed: ${String(err)}`);
deps.runtime.error?.(danger(`msteams handler failed: ${String(err)}`));
}
await next();
});

View File

@@ -1,10 +1,25 @@
import {
buildPendingHistoryContextFromMap,
clearHistoryEntries,
createInboundDebouncer,
danger,
DEFAULT_GROUP_HISTORY_LIMIT,
readChannelAllowFromStore,
recordSessionMetaFromInbound,
recordPendingHistoryEntry,
resolveAgentRoute,
resolveCommandAuthorizedFromAuthorizers,
resolveInboundDebounceMs,
resolveMentionGating,
resolveStorePath,
dispatchReplyFromConfig,
finalizeInboundContext,
formatAgentEnvelope,
formatAllowlistMatchMeta,
hasControlCommand,
logVerbose,
shouldLogVerbose,
upsertChannelPairingRequest,
type HistoryEntry,
} from "clawdbot/plugin-sdk";
@@ -35,7 +50,6 @@ import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
import type { MSTeamsTurnContext } from "../sdk-types.js";
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
import { getMSTeamsRuntime } from "../runtime.js";
export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const {
@@ -50,12 +64,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
pollStore,
log,
} = deps;
const core = getMSTeamsRuntime();
const logVerboseMessage = (message: string) => {
if (core.logging.shouldLogVerbose()) {
log.debug(message);
}
};
const msteamsCfg = cfg.channels?.msteams;
const historyLimit = Math.max(
0,
@@ -64,10 +72,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
DEFAULT_GROUP_HISTORY_LIMIT,
);
const conversationHistories = new Map<string, HistoryEntry[]>();
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
cfg,
channel: "msteams",
});
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "msteams" });
type MSTeamsDebounceEntry = {
context: MSTeamsTurnContext;
@@ -121,9 +126,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const senderName = from.name ?? from.id;
const senderId = from.aadObjectId ?? from.id;
const storedAllowFrom = await core.channel.pairing
.readAllowFromStore("msteams")
.catch(() => []);
const storedAllowFrom = await readChannelAllowFromStore("msteams").catch(() => []);
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
// Check DM policy for direct messages.
@@ -148,7 +151,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
if (!allowMatch.allowed) {
if (dmPolicy === "pairing") {
const request = await core.channel.pairing.upsertPairingRequest({
const request = await upsertChannelPairingRequest({
channel: "msteams",
id: senderId,
meta: { name: senderName },
@@ -251,15 +254,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
senderId,
senderName,
});
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
});
if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) {
logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`);
if (hasControlCommand(text, cfg) && !commandAuthorized) {
logVerbose(`msteams: drop control command from unauthorized sender ${senderId}`);
return;
}
@@ -326,7 +329,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
: `msteams:group:${conversationId}`;
const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`;
const route = core.channel.routing.resolveAgentRoute({
const route = resolveAgentRoute({
cfg,
channel: "msteams",
peer: {
@@ -340,7 +343,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
? `Teams DM from ${senderName}`
: `Teams message in ${conversationType} from ${senderName}`;
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey: route.sessionKey,
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
});
@@ -406,7 +409,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
const envelopeFrom = isDirectMessage ? senderName : conversationType;
const body = core.channel.reply.formatAgentEnvelope({
const body = formatAgentEnvelope({
channel: "Teams",
from: envelopeFrom,
timestamp,
@@ -422,7 +425,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
core.channel.reply.formatAgentEnvelope({
formatAgentEnvelope({
channel: "Teams",
from: conversationType,
timestamp: entry.timestamp,
@@ -431,7 +434,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
});
}
const ctxPayload = core.channel.reply.finalizeInboundContext({
const ctxPayload = finalizeInboundContext({
Body: combinedBody,
RawBody: rawBody,
CommandBody: rawBody,
@@ -455,18 +458,20 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
...mediaPayload,
});
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
void core.channel.session.recordSessionMetaFromInbound({
void recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
}).catch((err) => {
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
logVerbose(`msteams: failed updating session meta: ${String(err)}`);
});
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
if (shouldLogVerbose()) {
logVerbose(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
}
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
cfg,
@@ -488,7 +493,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
log.info("dispatching to agent", { sessionKey: route.sessionKey });
try {
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
const { queuedFinal, counts } = await dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
@@ -508,16 +513,18 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
}
return;
}
const finalCount = counts.final;
logVerboseMessage(
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
);
if (shouldLogVerbose()) {
const finalCount = counts.final;
logVerbose(
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
);
}
if (isRoomish && historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
}
} catch (err) {
log.error("dispatch failed", { error: String(err) });
runtime.error?.(`msteams dispatch failed: ${String(err)}`);
runtime.error?.(danger(`msteams dispatch failed: ${String(err)}`));
try {
await context.sendActivity(
`⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -528,7 +535,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
}
};
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<MSTeamsDebounceEntry>({
const inboundDebouncer = createInboundDebouncer<MSTeamsDebounceEntry>({
debounceMs: inboundDebounceMs,
buildKey: (entry) => {
const conversationId = normalizeMSTeamsConversationId(
@@ -542,7 +549,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
shouldDebounce: (entry) => {
if (!entry.text.trim()) return false;
if (entry.attachments.length > 0) return false;
return !core.channel.text.hasControlCommand(entry.text, cfg);
return !hasControlCommand(entry.text, cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);
@@ -572,7 +579,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
});
},
onError: (err) => {
runtime.error?.(`msteams debounce flush failed: ${String(err)}`);
runtime.error?.(danger(`msteams debounce flush failed: ${String(err)}`));
},
});

View File

@@ -1,6 +1,8 @@
import type { Request, Response } from "express";
import {
getChildLogger,
mergeAllowlist,
resolveTextChunkLimit,
summarizeMapping,
type ClawdbotConfig,
type RuntimeEnv,
@@ -17,7 +19,8 @@ import {
} from "./resolve-allowlist.js";
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js";
import { getMSTeamsRuntime } from "./runtime.js";
const log = getChildLogger({ name: "msteams" });
export type MonitorMSTeamsOpts = {
cfg: ClawdbotConfig;
@@ -35,8 +38,6 @@ export type MonitorMSTeamsResult = {
export async function monitorMSTeamsProvider(
opts: MonitorMSTeamsOpts,
): Promise<MonitorMSTeamsResult> {
const core = getMSTeamsRuntime();
const log = core.logging.getChildLogger({ name: "msteams" });
let cfg = opts.cfg;
let msteamsCfg = cfg.channels?.msteams;
if (!msteamsCfg?.enabled) {
@@ -196,7 +197,7 @@ export async function monitorMSTeamsProvider(
};
const port = msteamsCfg.webhook?.port ?? 3978;
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "msteams");
const textLimit = resolveTextChunkLimit(cfg, "msteams");
const MB = 1024 * 1024;
const agentDefaults = cfg.agents?.defaults;
const mediaMaxBytes =

View File

@@ -1,12 +1,11 @@
import type { ChannelOutboundAdapter } from "clawdbot/plugin-sdk";
import { chunkMarkdownText, type ChannelOutboundAdapter } from "clawdbot/plugin-sdk";
import { createMSTeamsPollStoreFs } from "./polls.js";
import { getMSTeamsRuntime } from "./runtime.js";
import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
export const msteamsOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
chunker: chunkMarkdownText,
textChunkLimit: 4000,
pollMaxOptions: 12,
sendText: async ({ cfg, to, text, deps }) => {

View File

@@ -2,28 +2,11 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js";
import { setMSTeamsRuntime } from "./runtime.js";
const runtimeStub = {
state: {
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
const override = env.CLAWDBOT_STATE_DIR?.trim();
if (override) return override;
const resolvedHome = homedir ? homedir() : os.homedir();
return path.join(resolvedHome, ".clawdbot");
},
},
} as unknown as PluginRuntime;
describe("msteams polls", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
});
it("builds poll cards with fallback text", () => {
const card = buildMSTeamsPollCard({
question: "Lunch?",

View File

@@ -1,7 +1,11 @@
import type {
ClawdbotConfig,
MSTeamsReplyStyle,
RuntimeEnv,
import {
createReplyDispatcherWithTyping,
danger,
resolveEffectiveMessagesConfig,
resolveHumanDelayConfig,
type ClawdbotConfig,
type MSTeamsReplyStyle,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
import type { StoredConversationReference } from "./conversation-store.js";
import {
@@ -16,7 +20,6 @@ import {
} from "./messenger.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
import { getMSTeamsRuntime } from "./runtime.js";
export function createMSTeamsReplyDispatcher(params: {
cfg: ClawdbotConfig;
@@ -31,7 +34,6 @@ export function createMSTeamsReplyDispatcher(params: {
textLimit: number;
onSentMessageIds?: (ids: string[]) => void;
}) {
const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => {
try {
await params.context.sendActivities([{ type: "typing" }]);
@@ -40,12 +42,9 @@ export function createMSTeamsReplyDispatcher(params: {
}
};
return core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(
params.cfg,
params.agentId,
).responsePrefix,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
return createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(params.cfg, params.agentId).responsePrefix,
humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId),
deliver: async (payload) => {
const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit,
@@ -75,7 +74,7 @@ export function createMSTeamsReplyDispatcher(params: {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
params.runtime.error?.(
`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
danger(`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`),
);
params.log.error("reply failed", {
kind: info.kind,

View File

@@ -1,14 +0,0 @@
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;
}

View File

@@ -1,4 +1,5 @@
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { getChildLogger as getChildLoggerFn } from "clawdbot/plugin-sdk";
import type {
MSTeamsConversationStore,
StoredConversationReference,
@@ -8,10 +9,8 @@ import type { MSTeamsAdapter } from "./messenger.js";
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js";
type GetChildLogger = PluginRuntime["logging"]["getChildLogger"];
let _log: ReturnType<GetChildLogger> | undefined;
const getLog = async (): Promise<ReturnType<GetChildLogger>> => {
let _log: ReturnType<typeof getChildLoggerFn> | undefined;
const getLog = async (): Promise<ReturnType<typeof getChildLoggerFn>> => {
if (_log) return _log;
const { getChildLogger } = await import("../logging.js");
_log = getChildLogger({ name: "msteams:send" });

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import { getMSTeamsRuntime } from "./runtime.js";
import { resolveStateDir } from "clawdbot/plugin-sdk";
export type MSTeamsStorePathOptions = {
env?: NodeJS.ProcessEnv;
@@ -15,8 +15,6 @@ export function resolveMSTeamsStorePath(params: MSTeamsStorePathOptions): string
if (params.stateDir) return path.join(params.stateDir, params.filename);
const env = params.env ?? process.env;
const stateDir = params.homedir
? getMSTeamsRuntime().state.resolveStateDir(env, params.homedir)
: getMSTeamsRuntime().state.resolveStateDir(env);
const stateDir = params.homedir ? resolveStateDir(env, params.homedir) : resolveStateDir(env);
return path.join(stateDir, params.filename);
}

View File

@@ -1,16 +0,0 @@
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;

View File

@@ -1,9 +0,0 @@
{
"name": "@clawdbot/signal",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Signal channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
}
}

View File

@@ -1,14 +0,0 @@
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;
}

View File

@@ -1,16 +0,0 @@
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;

View File

@@ -1,9 +0,0 @@
{
"name": "@clawdbot/slack",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Slack channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
}
}

View File

@@ -1,14 +0,0 @@
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;
}

View File

@@ -1,16 +0,0 @@
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;

View File

@@ -1,9 +0,0 @@
{
"name": "@clawdbot/telegram",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Telegram channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
}
}

View File

@@ -1,14 +0,0 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setTelegramRuntime(next: PluginRuntime) {
runtime = next;
}
export function getTelegramRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Telegram runtime not initialized");
}
return runtime;
}

View File

@@ -1,16 +0,0 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { whatsappPlugin } from "./src/channel.js";
import { setWhatsAppRuntime } from "./src/runtime.js";
const plugin = {
id: "whatsapp",
name: "WhatsApp",
description: "WhatsApp channel plugin",
register(api: ClawdbotPluginApi) {
setWhatsAppRuntime(api.runtime);
api.registerChannel({ plugin: whatsappPlugin });
},
};
export default plugin;

View File

@@ -1,9 +0,0 @@
{
"name": "@clawdbot/whatsapp",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot WhatsApp channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
}
}

View File

@@ -1,14 +0,0 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setWhatsAppRuntime(next: PluginRuntime) {
runtime = next;
}
export function getWhatsAppRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("WhatsApp runtime not initialized");
}
return runtime;
}

Some files were not shown because too many files have changed in this diff Show More