Compare commits

..
94 changed files with 1754 additions and 5083 deletions

No files matched your search

+13 -58
View File
@@ -2,46 +2,30 @@
Docs: https://docs.clawd.bot
## 2026.1.18-5
## 2026.1.17-7
### Changes
- Exec approvals: add `clawdbot approvals` CLI for viewing and updating gateway/node allowlists.
- CLI: add `clawdbot service` gateway/node management and a `clawdbot node status` alias.
- Status: show gateway + node service summaries in `clawdbot status` and `status --all`.
- Control UI: add gateway/node target selector for exec approvals.
- Docs: add approvals/service references and refresh node/control UI docs.
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
### Fixes
- Plugins: auto-select exclusive slots when enabling/installing plugins. (#1181) — thanks @sebslight.
## 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.
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
- Memory: add native Gemini embeddings provider for memory search. (#1151)
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev.
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
## 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.
- Hooks: run `BOOT.md` on gateway startup with the boot-md hook. (#1164) — thanks @ngutman.
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
@@ -56,34 +40,21 @@ Docs: https://docs.clawd.bot
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
- Exec approvals: parse command tokens correctly for PATH and relative resolution.
- Tools: return a companion-app-required message when node exec is requested with no paired node.
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
## 2026.1.18-2
### Fixes
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-6
@@ -112,22 +83,6 @@ Docs: https://docs.clawd.bot
### Fixes
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-3
### Changes
-64
View File
@@ -1,64 +0,0 @@
# Clawdbot macOS app (dev + signing)
## Quick dev run
```bash
# from repo root
scripts/restart-mac.sh
```
Options:
```bash
scripts/restart-mac.sh --no-sign # fastest dev; ad-hoc signing (TCC permissions do not stick)
scripts/restart-mac.sh --sign # force code signing (requires cert)
```
## Packaging flow
```bash
scripts/package-mac-app.sh
```
Creates `dist/Clawdbot.app` and signs it via `scripts/codesign-mac-app.sh`.
## Signing behavior
Auto-selects identity (first match):
1) Developer ID Application
2) Apple Distribution
3) Apple Development
4) first available identity
If none found:
- errors by default
- set `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` to ad-hoc sign
## Team ID audit (Sparkle mismatch guard)
After signing, we read the app bundle Team ID and compare every Mach-O inside the app.
If any embedded binary has a different Team ID, signing fails.
Skip the audit:
```bash
SKIP_TEAM_ID_CHECK=1 scripts/package-mac-app.sh
```
## Library validation workaround (dev only)
If Sparkle Team ID mismatch blocks loading (common with Apple Development certs), opt in:
```bash
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh
```
This adds `com.apple.security.cs.disable-library-validation` to app entitlements.
Use for local dev only; keep off for release builds.
## Useful env flags
- `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"`
- `ALLOW_ADHOC_SIGNING=1` (ad-hoc, TCC permissions do not persist)
- `CODESIGN_TIMESTAMP=off` (offline debug)
- `DISABLE_LIBRARY_VALIDATION=1` (dev-only Sparkle workaround)
- `SKIP_TEAM_ID_CHECK=1` (bypass audit)
@@ -8,8 +8,6 @@ struct BridgeNodeInfo: Sendable {
var displayName: String?
var platform: String?
var version: String?
var coreVersion: String?
var uiVersion: String?
var deviceFamily: String?
var modelIdentifier: String?
var remoteAddress: String?
@@ -149,8 +147,6 @@ actor BridgeConnectionHandler {
displayName: hello.displayName,
platform: hello.platform,
version: hello.version,
coreVersion: hello.coreVersion,
uiVersion: hello.uiVersion,
deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier,
remoteAddress: self.remoteAddressString(),
@@ -175,8 +171,6 @@ actor BridgeConnectionHandler {
displayName: req.displayName,
platform: req.platform,
version: req.version,
coreVersion: req.coreVersion,
uiVersion: req.uiVersion,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
caps: req.caps,
@@ -192,8 +186,6 @@ actor BridgeConnectionHandler {
displayName: enriched.displayName,
platform: enriched.platform,
version: enriched.version,
coreVersion: enriched.coreVersion,
uiVersion: enriched.uiVersion,
deviceFamily: enriched.deviceFamily,
modelIdentifier: enriched.modelIdentifier,
remoteAddress: enriched.remoteAddress,
@@ -1,6 +1,5 @@
import AppKit
import ClawdbotKit
import CryptoKit
import Darwin
import Foundation
import OSLog
@@ -28,49 +27,6 @@ private struct ExecApprovalSocketDecision: Codable {
var decision: ExecApprovalDecision
}
fileprivate struct ExecHostSocketRequest: Codable {
var type: String
var id: String
var nonce: String
var ts: Int
var hmac: String
var requestJson: String
}
fileprivate struct ExecHostRequest: Codable {
var command: [String]
var rawCommand: String?
var cwd: String?
var env: [String: String]?
var timeoutMs: Int?
var needsScreenRecording: Bool?
var agentId: String?
var sessionKey: String?
}
fileprivate struct ExecHostRunResult: Codable {
var exitCode: Int?
var timedOut: Bool
var success: Bool
var stdout: String
var stderr: String
var error: String?
}
fileprivate struct ExecHostError: Codable {
var code: String
var message: String
var reason: String?
}
fileprivate struct ExecHostResponse: Codable {
var type: String
var id: String
var ok: Bool
var payload: ExecHostRunResult?
var error: ExecHostError?
}
enum ExecApprovalsSocketClient {
private struct TimeoutError: LocalizedError {
var message: String
@@ -190,9 +146,6 @@ final class ExecApprovalsPromptServer {
token: approvals.token,
onPrompt: { request in
await ExecApprovalsPromptPresenter.prompt(request)
},
onExec: { request in
await ExecHostExecutor.handle(request)
})
server.start()
self.server = server
@@ -253,182 +206,11 @@ enum ExecApprovalsPromptPresenter {
}
}
@MainActor
fileprivate enum ExecHostExecutor {
private static let blockedEnvKeys: Set<String> = [
"PATH",
"NODE_OPTIONS",
"PYTHONHOME",
"PYTHONPATH",
"PERL5LIB",
"PERL5OPT",
"RUBYOPT",
]
private static let blockedEnvPrefixes: [String] = [
"DYLD_",
"LD_",
]
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !command.isEmpty else {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
}
let displayCommand = ExecCommandFormatter.displayString(
for: command,
rawCommand: request.rawCommand)
let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil
let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent)
let security = approvals.agent.security
let ask = approvals.agent.ask
let autoAllowSkills = approvals.agent.autoAllowSkills
let env = self.sanitizedEnv(request.env)
let resolution = ExecCommandResolution.resolve(
command: command,
rawCommand: request.rawCommand,
cwd: request.cwd,
env: env)
let allowlistMatch = security == .allowlist
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
: nil
let skillAllow: Bool
if autoAllowSkills, let name = resolution?.executableName {
let bins = await SkillBinsCache.shared.currentBins()
skillAllow = bins.contains(name)
} else {
skillAllow = false
}
if security == .deny {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny", reason: "security=deny"))
}
let requiresAsk: Bool = {
if ask == .always { return true }
if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true }
return false
}()
var approvedByAsk = false
if requiresAsk {
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: displayCommand,
cwd: request.cwd,
host: "node",
security: security.rawValue,
ask: ask.rawValue,
agentId: trimmedAgent,
resolvedPath: resolution?.resolvedPath))
switch decision {
case .deny:
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: user denied", reason: "user-denied"))
case .allowAlways:
approvedByAsk = true
if security == .allowlist {
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
if !pattern.isEmpty {
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
}
}
case .allowOnce:
approvedByAsk = true
}
}
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss", reason: "allowlist-miss"))
}
if let match = allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: trimmedAgent,
pattern: match.pattern,
command: displayCommand,
resolvedPath: resolution?.resolvedPath)
}
if request.needsScreenRecording == true {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if !authorized {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording", reason: "permission:screenRecording"))
}
}
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
let result = await Task.detached { () -> ShellExecutor.ShellResult in
await ShellExecutor.runDetailed(
command: command,
cwd: request.cwd,
env: env,
timeout: timeoutSec)
}.value
let payload = ExecHostRunResult(
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
stdout: result.stdout,
stderr: result.stderr,
error: result.errorMessage)
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: true,
payload: payload,
error: nil)
}
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
guard let overrides else { return nil }
var merged = ProcessInfo.processInfo.environment
for (rawKey, value) in overrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
let upper = key.uppercased()
if self.blockedEnvKeys.contains(upper) { continue }
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
merged[key] = value
}
return merged
}
}
private final class ExecApprovalsSocketServer: @unchecked Sendable {
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
private let socketPath: String
private let token: String
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse
private var socketFD: Int32 = -1
private var acceptTask: Task<Void, Never>?
private var isRunning = false
@@ -436,13 +218,11 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
init(
socketPath: String,
token: String,
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision,
onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse)
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision)
{
self.socketPath = socketPath
self.token = token
self.onPrompt = onPrompt
self.onExec = onExec
}
func start() {
@@ -537,39 +317,26 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
private func handleClient(fd: Int32) async {
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
do {
guard self.isAllowedPeer(fd: fd) else {
try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny)
return
}
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
let data = line.data(using: .utf8)
else {
return
}
guard
let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = envelope["type"] as? String
else {
return
}
if type == "request" {
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
guard request.token == self.token else {
try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny)
return
}
let decision = await self.onPrompt(request.request)
try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision)
return
}
if type == "exec" {
let request = try JSONDecoder().decode(ExecHostSocketRequest.self, from: data)
let response = await self.handleExecRequest(request)
try self.sendExecResponse(handle: handle, response: response)
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
guard request.type == "request", request.token == self.token else {
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny)
let data = try JSONEncoder().encode(response)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
return
}
let decision = await self.onPrompt(request.request)
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: decision)
let responseData = try JSONEncoder().encode(response)
var payload = responseData
payload.append(0x0A)
try handle.write(contentsOf: payload)
} catch {
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
}
@@ -590,77 +357,4 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
let lineData = buffer.subdata(in: 0..<newlineIndex)
return String(data: lineData, encoding: .utf8)
}
private func sendApprovalResponse(
handle: FileHandle,
id: String,
decision: ExecApprovalDecision) throws
{
let response = ExecApprovalSocketDecision(type: "decision", id: id, decision: decision)
let data = try JSONEncoder().encode(response)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
}
private func sendExecResponse(handle: FileHandle, response: ExecHostResponse) throws {
let data = try JSONEncoder().encode(response)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
}
private func isAllowedPeer(fd: Int32) -> Bool {
var uid = uid_t(0)
var gid = gid_t(0)
if getpeereid(fd, &uid, &gid) != 0 {
return false
}
return uid == geteuid()
}
private func handleExecRequest(_ request: ExecHostSocketRequest) async -> ExecHostResponse {
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
if abs(nowMs - request.ts) > 10_000 {
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl"))
}
let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson)
if expected != request.hmac {
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid auth", reason: "hmac"))
}
guard let requestData = request.requestJson.data(using: .utf8),
let payload = try? JSONDecoder().decode(ExecHostRequest.self, from: requestData)
else {
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid payload", reason: "json"))
}
let response = await self.onExec(payload)
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: response.ok,
payload: response.payload,
error: response.error)
}
private func hmacHex(nonce: String, ts: Int, requestJson: String) -> String {
let key = SymmetricKey(data: Data(self.token.utf8))
let message = "\(nonce):\(ts):\(requestJson)"
let mac = HMAC<SHA256>.authenticationCode(for: Data(message.utf8), using: key)
return mac.map { String(format: "%02x", $0) }.joined()
}
}
@@ -249,13 +249,6 @@ actor GatewayConnection {
return trimmed.isEmpty ? nil : trimmed
}
func cachedGatewayVersion() -> String? {
guard let snapshot = self.lastSnapshot else { return nil }
let raw = snapshot.server["version"]?.value as? String
let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -280,7 +280,9 @@ extension MenuSessionsInjector {
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
let rows = self.usageRows
if rows.isEmpty {
let errorText = self.cachedUsageErrorText
if rows.isEmpty, errorText == nil {
return cursor
}
@@ -304,6 +306,25 @@ extension MenuSessionsInjector {
menu.insertItem(headerItem, at: cursor)
cursor += 1
if let errorText = errorText?.nonEmpty, !rows.isEmpty {
menu.insertItem(
self.makeMessageItem(
text: errorText,
symbolName: "exclamationmark.triangle",
width: width,
maxLines: 2),
at: cursor)
cursor += 1
}
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
at: cursor)
cursor += 1
return cursor
}
if let selectedProvider = self.selectedUsageProviderId,
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
rows.count > 1
@@ -419,8 +440,6 @@ extension MenuSessionsInjector {
displayName: "Gateway",
platform: platform,
version: nil,
coreVersion: nil,
uiVersion: nil,
deviceFamily: nil,
modelIdentifier: nil,
remoteIp: host,
@@ -540,11 +559,14 @@ extension MenuSessionsInjector {
do {
self.cachedUsageSummary = try await UsageLoader.loadSummary()
} catch {
self.cachedUsageSummary = nil
self.cachedUsageErrorText = nil
self.usageCacheUpdatedAt = Date()
} catch {
if self.cachedUsageSummary == nil {
self.cachedUsageErrorText = self.compactUsageError(error)
}
self.usageCacheUpdatedAt = Date()
}
self.usageCacheUpdatedAt = Date()
}
private func compactUsageError(_ error: Error) -> String {
@@ -725,8 +747,8 @@ extension MenuSessionsInjector {
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
}
if let version = NodeMenuEntryFormatter.detailRightVersion(entry)?.nonEmpty {
menu.addItem(self.makeNodeCopyItem(label: "Version", value: version))
if let version = entry.version?.nonEmpty {
menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version)))
}
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
@@ -95,8 +95,6 @@ actor MacNodeBridgePairingClient {
displayName: hello.displayName,
platform: hello.platform,
version: hello.version,
coreVersion: hello.coreVersion,
uiVersion: hello.uiVersion,
deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier,
caps: hello.caps,
@@ -114,19 +114,12 @@ final class MacNodeModeCoordinator {
let caps = self.currentCaps()
let commands = self.currentCommands(caps: caps)
let permissions = await self.currentPermissions()
let uiVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
let liveGatewayVersion = await GatewayConnection.shared.cachedGatewayVersion()
let fallbackGatewayVersion = GatewayProcessManager.shared.environmentStatus.gatewayVersion
let coreVersion = (liveGatewayVersion ?? fallbackGatewayVersion)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return BridgeHello(
nodeId: Self.nodeId(),
displayName: InstanceIdentity.displayName,
token: token,
platform: "macos",
version: uiVersion,
coreVersion: coreVersion?.isEmpty == false ? coreVersion : nil,
uiVersion: uiVersion,
version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
deviceFamily: "Mac",
modelIdentifier: InstanceIdentity.modelIdentifier,
caps: caps,
+4 -39
View File
@@ -35,9 +35,8 @@ struct NodeMenuEntryFormatter {
if let platform = self.platformText(entry) {
parts.append("platform \(platform)")
}
let versionLabels = self.versionLabels(entry)
if !versionLabels.isEmpty {
parts.append(versionLabels.joined(separator: " · "))
if let version = entry.version?.nonEmpty {
parts.append("app \(self.compactVersion(version))")
}
parts.append("status \(self.roleText(entry))")
return parts.joined(separator: " · ")
@@ -61,9 +60,8 @@ struct NodeMenuEntryFormatter {
}
static func detailRightVersion(_ entry: NodeInfo) -> String? {
let labels = self.versionLabels(entry, compact: false)
if labels.isEmpty { return nil }
return labels.joined(separator: " · ")
guard let version = entry.version?.nonEmpty else { return nil }
return self.shortVersionLabel(version)
}
static func platformText(_ entry: NodeInfo) -> String? {
@@ -129,39 +127,6 @@ struct NodeMenuEntryFormatter {
return compact
}
private static func versionLabels(_ entry: NodeInfo, compact: Bool = true) -> [String] {
let (core, ui) = self.resolveVersions(entry)
var labels: [String] = []
if let core {
let label = compact ? self.compactVersion(core) : self.shortVersionLabel(core)
labels.append("core \(label)")
}
if let ui {
let label = compact ? self.compactVersion(ui) : self.shortVersionLabel(ui)
labels.append("ui \(label)")
}
return labels
}
private static func resolveVersions(_ entry: NodeInfo) -> (core: String?, ui: String?) {
let core = entry.coreVersion?.nonEmpty
let ui = entry.uiVersion?.nonEmpty
if core != nil || ui != nil {
return (core, ui)
}
guard let legacy = entry.version?.nonEmpty else { return (nil, nil) }
if self.isHeadlessPlatform(entry) {
return (legacy, nil)
}
return (nil, legacy)
}
private static func isHeadlessPlatform(_ entry: NodeInfo) -> Bool {
let raw = entry.platform?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
if raw == "darwin" || raw == "linux" || raw == "win32" || raw == "windows" { return true }
return false
}
static func leadingSymbol(_ entry: NodeInfo) -> String {
if self.isGateway(entry) {
return self.safeSystemSymbol(
@@ -7,8 +7,6 @@ struct NodeInfo: Identifiable, Codable {
let displayName: String?
let platform: String?
let version: String?
let coreVersion: String?
let uiVersion: String?
let deviceFamily: String?
let modelIdentifier: String?
let remoteIp: String?
@@ -530,8 +530,6 @@ public struct NodePairRequestParams: Codable, Sendable {
public let displayname: String?
public let platform: String?
public let version: String?
public let coreversion: String?
public let uiversion: String?
public let devicefamily: String?
public let modelidentifier: String?
public let caps: [String]?
@@ -544,8 +542,6 @@ public struct NodePairRequestParams: Codable, Sendable {
displayname: String?,
platform: String?,
version: String?,
coreversion: String?,
uiversion: String?,
devicefamily: String?,
modelidentifier: String?,
caps: [String]?,
@@ -557,8 +553,6 @@ public struct NodePairRequestParams: Codable, Sendable {
self.displayname = displayname
self.platform = platform
self.version = version
self.coreversion = coreversion
self.uiversion = uiversion
self.devicefamily = devicefamily
self.modelidentifier = modelidentifier
self.caps = caps
@@ -571,8 +565,6 @@ public struct NodePairRequestParams: Codable, Sendable {
case displayname = "displayName"
case platform
case version
case coreversion = "coreVersion"
case uiversion = "uiVersion"
case devicefamily = "deviceFamily"
case modelidentifier = "modelIdentifier"
case caps
@@ -1660,40 +1652,6 @@ public struct ExecApprovalsSetParams: Codable, Sendable {
}
}
public struct ExecApprovalsNodeGetParams: Codable, Sendable {
public let nodeid: String
public init(
nodeid: String
) {
self.nodeid = nodeid
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
}
}
public struct ExecApprovalsNodeSetParams: Codable, Sendable {
public let nodeid: String
public let file: [String: AnyCodable]
public let basehash: String?
public init(
nodeid: String,
file: [String: AnyCodable],
basehash: String?
) {
self.nodeid = nodeid
self.file = file
self.basehash = basehash
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case file
case basehash = "baseHash"
}
}
public struct ExecApprovalsSnapshot: Codable, Sendable {
public let path: String
public let exists: Bool
@@ -63,8 +63,6 @@ public struct BridgeHello: Codable, Sendable {
public let token: String?
public let platform: String?
public let version: String?
public let coreVersion: String?
public let uiVersion: String?
public let deviceFamily: String?
public let modelIdentifier: String?
public let caps: [String]?
@@ -78,8 +76,6 @@ public struct BridgeHello: Codable, Sendable {
token: String?,
platform: String?,
version: String?,
coreVersion: String? = nil,
uiVersion: String? = nil,
deviceFamily: String? = nil,
modelIdentifier: String? = nil,
caps: [String]? = nil,
@@ -92,8 +88,6 @@ public struct BridgeHello: Codable, Sendable {
self.token = token
self.platform = platform
self.version = version
self.coreVersion = coreVersion
self.uiVersion = uiVersion
self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier
self.caps = caps
@@ -127,8 +121,6 @@ public struct BridgePairRequest: Codable, Sendable {
public let displayName: String?
public let platform: String?
public let version: String?
public let coreVersion: String?
public let uiVersion: String?
public let deviceFamily: String?
public let modelIdentifier: String?
public let caps: [String]?
@@ -143,8 +135,6 @@ public struct BridgePairRequest: Codable, Sendable {
displayName: String?,
platform: String?,
version: String?,
coreVersion: String? = nil,
uiVersion: String? = nil,
deviceFamily: String? = nil,
modelIdentifier: String? = nil,
caps: [String]? = nil,
@@ -158,8 +148,6 @@ public struct BridgePairRequest: Codable, Sendable {
self.displayName = displayName
self.platform = platform
self.version = version
self.coreVersion = coreVersion
self.uiVersion = uiVersion
self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier
self.caps = caps
+5 -14
View File
@@ -7,8 +7,8 @@ read_when:
# `clawdbot memory`
Manage semantic memory indexing and search.
Provided by the active memory plugin (default: `memory-core`; set `plugins.slots.memory = "none"` to disable).
Memory search tools (semantic memory status/index/search).
Provided by the active memory plugin (default: `memory-core`; use `plugins.slots.memory = "none"` to disable).
Related:
- Memory concept: [Memory](/concepts/memory)
@@ -22,20 +22,11 @@ clawdbot memory status --deep
clawdbot memory status --deep --index
clawdbot memory status --deep --index --verbose
clawdbot memory index
clawdbot memory index --verbose
clawdbot memory search "release checklist"
clawdbot memory status --agent main
clawdbot memory index --agent main --verbose
```
## Options
Common:
- `--agent <id>`: scope to a single agent (default: all configured agents).
- `--verbose`: emit detailed logs during probes and indexing.
Notes:
- `memory status --deep` probes vector + embedding availability.
- `memory status --deep --index` runs a reindex if the store is dirty.
- `memory index --verbose` prints per-phase details (provider, model, sources, batch activity).
- `--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).
+18 -32
View File
@@ -79,46 +79,37 @@ semantic queries can find related notes even when wording differs.
Defaults:
- Enabled by default.
- Watches memory files for changes (debounced).
- Uses remote embeddings by default. If `memorySearch.provider` is not set, Clawdbot auto-selects:
1. `local` if a `memorySearch.local.modelPath` is configured and the file exists.
2. `openai` if an OpenAI key can be resolved.
3. `gemini` if a Gemini key can be resolved.
4. Otherwise memory search stays disabled until configured.
- Uses remote embeddings (OpenAI) unless configured for local.
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
Remote embeddings **require** an API key for the embedding provider. Clawdbot
resolves keys from auth profiles, `models.providers.*.apiKey`, or environment
variables. Codex OAuth only covers chat/completions and does **not** satisfy
embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or
`models.providers.google.apiKey`. When using a custom OpenAI-compatible endpoint,
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
Remote embeddings **require** an API key for the embedding provider. By default
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
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`).
### Gemini embeddings (native)
Set the provider to `gemini` to use the Gemini embeddings API directly:
If you want to use **Gemini embeddings** directly, set the provider to `gemini`:
```json5
agents: {
defaults: {
memorySearch: {
provider: "gemini",
model: "gemini-embedding-001",
model: "gemini-embedding-001", // default
remote: {
apiKey: "YOUR_GEMINI_API_KEY"
apiKey: "${GEMINI_API_KEY}"
}
}
}
}
```
Notes:
- `remote.baseUrl` is optional (defaults to the Gemini API base URL).
- `remote.headers` lets you add extra headers if needed.
- Default model: `gemini-embedding-001`.
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** (OpenRouter, vLLM, or a proxy),
you can use the `remote` configuration with the OpenAI provider:
If you want to use a **custom OpenAI-compatible endpoint** (like OpenRouter or a proxy),
you can use the `remote` configuration:
```json5
agents: {
@@ -127,8 +118,8 @@ agents: {
provider: "openai",
model: "text-embedding-3-small",
remote: {
baseUrl: "https://api.example.com/v1/",
apiKey: "YOUR_OPENAI_COMPAT_API_KEY",
baseUrl: "https://proxy.example/v1",
apiKey: "YOUR_PROXY_KEY",
headers: { "X-Custom-Header": "value" }
}
}
@@ -139,16 +130,11 @@ agents: {
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
`memorySearch.fallback = "none"`.
Fallbacks:
- `memorySearch.fallback` can be `openai`, `gemini`, `local`, or `none`.
- The fallback provider is only used when the primary embedding provider fails.
Batch indexing (OpenAI + Gemini):
- Enabled by default for OpenAI and Gemini embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
Batch indexing (OpenAI only):
- Enabled by default for OpenAI embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
- Batch mode applies when `memorySearch.provider = "openai"` or `"gemini"` and uses the corresponding API key.
- Gemini batch jobs use the async embeddings batch endpoint and require Gemini Batch API availability.
- Batch mode currently applies only when `memorySearch.provider = "openai"` and uses your OpenAI API key.
Why OpenAI batch is fast + cheap:
- For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously.
+2 -5
View File
@@ -9,9 +9,8 @@ read_when:
# Gateway on macOS (external launchd)
Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app
expects an **external** `clawdbot` CLI install, does not spawn the Gateway as a
child process, and manages a per‑user launchd service to keep the Gateway
running (or attaches to an existing local Gateway if one is already running).
expects an **external** `clawdbot` CLI install and manages a per‑user launchd
service to keep the Gateway running.
## Install the CLI (required for local mode)
@@ -39,8 +38,6 @@ Manager:
Behavior:
- “Clawdbot Active” enables/disables the LaunchAgent.
- App quit does **not** stop the gateway (launchd keeps it alive).
- If a Gateway is already running on the configured port, the app attaches to
it instead of starting a new one.
Logging:
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
+3 -5
View File
@@ -5,11 +5,9 @@ read_when:
---
# Gateway lifecycle on macOS
The macOS app **manages the Gateway via launchd** by default and does not spawn
the Gateway as a child process. It first tries to attach to an already‑running
Gateway on the configured port; if none is reachable, it enables the launchd
service via the external `clawdbot` CLI (no embedded runtime). This gives you
reliable auto‑start at login and restart on crashes.
The macOS app **manages the Gateway via launchd** by default. The launchd job
uses the external `clawdbot` CLI (no embedded runtime). This gives you reliable
auto‑start at login and restart on crashes.
Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
If you need tighter coupling to the UI, run the Gateway manually in a terminal.
-3
View File
@@ -32,9 +32,6 @@ To build the macOS app and package it into `dist/Clawdbot.app`, run:
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
For dev run modes, signing flags, and Team ID troubleshooting, see the macOS app README:
https://github.com/clawdbot/clawdbot/blob/main/apps/macos/README.md
> **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
-2
View File
@@ -14,7 +14,6 @@ This app is usually built from [`scripts/package-mac-app.sh`](https://github.com
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build.
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
- runs a Team ID audit after signing and fails if any Mach-O inside the app bundle is signed by a different Team ID. Set `SKIP_TEAM_ID_CHECK=1` to bypass.
## Usage
@@ -24,7 +23,6 @@ scripts/package-mac-app.sh # auto-selects identity; errors if none
SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert
ALLOW_ADHOC_SIGNING=1 scripts/package-mac-app.sh # ad-hoc (permissions will not stick)
SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same caveat)
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh # dev-only Sparkle Team ID mismatch workaround
```
### Ad-hoc Signing Note
-17
View File
@@ -7,8 +7,6 @@ read_when:
**Current model:** there is **no local control socket** and no `clawdbot-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge.
**Planned model:** add a local Unix socket between the **node service** and the **macOS app**. The app owns `system.run` (UI/TCC context); the node service forwards exec requests over IPC.
## Goals
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
- A small surface for automation: Gateway + node commands, plus PeekabooBridge for UI automation.
@@ -19,19 +17,6 @@ read_when:
- The app runs the Gateway (local mode) and connects to it as a node.
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
### Node service + app IPC (planned)
- A headless node service connects to the Gateway bridge.
- `system.run` requests are forwarded to the macOS app over a local Unix socket.
- The app performs the exec in UI context, prompts if needed, and returns output.
Diagram (SCI):
```
Agent -> Gateway -> Bridge -> Node Service (TS)
| IPC (UDS + token + HMAC + TTL)
v
Mac App (UI + TCC + system.run)
```
### PeekabooBridge (UI automation)
- UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol.
- Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution.
@@ -40,7 +25,6 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
### Mach/XPC
- Not required for automation; `node.invoke` + PeekabooBridge cover current needs.
- Planned IPC keeps Unix sockets (no XPC helper).
## Operational flows
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh`
@@ -54,4 +38,3 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
- All communication remains local-only; no network sockets are exposed.
- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
- Planned IPC hardening: socket mode `0600`, token, peer-UID checks, HMAC challenge/response, short TTL.
+5 -21
View File
@@ -7,8 +7,8 @@ read_when:
# Clawdbot macOS Companion (menu bar + gateway broker)
The macOS app is the **menu‑bar companion** for Clawdbot. It owns permissions,
manages/attaches to the Gateway locally (launchd or manual), and exposes macOS
capabilities to the agent as a node.
manages the Gateway locally, and exposes macOS capabilities to the agent as a
node.
## What it does
@@ -20,17 +20,13 @@ capabilities to the agent as a node.
- Optionally hosts **PeekabooBridge** for UI automation.
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime).
Planned:
- Run a headless **node service** locally (launchd).
- Keep `system.run` in the app (UI/TCC context), with the node service forwarding via IPC.
## Local vs remote mode
- **Local** (default): the app attaches to a running local Gateway if present;
otherwise it enables the launchd service via `clawdbot daemon`.
- **Local** (default): the app ensures a local Gateway is running via launchd.
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
a local process.
The app does not spawn the Gateway as a child process.
- **Attach‑only** (debug): the app connects to an already‑running local Gateway
and never spawns its own.
## Launchd control
@@ -58,18 +54,6 @@ The macOS app presents itself as a node. Common commands:
The node reports a `permissions` map so agents can decide what’s allowed.
Planned split:
- Node service advertises the node surface to the Gateway.
- macOS app performs `system.run` in UI context over IPC.
Diagram (SCI):
```
Gateway -> Bridge -> Node Service (TS)
| IPC (UDS + token + HMAC + TTL)
v
Mac App (UI + TCC + system.run)
```
## Exec approvals (system.run)
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
+1 -2
View File
@@ -37,7 +37,6 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
- Memory (LanceDB) — bundled long-term memory plugin (auto-recall/capture; set `plugins.slots.memory = "memory-lancedb"`)
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
@@ -46,7 +45,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
register:
-21
View File
@@ -6,27 +6,6 @@ read_when:
---
# Github Copilot
## What is GitHub Copilot?
GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot
models for your GitHub account and plan. Clawdbot can use Copilot as a model
provider in two different ways.
## Two ways to use Copilot in Clawdbot
### 1) Built-in GitHub Copilot provider (`github-copilot`)
Use the native device-login flow to obtain a GitHub token, then exchange it for
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
because it does not require VS Code.
### 2) Copilot Proxy plugin (`copilot-proxy`)
Use the **Copilot Proxy** VS Code extension as a local bridge. Clawdbot talks to
the proxy’s `/v1` endpoint and uses the model list you configure there. Choose
this when you already run Copilot Proxy in VS Code or need to route through it.
You must enable the plugin and keep the VS Code extension running.
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
the GitHub device flow, saves an auth profile, and updates your config to use that
profile.
+8 -21
View File
@@ -30,8 +30,6 @@ read_when:
- **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).
- **macOS exec host:** run `system.run` inside the macOS app; node service forwards requests over local IPC.
- **No XPC helper:** stick to Unix socket + token + peer checks.
## Key concepts
### Host
@@ -141,30 +139,19 @@ Notes:
## UI integration (macOS app)
### IPC
- Unix socket at `~/.clawdbot/exec-approvals.sock` (0600).
- Token stored in `exec-approvals.json` (0600).
- Peer checks: same-UID only.
- Challenge/response: nonce + HMAC(token, request-hash) to prevent replay.
- Short TTL (e.g., 10s) + max payload + rate limit.
- Unix socket at `~/.clawdbot/exec-approvals.sock`.
- Runner connects and sends an approval request; UI responds with a decision.
- Token stored in `exec-approvals.json`.
### Ask flow (macOS app exec host)
1) Node service receives `system.run` from gateway.
2) Node service connects to the local socket and sends the prompt/exec request.
3) App validates peer + token + HMAC + TTL, then shows dialog if needed.
4) App executes the command in UI context and returns output.
5) Node service returns output to gateway.
### Ask flow
1) Runner receives `system.run` from gateway.
2) If ask required, runner connects to the socket and sends a prompt request.
3) UI shows dialog; returns decision.
4) Runner enforces decision and proceeds.
If UI missing:
- Apply `askFallback` (`deny|allowlist|full`).
### Diagram (SCI)
```
Agent -> Gateway -> Bridge -> Node Service (TS)
| IPC (UDS + token + HMAC + TTL)
v
Mac App (UI + TCC + system.run)
```
## Node identity + binding
- Use existing `nodeId` from Bridge pairing.
- Binding model:
+36 -22
View File
@@ -60,6 +60,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Remote gateways + nodes](#remote-gateways-nodes)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
- [Can I run a headless node host without the macOS app?](#can-i-run-a-headless-node-host-without-the-macos-app)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
@@ -405,7 +406,7 @@ You have three supported patterns:
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
**Option B - use a macOS node (no SSH).**
Run the Gateway on Linux, pair a macOS node (menubar app), and set **Node Run Commands** to "Always Ask" or "Always Allow" on the Mac. Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Always Ask", approving "Always Allow" in the prompt adds that command to the allowlist.
Run the Gateway on Linux, pair a macOS node (menubar app), and configure **Exec approvals** (Settings → Exec approvals) to "Ask" or "Always Allow". Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Ask", selecting "Always Allow" in the prompt adds that command to the allowlist.
**Option C - proxy macOS binaries over SSH (advanced).**
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
@@ -501,23 +502,14 @@ is writable (read-only sandboxes skip it). See [Memory](/concepts/memory).
### Does semantic memory search require an OpenAI API key?
Only if you use **OpenAI embeddings**. Codex OAuth covers chat/completions and
does **not** grant embeddings access, so **signing in with Codex (OAuth or the
Codex CLI login)** does not help for semantic memory search. OpenAI embeddings
still need a real API key (`OPENAI_API_KEY` or `models.providers.openai.apiKey`).
If you don’t set a provider explicitly, Clawdbot auto-selects a provider when it
can resolve an API key (auth profiles, `models.providers.*.apiKey`, or env vars).
It prefers OpenAI if an OpenAI key resolves, otherwise Gemini if a Gemini key
resolves. If neither key is available, memory search stays disabled until you
configure it. If you have a local model path configured and present, Clawdbot
prefers `local`.
If you’d rather stay local, set `memorySearch.provider = "local"` (and optionally
`memorySearch.fallback = "none"`). If you want Gemini embeddings, set
`memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or
`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, or local** embedding
models — see [Memory](/concepts/memory) for the setup details.
Only if you use **remote embeddings** (OpenAI). Codex OAuth covers
chat/completions and does **not** grant embeddings access, so **signing in with
Codex (OAuth or the Codex CLI login)** does not help for semantic memory search.
Remote memory search still needs a real OpenAI API key (`OPENAI_API_KEY` or
`models.providers.openai.apiKey`). If you’d rather stay local, set
`memorySearch.provider = "local"` (and optionally `memorySearch.fallback =
"none"`). We support **remote or local embedding models** — see [Memory](/concepts/memory)
for the setup details.
## Where things live on disk
@@ -751,6 +743,23 @@ to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
### Can I run a headless node host without the macOS app?
Yes. The headless node host is a **command-only** node that exposes `system.run` / `system.which`
without any UI. It has no screen/camera/notify support (use the macOS app for those).
Start it:
```bash
clawdbot node start --host <gateway-host> --port 18790
```
Notes:
- Pairing is still required (`clawdbot nodes pending` → `clawdbot nodes approve <requestId>`).
- Exec approvals still apply via `~/.clawdbot/exec-approvals.json`.
- If prompts are enabled but no companion UI is reachable, `askFallback` decides (default: deny).
Docs: [Node CLI](/cli/node), [Nodes](/nodes), [Exec approvals](/tools/exec-approvals).
### Is there an API / RPC way to apply config?
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
@@ -889,14 +898,19 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce
### Do sessions reset automatically if I never send `/new`?
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.
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.
```json5
{
session: {
idleMinutes: 240
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 240
}
}
}
```
-17
View File
@@ -22,10 +22,6 @@ 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)
Planned macOS split:
- **node service** forwards `system.run` to the **macOS app** over local IPC.
- **macOS app** enforces approvals + executes the command in UI context.
## Settings and storage
Approvals live in a local JSON file on the execution host:
@@ -132,19 +128,6 @@ Actions:
- **Always allow** → add to allowlist + run
- **Deny** → block
### macOS IPC flow (planned)
```
Gateway -> Bridge -> Node Service (TS)
| IPC (UDS + token + HMAC + TTL)
v
Mac App (UI + approvals + system.run)
```
Security notes:
- Unix socket mode `0600`, token stored in `exec-approvals.json`.
- Same-UID peer check.
- Challenge/response (nonce + HMAC token + request hash) + short TTL.
## System events
Exec lifecycle is surfaced as system messages:
+4 -4
View File
@@ -37,8 +37,8 @@ describeWithKey("memory plugin e2e", () => {
// Dynamic import to avoid loading LanceDB when not testing
const { default: memoryPlugin } = await import("./index.js");
expect(memoryPlugin.id).toBe("memory-lancedb");
expect(memoryPlugin.name).toBe("Memory (LanceDB)");
expect(memoryPlugin.id).toBe("memory");
expect(memoryPlugin.name).toBe("Memory (Vector)");
expect(memoryPlugin.kind).toBe("memory");
expect(memoryPlugin.configSchema).toBeDefined();
expect(memoryPlugin.register).toBeInstanceOf(Function);
@@ -185,8 +185,8 @@ describeWithKey("memory plugin live tests", () => {
const logs: string[] = [];
const mockApi = {
id: "memory-lancedb",
name: "Memory (LanceDB)",
id: "memory",
name: "Memory (Vector)",
source: "test",
config: {},
pluginConfig: {
+13 -15
View File
@@ -1,5 +1,5 @@
/**
* Clawdbot Memory (LanceDB) Plugin
* Clawdbot Memory Plugin
*
* Long-term memory with vector search for AI conversations.
* Uses LanceDB for storage and OpenAI for embeddings.
@@ -214,9 +214,9 @@ function detectCategory(text: string): MemoryCategory {
// ============================================================================
const memoryPlugin = {
id: "memory-lancedb",
name: "Memory (LanceDB)",
description: "LanceDB-backed long-term memory with auto-recall/capture",
id: "memory",
name: "Memory (Vector)",
description: "Long-term memory with vector search and seamless auto-recall/capture",
kind: "memory" as const,
configSchema: memoryConfigSchema,
@@ -227,9 +227,7 @@ const memoryPlugin = {
const db = new MemoryDB(resolvedDbPath, vectorDim);
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!);
api.logger.info(
`memory-lancedb: plugin registered (db: ${resolvedDbPath}, lazy init)`,
);
api.logger.info(`memory: plugin registered (db: ${resolvedDbPath}, lazy init)`);
// ========================================================================
// Tools
@@ -419,7 +417,7 @@ const memoryPlugin = {
({ program }) => {
const memory = program
.command("ltm")
.description("LanceDB memory plugin commands");
.description("Long-term memory plugin commands");
memory
.command("list")
@@ -479,14 +477,14 @@ const memoryPlugin = {
.join("\n");
api.logger.info?.(
`memory-lancedb: injecting ${results.length} memories into context`,
`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-lancedb: recall failed: ${String(err)}`);
api.logger.warn(`memory: recall failed: ${String(err)}`);
}
});
}
@@ -561,10 +559,10 @@ const memoryPlugin = {
}
if (stored > 0) {
api.logger.info(`memory-lancedb: auto-captured ${stored} memories`);
api.logger.info(`memory: auto-captured ${stored} memories`);
}
} catch (err) {
api.logger.warn(`memory-lancedb: capture failed: ${String(err)}`);
api.logger.warn(`memory: capture failed: ${String(err)}`);
}
});
}
@@ -574,14 +572,14 @@ const memoryPlugin = {
// ========================================================================
api.registerService({
id: "memory-lancedb",
id: "memory",
start: () => {
api.logger.info(
`memory-lancedb: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
`memory: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
);
},
stop: () => {
api.logger.info("memory-lancedb: stopped");
api.logger.info("memory: stopped");
},
});
},
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "@clawdbot/memory-lancedb",
"name": "@clawdbot/memory",
"version": "0.0.1",
"type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture",
"dependencies": {
"@lancedb/lancedb": "^0.15.0",
"@sinclair/typebox": "0.34.47",
+12 -68
View File
@@ -62,34 +62,6 @@ export class TwilioProvider implements VoiceCallProvider {
/** Map of call SID to stream SID for media streams */
private callStreamMap = new Map<string, string>();
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
private readonly twimlStorage = new Map<string, string>();
/**
* Delete stored TwiML for a given `callId`.
*
* We keep TwiML in-memory only long enough to satisfy the initial Twilio
* webhook request (notify mode). Subsequent webhooks should not reuse it.
*/
private deleteStoredTwiml(callId: string): void {
this.twimlStorage.delete(callId);
}
/**
* Delete stored TwiML for a call, addressed by Twilio's provider call SID.
*
* This is used when we only have `providerCallId` (e.g. hangup).
*/
private deleteStoredTwimlForProviderCall(providerCallId: string): void {
const webhookUrl = this.callWebhookUrls.get(providerCallId);
if (!webhookUrl) return;
const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
if (!callIdMatch) return;
this.deleteStoredTwiml(callIdMatch[1]);
}
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
if (!config.accountSid) {
throw new Error("Twilio Account SID is required");
@@ -256,14 +228,8 @@ export class TwilioProvider implements VoiceCallProvider {
case "busy":
case "no-answer":
case "failed":
if (callIdOverride) {
this.deleteStoredTwiml(callIdOverride);
}
return { ...baseEvent, type: "call.ended", reason: callStatus };
case "canceled":
if (callIdOverride) {
this.deleteStoredTwiml(callIdOverride);
}
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
default:
return null;
@@ -288,25 +254,11 @@ export class TwilioProvider implements VoiceCallProvider {
const params = new URLSearchParams(ctx.rawBody);
const callStatus = params.get("CallStatus");
const direction = params.get("Direction");
const callIdFromQuery =
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
? ctx.query.callId.trim()
: undefined;
// Avoid logging webhook params/TwiML (may contain PII).
console.log(
`[voice-call] generateTwimlResponse: status=${callStatus} direction=${direction}`,
);
// Handle initial TwiML request (when Twilio first initiates the call)
// Check if we have stored TwiML for this call (notify mode)
if (callIdFromQuery) {
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
if (storedTwiml) {
// Clean up after serving (one-time use)
this.deleteStoredTwiml(callIdFromQuery);
return storedTwiml;
}
}
// Handle subsequent webhook requests (status callbacks, etc.)
// For inbound calls, answer immediately with stream
if (direction === "inbound") {
const streamUrl = this.getStreamUrl();
@@ -376,28 +328,22 @@ export class TwilioProvider implements VoiceCallProvider {
const url = new URL(input.webhookUrl);
url.searchParams.set("callId", input.callId);
// Create separate URL for status callbacks (required by Twilio)
const statusUrl = new URL(input.webhookUrl);
statusUrl.searchParams.set("callId", input.callId);
statusUrl.searchParams.set("type", "status"); // Differentiate from TwiML requests
// Store TwiML content if provided (for notify mode)
// We now serve it from the webhook endpoint instead of sending inline
if (input.inlineTwiml) {
this.twimlStorage.set(input.callId, input.inlineTwiml);
}
// Build request params - always use URL-based TwiML.
// Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter.
// Build request params
const params: Record<string, string> = {
To: input.to,
From: input.from,
Url: url.toString(), // TwiML serving endpoint
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
StatusCallback: url.toString(),
StatusCallbackEvent: "initiated ringing answered completed",
Timeout: "30",
};
// Use inline TwiML for notify mode (simpler, no webhook needed)
if (input.inlineTwiml) {
params.Twiml = input.inlineTwiml;
} else {
params.Url = url.toString();
}
const result = await this.apiRequest<TwilioCallResponse>(
"/Calls.json",
params,
@@ -415,8 +361,6 @@ export class TwilioProvider implements VoiceCallProvider {
* Hang up a call via Twilio API.
*/
async hangupCall(input: HangupCallInput): Promise<void> {
this.deleteStoredTwimlForProviderCall(input.providerCallId);
this.callWebhookUrls.delete(input.providerCallId);
await this.apiRequest(
+16 -16
View File
@@ -65,27 +65,27 @@
"dist/whatsapp/**"
],
"scripts": {
"dev": "bun src/entry.ts",
"dev": "tsx src/entry.ts",
"postinstall": "node scripts/postinstall.js",
"prepack": "pnpm build",
"docs:list": "bun scripts/docs-list.ts",
"docs:list": "tsx scripts/docs-list.ts",
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list",
"docs:dev": "cd docs && mint dev",
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
"build": "tsc -p tsconfig.json && bun scripts/canvas-a2ui-copy.ts && bun scripts/copy-hook-metadata.ts && bun scripts/write-build-info.ts",
"plugins:sync": "bun scripts/sync-plugin-versions.ts",
"release:check": "bun scripts/release-check.ts",
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts && tsx scripts/copy-hook-metadata.ts && tsx scripts/write-build-info.ts",
"plugins:sync": "tsx scripts/sync-plugin-versions.ts",
"release:check": "tsx scripts/release-check.ts",
"ui:install": "node scripts/ui.js install",
"ui:dev": "node scripts/ui.js dev",
"ui:build": "node scripts/ui.js build",
"start": "bun src/entry.ts",
"clawdbot": "bun src/entry.ts",
"gateway:watch": "bun --watch src/entry.ts gateway --force",
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway",
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway --reset",
"tui": "bun src/entry.ts tui",
"tui:dev": "CLAWDBOT_PROFILE=dev bun src/entry.ts tui",
"clawdbot:rpc": "bun src/entry.ts agent --mode rpc --json",
"start": "tsx src/entry.ts",
"clawdbot": "tsx src/entry.ts",
"gateway:watch": "tsx watch src/entry.ts gateway --force",
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 tsx src/entry.ts --dev gateway",
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 tsx src/entry.ts --dev gateway --reset",
"tui": "tsx src/entry.ts tui",
"tui:dev": "CLAWDBOT_PROFILE=dev tsx src/entry.ts tui",
"clawdbot:rpc": "tsx src/entry.ts agent --mode rpc --json",
"ios:gen": "cd apps/ios && xcodegen generate",
"ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj",
"ios:build": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Clawdbot.xcodeproj -scheme Clawdbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
@@ -108,7 +108,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "pnpm --dir ui test",
"test:force": "bun scripts/test-force.ts",
"test:force": "tsx scripts/test-force.ts",
"test:coverage": "vitest run --coverage",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:live": "CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts",
@@ -126,8 +126,8 @@
"test:install:smoke": "bash scripts/test-install-sh-docker.sh",
"test:install:e2e:openai": "CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh",
"test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
"protocol:gen": "bun scripts/protocol-gen.ts",
"protocol:gen:swift": "bun scripts/protocol-gen-swift.ts",
"protocol:gen": "tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts",
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check:loc": "tsx scripts/check-ts-max-loc.ts --max 500"
-77
View File
@@ -4,28 +4,11 @@ set -euo pipefail
APP_BUNDLE="${1:-dist/Clawdbot.app}"
IDENTITY="${SIGN_IDENTITY:-}"
TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}"
DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}"
SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}"
ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX)
ENT_TMP_APP=$(mktemp -t clawdbot-entitlements-app.XXXXXX)
ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX)
ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX)
if [[ "${APP_BUNDLE}" == "--help" || "${APP_BUNDLE}" == "-h" ]]; then
cat <<'HELP'
Usage: scripts/codesign-mac-app.sh [app-bundle]
Env:
SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"
ALLOW_ADHOC_SIGNING=1
CODESIGN_TIMESTAMP=auto|on|off
DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround
SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit
ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1
HELP
exit 0
fi
if [ ! -d "$APP_BUNDLE" ]; then
echo "App bundle not found: $APP_BUNDLE" >&2
exit 1
@@ -201,14 +184,6 @@ cat > "$ENT_TMP_APP" <<'PLIST'
</plist>
PLIST
if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP_BASE" >/dev/null 2>&1 || \
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP_BASE"
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP" >/dev/null 2>&1 || \
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP"
echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)."
fi
# The time-sensitive entitlement is restricted and requires explicit enablement
# (and typically a matching provisioning profile). It is *not* safe to enable
# unconditionally for local debug packaging since AMFI will refuse to launch.
@@ -234,51 +209,6 @@ sign_plain_item() {
codesign --force ${options_args+"${options_args[@]}"} "${timestamp_args[@]}" --sign "$IDENTITY" "$target"
}
team_id_for() {
codesign -dv --verbose=4 "$1" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2; exit}'
}
verify_team_ids() {
if [[ "$SKIP_TEAM_ID_CHECK" == "1" ]]; then
echo "Note: skipping Team ID audit (SKIP_TEAM_ID_CHECK=1)."
return 0
fi
local expected
expected="$(team_id_for "$APP_BUNDLE" || true)"
if [[ -z "$expected" ]]; then
echo "WARN: TeamIdentifier missing on app bundle; skipping Team ID audit."
return 0
fi
local mismatches=()
while IFS= read -r -d '' f; do
if /usr/bin/file "$f" | /usr/bin/grep -q "Mach-O"; then
local team
team="$(team_id_for "$f" || true)"
if [[ -z "$team" ]]; then
team="not set"
fi
if [[ "$expected" == "not set" ]]; then
if [[ "$team" != "not set" ]]; then
mismatches+=("$f (TeamIdentifier=$team)")
fi
elif [[ "$team" != "$expected" ]]; then
mismatches+=("$f (TeamIdentifier=$team)")
fi
fi
done < <(find "$APP_BUNDLE" -type f -print0)
if [[ "${#mismatches[@]}" -gt 0 ]]; then
echo "ERROR: Team ID mismatch detected (expected: $expected)"
for entry in "${mismatches[@]}"; do
echo " - $entry"
done
echo "Hint: re-sign embedded frameworks or set DISABLE_LIBRARY_VALIDATION=1 for dev builds."
exit 1
fi
}
# Sign main binary
if [ -f "$APP_BUNDLE/Contents/MacOS/Clawdbot" ]; then
echo "Signing main binary"; sign_item "$APP_BUNDLE/Contents/MacOS/Clawdbot" "$APP_ENTITLEMENTS"
@@ -288,11 +218,6 @@ fi
SPARKLE="$APP_BUNDLE/Contents/Frameworks/Sparkle.framework"
if [ -d "$SPARKLE" ]; then
echo "Signing Sparkle framework and helpers"
find "$SPARKLE" -type f -print0 | while IFS= read -r -d '' f; do
if /usr/bin/file "$f" | /usr/bin/grep -q "Mach-O"; then
sign_plain_item "$f"
fi
done
sign_plain_item "$SPARKLE/Versions/B/Sparkle"
sign_plain_item "$SPARKLE/Versions/B/Autoupdate"
sign_plain_item "$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater"
@@ -315,7 +240,5 @@ fi
# Finally sign the bundle
sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS"
verify_team_ids
rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_APP" "$ENT_TMP_RUNTIME"
echo "Codesign complete for $APP_BUNDLE"
-18
View File
@@ -22,21 +22,6 @@ describe("memory search config", () => {
expect(resolved).toBeNull();
});
it("defaults provider to auto when unspecified", () => {
const cfg = {
agents: {
defaults: {
memorySearch: {
enabled: true,
},
},
},
};
const resolved = resolveMemorySearchConfig(cfg, "main");
expect(resolved?.provider).toBe("auto");
expect(resolved?.fallback).toBe("none");
});
it("merges defaults and overrides", () => {
const cfg = {
agents: {
@@ -141,7 +126,6 @@ describe("memory search config", () => {
agents: {
defaults: {
memorySearch: {
provider: "openai",
remote: {
baseUrl: "https://default.example/v1",
apiKey: "default-key",
@@ -182,7 +166,6 @@ describe("memory search config", () => {
agents: {
defaults: {
memorySearch: {
provider: "openai",
sources: ["memory", "sessions"],
},
},
@@ -206,7 +189,6 @@ describe("memory search config", () => {
agents: {
defaults: {
memorySearch: {
provider: "openai",
sources: ["memory", "sessions"],
experimental: { sessionMemory: true },
},
+24 -30
View File
@@ -3,13 +3,13 @@ import path from "node:path";
import type { ClawdbotConfig, MemorySearchConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { clampInt, clampNumber, resolveUserPath } from "../utils.js";
import { resolveUserPath } from "../utils.js";
import { resolveAgentConfig } from "./agent-scope.js";
export type ResolvedMemorySearchConfig = {
enabled: boolean;
sources: Array<"memory" | "sessions">;
provider: "openai" | "local" | "gemini" | "auto";
provider: "openai" | "gemini" | "local";
remote?: {
baseUrl?: string;
apiKey?: string;
@@ -25,7 +25,7 @@ export type ResolvedMemorySearchConfig = {
experimental: {
sessionMemory: boolean;
};
fallback: "openai" | "gemini" | "local" | "none";
fallback: "openai" | "none";
model: string;
local: {
modelPath?: string;
@@ -110,40 +110,34 @@ function mergeConfig(
const enabled = overrides?.enabled ?? defaults?.enabled ?? true;
const sessionMemory =
overrides?.experimental?.sessionMemory ?? defaults?.experimental?.sessionMemory ?? false;
const provider = overrides?.provider ?? defaults?.provider ?? "auto";
const defaultRemote = defaults?.remote;
const overrideRemote = overrides?.remote;
const hasRemote = Boolean(defaultRemote || overrideRemote);
const includeRemote =
hasRemote || provider === "openai" || provider === "gemini" || provider === "auto";
const provider = overrides?.provider ?? defaults?.provider ?? "openai";
const hasRemote = Boolean(defaults?.remote || overrides?.remote);
const includeRemote = hasRemote || provider === "openai" || provider === "gemini";
const batch = {
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? true,
wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true,
enabled: overrides?.remote?.batch?.enabled ?? defaults?.remote?.batch?.enabled ?? true,
wait: overrides?.remote?.batch?.wait ?? defaults?.remote?.batch?.wait ?? true,
concurrency: Math.max(
1,
overrideRemote?.batch?.concurrency ?? defaultRemote?.batch?.concurrency ?? 2,
overrides?.remote?.batch?.concurrency ?? defaults?.remote?.batch?.concurrency ?? 2,
),
pollIntervalMs:
overrideRemote?.batch?.pollIntervalMs ?? defaultRemote?.batch?.pollIntervalMs ?? 2000,
overrides?.remote?.batch?.pollIntervalMs ?? defaults?.remote?.batch?.pollIntervalMs ?? 2000,
timeoutMinutes:
overrideRemote?.batch?.timeoutMinutes ?? defaultRemote?.batch?.timeoutMinutes ?? 60,
overrides?.remote?.batch?.timeoutMinutes ?? defaults?.remote?.batch?.timeoutMinutes ?? 60,
};
const remote = includeRemote
? {
baseUrl: overrideRemote?.baseUrl ?? defaultRemote?.baseUrl,
apiKey: overrideRemote?.apiKey ?? defaultRemote?.apiKey,
headers: overrideRemote?.headers ?? defaultRemote?.headers,
baseUrl: overrides?.remote?.baseUrl ?? defaults?.remote?.baseUrl,
apiKey: overrides?.remote?.apiKey ?? defaults?.remote?.apiKey,
headers: overrides?.remote?.headers ?? defaults?.remote?.headers,
batch,
}
: undefined;
const fallback = overrides?.fallback ?? defaults?.fallback ?? "none";
const modelDefault =
provider === "gemini"
? DEFAULT_GEMINI_MODEL
: provider === "openai"
? DEFAULT_OPENAI_MODEL
: undefined;
const model = overrides?.model ?? defaults?.model ?? modelDefault ?? "";
const fallback = overrides?.fallback ?? defaults?.fallback ?? "openai";
const model =
overrides?.model ??
defaults?.model ??
(provider === "gemini" ? DEFAULT_GEMINI_MODEL : DEFAULT_OPENAI_MODEL);
const local = {
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,
modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
@@ -200,14 +194,14 @@ function mergeConfig(
maxEntries: overrides?.cache?.maxEntries ?? defaults?.cache?.maxEntries,
};
const overlap = clampNumber(chunking.overlap, 0, Math.max(0, chunking.tokens - 1));
const minScore = clampNumber(query.minScore, 0, 1);
const vectorWeight = clampNumber(hybrid.vectorWeight, 0, 1);
const textWeight = clampNumber(hybrid.textWeight, 0, 1);
const overlap = Math.max(0, Math.min(chunking.overlap, chunking.tokens - 1));
const minScore = Math.max(0, Math.min(1, query.minScore));
const vectorWeight = Math.max(0, Math.min(1, hybrid.vectorWeight));
const textWeight = Math.max(0, Math.min(1, hybrid.textWeight));
const sum = vectorWeight + textWeight;
const normalizedVectorWeight = sum > 0 ? vectorWeight / sum : DEFAULT_HYBRID_VECTOR_WEIGHT;
const normalizedTextWeight = sum > 0 ? textWeight / sum : DEFAULT_HYBRID_TEXT_WEIGHT;
const candidateMultiplier = clampInt(hybrid.candidateMultiplier, 1, 20);
const candidateMultiplier = Math.max(1, Math.min(20, Math.floor(hybrid.candidateMultiplier)));
return {
enabled,
sources,
-3
View File
@@ -97,9 +97,6 @@ describe("buildAgentSystemPrompt", () => {
"Use `Read` to load the SKILL.md at the location listed for that skill.",
);
expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs");
expect(prompt).toContain(
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
);
});
it("includes docs guidance when docsPath is provided", () => {
-7
View File
@@ -4,9 +4,6 @@ export type NodeListNode = {
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string;
deviceFamily?: string;
modelIdentifier?: string;
@@ -23,8 +20,6 @@ type PendingRequest = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string;
isRepair?: boolean;
ts: number;
@@ -36,8 +31,6 @@ type PairedNode = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string;
permissions?: Record<string, boolean>;
createdAtMs?: number;
+5 -2
View File
@@ -5,7 +5,6 @@ import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import { killProcessTree } from "../../agents/shell-utils.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
import { clampInt } from "../../utils.js";
import type { MsgContext } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
@@ -32,10 +31,14 @@ type ActiveBashJob =
let activeJob: ActiveBashJob | null = null;
function clampNumber(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function resolveForegroundMs(cfg: ClawdbotConfig): number {
const raw = cfg.commands?.bashForegroundMs;
if (typeof raw !== "number" || Number.isNaN(raw)) return DEFAULT_FOREGROUND_MS;
return clampInt(raw, 0, MAX_FOREGROUND_MS);
return clampNumber(Math.floor(raw), 0, MAX_FOREGROUND_MS);
}
function formatSessionSnippet(sessionId: string) {
+89 -2
View File
@@ -248,6 +248,69 @@ describe("memory cli", () => {
expect(close).toHaveBeenCalled();
});
it("applies index mode overrides", async () => {
const { registerMemoryCli } = await import("./memory-cli.js");
const close = vi.fn(async () => {});
const sync = vi.fn(async () => {});
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
loadConfig.mockReturnValueOnce({
agents: {
defaults: {
memorySearch: {
remote: {
batch: { enabled: true },
},
},
},
},
});
getMemorySearchManager.mockResolvedValueOnce({
manager: {
probeVectorAvailability: vi.fn(async () => true),
probeEmbeddingAvailability,
sync,
status: () => ({
files: 1,
chunks: 1,
dirty: false,
workspaceDir: "/tmp/clawd",
dbPath: "/tmp/memory.sqlite",
provider: "openai",
model: "text-embedding-3-small",
requestedProvider: "openai",
vector: { enabled: true, available: true },
}),
close,
},
});
const program = new Command();
program.name("test");
registerMemoryCli(program);
await program.parseAsync(["memory", "status", "--index", "--index-mode", "direct"], {
from: "user",
});
expect(getMemorySearchManager).toHaveBeenCalledWith(
expect.objectContaining({
cfg: expect.objectContaining({
agents: expect.objectContaining({
defaults: expect.objectContaining({
memorySearch: expect.objectContaining({
remote: expect.objectContaining({
batch: expect.objectContaining({ enabled: false }),
}),
}),
}),
}),
}),
}),
);
expect(sync).toHaveBeenCalled();
expect(probeEmbeddingAvailability).toHaveBeenCalled();
expect(close).toHaveBeenCalled();
});
it("closes manager after index", async () => {
const { registerMemoryCli } = await import("./memory-cli.js");
const { defaultRuntime } = await import("../runtime.js");
@@ -266,9 +329,31 @@ describe("memory cli", () => {
registerMemoryCli(program);
await program.parseAsync(["memory", "index"], { from: "user" });
expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
);
expect(close).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("Memory index updated.");
});
it("skips progress when --progress none", async () => {
const { registerMemoryCli } = await import("./memory-cli.js");
const close = vi.fn(async () => {});
const sync = vi.fn(async () => {});
getMemorySearchManager.mockResolvedValueOnce({
manager: {
sync,
close,
},
});
const program = new Command();
program.name("test");
registerMemoryCli(program);
await program.parseAsync(["memory", "index", "--progress", "none"], { from: "user" });
expect(sync).toHaveBeenCalledWith({ reason: "cli", force: false });
expect(close).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
});
it("logs close failures without failing the command", async () => {
@@ -291,7 +376,9 @@ describe("memory cli", () => {
registerMemoryCli(program);
await program.parseAsync(["memory", "index"], { from: "user" });
expect(sync).toHaveBeenCalledWith({ reason: "cli", force: false });
expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
);
expect(close).toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
expect.stringContaining("Memory manager close failed: close boom"),
+277 -241
View File
@@ -1,39 +1,36 @@
import os from "node:os";
import path from "node:path";
import type { Command } from "commander";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { MemorySearchConfig } from "../config/types.tools.js";
import { loadConfig } from "../config/config.js";
import { setVerbose } from "../globals.js";
import { withProgress, withProgressTotals } from "./progress.js";
import { formatErrorMessage, withManager } from "./cli-utils.js";
import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js";
import {
resolveMemoryCacheState,
resolveMemoryFtsState,
resolveMemoryVectorState,
type Tone,
} from "../memory/status-format.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { resolveStateDir } from "../config/paths.js";
type MemoryCommandOptions = {
agent?: string;
json?: boolean;
deep?: boolean;
index?: boolean;
indexMode?: IndexMode;
progress?: ProgressMode;
verbose?: boolean;
};
type MemoryManager = NonNullable<MemorySearchManagerResult["manager"]>;
function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string {
if (source === "memory") {
return `memory (MEMORY.md + ${path.join(workspaceDir, "memory")}${path.sep}*.md)`;
}
if (source === "sessions") {
const stateDir = resolveStateDir(process.env, os.homedir);
return `sessions (${path.join(stateDir, "agents", agentId, "sessions")}${path.sep}*.jsonl)`;
}
return source;
}
type IndexMode = "auto" | "batch" | "direct";
type ProgressMode = "auto" | "line" | "log" | "none";
function resolveAgent(cfg: ReturnType<typeof loadConfig>, agent?: string) {
const trimmed = agent?.trim();
@@ -41,14 +38,66 @@ function resolveAgent(cfg: ReturnType<typeof loadConfig>, agent?: string) {
return resolveDefaultAgentId(cfg);
}
function resolveAgentIds(cfg: ReturnType<typeof loadConfig>, agent?: string): string[] {
const trimmed = agent?.trim();
if (trimmed) return [trimmed];
const list = cfg.agents?.list ?? [];
if (list.length > 0) {
return list.map((entry) => entry.id).filter(Boolean);
function resolveIndexMode(raw?: string): IndexMode {
if (!raw) return "auto";
const trimmed = raw.trim().toLowerCase();
if (trimmed === "batch") return "batch";
if (trimmed === "direct") return "direct";
return "auto";
}
function resolveProgressMode(raw?: string): ProgressMode {
if (!raw) return "auto";
const trimmed = raw.trim().toLowerCase();
if (trimmed === "line") return "line";
if (trimmed === "log") return "log";
if (trimmed === "none") return "none";
return "auto";
}
function applyIndexMode(cfg: ClawdbotConfig, agentId: string, mode: IndexMode): ClawdbotConfig {
if (mode === "auto") return cfg;
const enabled = mode === "batch";
const patchMemorySearch = (memorySearch?: MemorySearchConfig) => {
const remote = memorySearch?.remote;
const batch = remote?.batch;
return {
...memorySearch,
remote: {
...remote,
batch: {
...batch,
enabled,
},
},
};
};
const nextAgents = { ...cfg.agents };
nextAgents.defaults = {
...cfg.agents?.defaults,
memorySearch: patchMemorySearch(cfg.agents?.defaults?.memorySearch),
};
if (cfg.agents?.list?.length) {
nextAgents.list = cfg.agents.list.map((agent) =>
agent.id === agentId
? {
...agent,
memorySearch: patchMemorySearch(agent.memorySearch),
}
: agent,
);
}
return [resolveDefaultAgentId(cfg)];
return { ...cfg, agents: nextAgents };
}
function resolveProgressOptions(
mode: ProgressMode,
verbose: boolean,
): { enabled?: boolean; fallback?: "spinner" | "line" | "log" | "none" } {
if (mode === "none") return { enabled: false, fallback: "none" };
if (mode === "line") return { fallback: "line" };
if (mode === "log") return { fallback: "log" };
return { fallback: verbose ? "line" : undefined };
}
export function registerMemoryCli(program: Command) {
@@ -68,194 +117,182 @@ export function registerMemoryCli(program: Command) {
.option("--json", "Print JSON")
.option("--deep", "Probe embedding provider availability")
.option("--index", "Reindex if dirty (implies --deep)")
.option("--index-mode <mode>", "Index mode (auto|batch|direct) when indexing", "auto")
.option("--progress <mode>", "Progress output (auto|line|log|none)", "auto")
.option("--verbose", "Verbose logging", false)
.action(async (opts: MemoryCommandOptions) => {
setVerbose(Boolean(opts.verbose));
const cfg = loadConfig();
const agentIds = resolveAgentIds(cfg, opts.agent);
const allResults: Array<{
agentId: string;
status: ReturnType<MemoryManager["status"]>;
embeddingProbe?: Awaited<ReturnType<MemoryManager["probeEmbeddingAvailability"]>>;
indexError?: string;
}> = [];
for (const agentId of agentIds) {
await withManager<MemoryManager>({
getManager: () => getMemorySearchManager({ cfg, agentId }),
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
onCloseError: (err) =>
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
close: (manager) => manager.close(),
run: async (manager) => {
const deep = Boolean(opts.deep || opts.index);
let embeddingProbe:
| Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>>
| undefined;
let indexError: string | undefined;
if (deep) {
await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => {
const rawCfg = loadConfig();
const agentId = resolveAgent(rawCfg, opts.agent);
const indexMode = resolveIndexMode(opts.indexMode);
const progressMode = resolveProgressMode(opts.progress);
const progressOptions = resolveProgressOptions(progressMode, Boolean(opts.verbose));
const cfg = applyIndexMode(rawCfg, agentId, indexMode);
await withManager<MemoryManager>({
getManager: () => getMemorySearchManager({ cfg, agentId }),
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
onCloseError: (err) =>
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
close: (manager) => manager.close(),
run: async (manager) => {
const deep = Boolean(opts.deep || opts.index);
let embeddingProbe:
| Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>>
| undefined;
let indexError: string | undefined;
if (deep) {
await withProgress(
{ label: "Checking memory…", total: 2, ...progressOptions },
async (progress) => {
progress.setLabel("Probing vector…");
await manager.probeVectorAvailability();
progress.tick();
progress.setLabel("Probing embeddings…");
embeddingProbe = await manager.probeEmbeddingAvailability();
progress.tick();
});
if (opts.index) {
await withProgressTotals(
{
label: "Indexing memory…",
total: 0,
fallback: opts.verbose ? "line" : undefined,
},
async (update, progress) => {
try {
await manager.sync({
reason: "cli",
progress: (syncUpdate) => {
update({
completed: syncUpdate.completed,
total: syncUpdate.total,
label: syncUpdate.label,
});
if (syncUpdate.label) progress.setLabel(syncUpdate.label);
},
});
} catch (err) {
indexError = formatErrorMessage(err);
defaultRuntime.error(`Memory index failed: ${indexError}`);
process.exitCode = 1;
}
},
);
}
} else {
await manager.probeVectorAvailability();
},
);
if (opts.index) {
await withProgressTotals(
{
label: "Indexing memory…",
total: 0,
...progressOptions,
},
async (update, progress) => {
try {
await manager.sync({
reason: "cli",
progress: (syncUpdate) => {
update({
completed: syncUpdate.completed,
total: syncUpdate.total,
label: syncUpdate.label,
});
if (syncUpdate.label) progress.setLabel(syncUpdate.label);
},
});
} catch (err) {
indexError = formatErrorMessage(err);
defaultRuntime.error(`Memory index failed: ${indexError}`);
process.exitCode = 1;
}
},
);
}
const status = manager.status();
allResults.push({ agentId, status, embeddingProbe, indexError });
},
});
}
if (opts.json) {
defaultRuntime.log(JSON.stringify(allResults, null, 2));
return;
}
const rich = isRich();
const heading = (text: string) => colorize(rich, theme.heading, text);
const muted = (text: string) => colorize(rich, theme.muted, text);
const info = (text: string) => colorize(rich, theme.info, text);
const success = (text: string) => colorize(rich, theme.success, text);
const warn = (text: string) => colorize(rich, theme.warn, text);
const accent = (text: string) => colorize(rich, theme.accent, text);
const label = (text: string) => muted(`${text}:`);
for (const result of allResults) {
const { agentId, status, embeddingProbe, indexError } = result;
if (opts.index) {
const line = indexError
? `Memory index failed: ${indexError}`
: "Memory index complete.";
defaultRuntime.log(line);
}
const lines = [
`${heading("Memory Search")} ${muted(`(${agentId})`)}`,
`${label("Provider")} ${info(status.provider)} ${muted(
`(requested: ${status.requestedProvider})`,
)}`,
`${label("Model")} ${info(status.model)}`,
status.sources?.length
? `${label("Sources")} ${info(status.sources.join(", "))}`
: null,
`${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`,
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
`${label("Store")} ${info(status.dbPath)}`,
`${label("Workspace")} ${info(status.workspaceDir)}`,
].filter(Boolean) as string[];
if (embeddingProbe) {
const state = embeddingProbe.ok ? "ready" : "unavailable";
const stateColor = embeddingProbe.ok ? theme.success : theme.warn;
lines.push(`${label("Embeddings")} ${colorize(rich, stateColor, state)}`);
if (embeddingProbe.error) {
lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`);
} else {
await manager.probeVectorAvailability();
}
}
if (status.sourceCounts?.length) {
lines.push(label("By source"));
for (const entry of status.sourceCounts) {
const counts = `${entry.files} files · ${entry.chunks} chunks`;
lines.push(` ${accent(entry.source)} ${muted("·")} ${muted(counts)}`);
const status = manager.status();
if (opts.json) {
defaultRuntime.log(
JSON.stringify(
{
...status,
embeddings: embeddingProbe
? { ok: embeddingProbe.ok, error: embeddingProbe.error }
: undefined,
indexError,
},
null,
2,
),
);
return;
}
}
if (status.fallback) {
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
}
if (status.vector) {
const vectorState = status.vector.enabled
? status.vector.available
? "ready"
: "unavailable"
: "disabled";
const vectorColor =
vectorState === "ready"
? theme.success
: vectorState === "unavailable"
? theme.warn
: theme.muted;
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState)}`);
if (status.vector.dims) {
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
if (opts.index) {
const line = indexError
? `Memory index failed: ${indexError}`
: "Memory index complete.";
defaultRuntime.log(line);
}
if (status.vector.extensionPath) {
lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`);
const rich = isRich();
const heading = (text: string) => colorize(rich, theme.heading, text);
const muted = (text: string) => colorize(rich, theme.muted, text);
const info = (text: string) => colorize(rich, theme.info, text);
const success = (text: string) => colorize(rich, theme.success, text);
const warn = (text: string) => colorize(rich, theme.warn, text);
const accent = (text: string) => colorize(rich, theme.accent, text);
const label = (text: string) => muted(`${text}:`);
const colorForTone = (tone: Tone) =>
tone === "ok" ? theme.success : tone === "warn" ? theme.warn : theme.muted;
const lines = [
`${heading("Memory Search")} ${muted(`(${agentId})`)}`,
`${label("Provider")} ${info(status.provider)} ${muted(
`(requested: ${status.requestedProvider})`,
)}`,
`${label("Model")} ${info(status.model)}`,
status.sources?.length
? `${label("Sources")} ${info(status.sources.join(", "))}`
: null,
`${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`,
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
`${label("Store")} ${info(status.dbPath)}`,
`${label("Workspace")} ${info(status.workspaceDir)}`,
].filter(Boolean) as string[];
if (embeddingProbe) {
const state = embeddingProbe.ok ? "ready" : "unavailable";
const stateColor = embeddingProbe.ok ? theme.success : theme.warn;
lines.push(`${label("Embeddings")} ${colorize(rich, stateColor, state)}`);
if (embeddingProbe.error) {
lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`);
}
}
if (status.vector.loadError) {
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
if (status.sourceCounts?.length) {
lines.push(label("By source"));
for (const entry of status.sourceCounts) {
const counts = `${entry.files} files · ${entry.chunks} chunks`;
lines.push(` ${accent(entry.source)} ${muted("·")} ${muted(counts)}`);
}
}
}
if (status.fts) {
const ftsState = status.fts.enabled
? status.fts.available
? "ready"
: "unavailable"
: "disabled";
const ftsColor =
ftsState === "ready"
? theme.success
: ftsState === "unavailable"
? theme.warn
: theme.muted;
lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState)}`);
if (status.fts.error) {
lines.push(`${label("FTS error")} ${warn(status.fts.error)}`);
if (status.fallback) {
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
}
}
if (status.cache) {
const cacheState = status.cache.enabled ? "enabled" : "disabled";
const cacheColor = status.cache.enabled ? theme.success : theme.muted;
const suffix =
status.cache.enabled && typeof status.cache.entries === "number"
? ` (${status.cache.entries} entries)`
: "";
lines.push(
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState)}${suffix}`,
);
if (status.cache.enabled && typeof status.cache.maxEntries === "number") {
lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`);
if (status.vector) {
const vectorState = resolveMemoryVectorState(status.vector);
const vectorColor = colorForTone(vectorState.tone);
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState.state)}`);
if (status.vector.dims) {
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
}
if (status.vector.extensionPath) {
lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`);
}
if (status.vector.loadError) {
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
}
}
}
if (status.fallback?.reason) {
lines.push(muted(status.fallback.reason));
}
if (indexError) {
lines.push(`${label("Index error")} ${warn(indexError)}`);
}
defaultRuntime.log(lines.join("\n"));
if (agentIds.length > 1) defaultRuntime.log("");
}
if (status.fts) {
const ftsState = resolveMemoryFtsState(status.fts);
const ftsColor = colorForTone(ftsState.tone);
lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState.state)}`);
if (status.fts.error) {
lines.push(`${label("FTS error")} ${warn(status.fts.error)}`);
}
}
if (status.cache) {
const cacheState = resolveMemoryCacheState(status.cache);
const cacheColor = colorForTone(cacheState.tone);
const suffix =
status.cache.enabled && typeof status.cache.entries === "number"
? ` (${status.cache.entries} entries)`
: "";
lines.push(
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState.state)}${suffix}`,
);
if (status.cache.enabled && typeof status.cache.maxEntries === "number") {
lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`);
}
}
if (status.fallback?.reason) {
lines.push(muted(status.fallback.reason));
}
if (indexError) {
lines.push(`${label("Index error")} ${warn(indexError)}`);
}
defaultRuntime.log(lines.join("\n"));
},
});
});
memory
@@ -263,57 +300,56 @@ export function registerMemoryCli(program: Command) {
.description("Reindex memory files")
.option("--agent <id>", "Agent id (default: default agent)")
.option("--force", "Force full reindex", false)
.option("--verbose", "Verbose logging", false)
.option("--index-mode <mode>", "Index mode (auto|batch|direct) when indexing", "auto")
.option("--progress <mode>", "Progress output (auto|line|log|none)", "auto")
.action(async (opts: MemoryCommandOptions & { force?: boolean }) => {
setVerbose(Boolean(opts.verbose));
const cfg = loadConfig();
const agentIds = resolveAgentIds(cfg, opts.agent);
for (const agentId of agentIds) {
await withManager<MemoryManager>({
getManager: () => getMemorySearchManager({ cfg, agentId }),
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
onCloseError: (err) =>
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
close: (manager) => manager.close(),
run: async (manager) => {
try {
if (opts.verbose) {
const status = manager.status();
const rich = isRich();
const heading = (text: string) => colorize(rich, theme.heading, text);
const muted = (text: string) => colorize(rich, theme.muted, text);
const info = (text: string) => colorize(rich, theme.info, text);
const warn = (text: string) => colorize(rich, theme.warn, text);
const label = (text: string) => muted(`${text}:`);
const sourceLabels = status.sources.map((source) =>
formatSourceLabel(source, status.workspaceDir, agentId),
);
const lines = [
`${heading("Memory Index")} ${muted(`(${agentId})`)}`,
`${label("Provider")} ${info(status.provider)} ${muted(
`(requested: ${status.requestedProvider})`,
)}`,
`${label("Model")} ${info(status.model)}`,
sourceLabels.length
? `${label("Sources")} ${info(sourceLabels.join(", "))}`
: null,
].filter(Boolean) as string[];
if (status.fallback) {
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
}
defaultRuntime.log(lines.join("\n"));
defaultRuntime.log("");
}
const rawCfg = loadConfig();
const agentId = resolveAgent(rawCfg, opts.agent);
const indexMode = resolveIndexMode(opts.indexMode);
const progressMode = resolveProgressMode(opts.progress);
const progressOptions = resolveProgressOptions(progressMode, Boolean(opts.verbose));
const cfg = applyIndexMode(rawCfg, agentId, indexMode);
await withManager<MemoryManager>({
getManager: () => getMemorySearchManager({ cfg, agentId }),
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
onCloseError: (err) =>
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
close: (manager) => manager.close(),
run: async (manager) => {
try {
if (progressMode === "none") {
await manager.sync({ reason: "cli", force: opts.force });
defaultRuntime.log(`Memory index updated (${agentId}).`);
} catch (err) {
const message = formatErrorMessage(err);
defaultRuntime.error(`Memory index failed (${agentId}): ${message}`);
process.exitCode = 1;
} else {
await withProgressTotals(
{
label: "Indexing memory…",
total: 0,
...progressOptions,
},
async (update, progress) => {
await manager.sync({
reason: "cli",
force: opts.force,
progress: (syncUpdate) => {
update({
completed: syncUpdate.completed,
total: syncUpdate.total,
label: syncUpdate.label,
});
if (syncUpdate.label) progress.setLabel(syncUpdate.label);
},
});
},
);
}
},
});
}
defaultRuntime.log("Memory index updated.");
} catch (err) {
const message = formatErrorMessage(err);
defaultRuntime.error(`Memory index failed: ${message}`);
process.exitCode = 1;
}
},
});
});
memory
+1 -47
View File
@@ -4,43 +4,6 @@ import { formatAge, formatPermissions, parseNodeList, parsePairingList } from ".
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
function formatVersionLabel(raw: string) {
const trimmed = raw.trim();
if (!trimmed) return raw;
if (trimmed.toLowerCase().startsWith("v")) return trimmed;
return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed;
}
function resolveNodeVersions(node: {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
}) {
const core = node.coreVersion?.trim() || undefined;
const ui = node.uiVersion?.trim() || undefined;
if (core || ui) return { core, ui };
const legacy = node.version?.trim();
if (!legacy) return { core: undefined, ui: undefined };
const platform = node.platform?.trim().toLowerCase() ?? "";
const headless =
platform === "darwin" || platform === "linux" || platform === "win32" || platform === "windows";
return headless ? { core: legacy, ui: undefined } : { core: undefined, ui: legacy };
}
function formatNodeVersions(node: {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
}) {
const { core, ui } = resolveNodeVersions(node);
const parts: string[] = [];
if (core) parts.push(`core ${formatVersionLabel(core)}`);
if (ui) parts.push(`ui ${formatVersionLabel(ui)}`);
return parts.length > 0 ? parts.join(" · ") : null;
}
export function registerNodesStatusCommands(nodes: Command) {
nodesCallOpts(
nodes
@@ -66,8 +29,6 @@ export function registerNodesStatusCommands(nodes: Command) {
const hw = n.modelIdentifier ? ` · hw: ${n.modelIdentifier}` : "";
const perms = formatPermissions(n.permissions);
const permsText = perms ? ` · perms: ${perms}` : "";
const versions = formatNodeVersions(n);
const versionText = versions ? ` · ${versions}` : "";
const caps =
Array.isArray(n.caps) && n.caps.length > 0
? `[${n.caps.map(String).filter(Boolean).sort().join(",")}]`
@@ -76,7 +37,7 @@ export function registerNodesStatusCommands(nodes: Command) {
: "?";
const pairing = n.paired ? "paired" : "unpaired";
defaultRuntime.log(
`- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText}${versionText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`,
`- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`,
);
}
} catch (err) {
@@ -116,19 +77,12 @@ export function registerNodesStatusCommands(nodes: Command) {
const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
const versions = formatNodeVersions(obj as {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
});
const parts: string[] = ["Node:", displayName, nodeId];
if (ip) parts.push(ip);
if (family) parts.push(`device: ${family}`);
if (model) parts.push(`hw: ${model}`);
if (perms) parts.push(`perms: ${perms}`);
if (versions) parts.push(versions);
parts.push(connected ? "connected" : "disconnected");
parts.push(`caps: ${caps ? `[${caps.join(",")}]` : "?"}`);
defaultRuntime.log(parts.join(" · "));
-6
View File
@@ -46,8 +46,6 @@ export type NodeListNode = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string;
deviceFamily?: string;
modelIdentifier?: string;
@@ -64,8 +62,6 @@ export type PendingRequest = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string;
isRepair?: boolean;
ts: number;
@@ -77,8 +73,6 @@ export type PairedNode = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string;
permissions?: Record<string, boolean>;
createdAtMs?: number;
+1 -39
View File
@@ -13,7 +13,6 @@ import {
resolvePluginInstallDir,
} from "../plugins/install.js";
import { recordPluginInstall } from "../plugins/installs.js";
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import type { PluginRecord } from "../plugins/registry.js";
import { buildPluginStatusReport } from "../plugins/status.js";
import { defaultRuntime } from "../runtime.js";
@@ -80,31 +79,6 @@ async function readInstalledPackageVersion(dir: string): Promise<string | undefi
}
}
function applySlotSelectionForPlugin(
config: ClawdbotConfig,
pluginId: string,
): { config: ClawdbotConfig; warnings: string[] } {
const report = buildPluginStatusReport({ config });
const plugin = report.plugins.find((entry) => entry.id === pluginId);
if (!plugin) {
return { config, warnings: [] };
}
const result = applyExclusiveSlotSelection({
config,
selectedId: plugin.id,
selectedKind: plugin.kind,
registry: report,
});
return { config: result.config, warnings: result.warnings };
}
function logSlotWarnings(warnings: string[]) {
if (warnings.length === 0) return;
for (const warning of warnings) {
defaultRuntime.log(chalk.yellow(warning));
}
}
export function registerPluginsCli(program: Command) {
const plugins = program
.command("plugins")
@@ -223,7 +197,7 @@ export function registerPluginsCli(program: Command) {
.argument("<id>", "Plugin id")
.action(async (id: string) => {
const cfg = loadConfig();
let next: ClawdbotConfig = {
const next = {
...cfg,
plugins: {
...cfg.plugins,
@@ -236,10 +210,7 @@ export function registerPluginsCli(program: Command) {
},
},
};
const slotResult = applySlotSelectionForPlugin(next, id);
next = slotResult.config;
await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`);
});
@@ -309,10 +280,7 @@ export function registerPluginsCli(program: Command) {
installPath: resolved,
version: probe.version,
});
const slotResult = applySlotSelectionForPlugin(next, probe.pluginId);
next = slotResult.config;
await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Linked plugin path: ${resolved}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
return;
@@ -351,10 +319,7 @@ export function registerPluginsCli(program: Command) {
installPath: result.targetDir,
version: result.version,
});
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
next = slotResult.config;
await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
return;
@@ -414,10 +379,7 @@ export function registerPluginsCli(program: Command) {
installPath: result.targetDir,
version: result.version,
});
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
next = slotResult.config;
await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
});
+2 -24
View File
@@ -13,7 +13,6 @@ export type AuthChoiceGroupId =
| "openai"
| "anthropic"
| "google"
| "copilot"
| "openrouter"
| "ai-gateway"
| "moonshot"
@@ -69,14 +68,8 @@ const AUTH_CHOICE_GROUP_DEFS: {
{
value: "google",
label: "Google",
hint: "Gemini API key + OAuth",
choices: ["gemini-api-key", "google-antigravity", "google-gemini-cli"],
},
{
value: "copilot",
label: "Copilot",
hint: "GitHub + local proxy",
choices: ["github-copilot", "copilot-proxy"],
hint: "Gemini API key",
choices: ["gemini-api-key"],
},
{
value: "openrouter",
@@ -202,23 +195,8 @@ export function buildAuthChoiceOptions(params: {
hint: "Uses GitHub device flow",
});
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
options.push({
value: "google-antigravity",
label: "Google Antigravity OAuth",
hint: "Uses the bundled Antigravity auth plugin",
});
options.push({
value: "google-gemini-cli",
label: "Google Gemini CLI OAuth",
hint: "Uses the bundled Gemini CLI auth plugin",
});
options.push({ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" });
options.push({ value: "qwen-portal", label: "Qwen OAuth" });
options.push({
value: "copilot-proxy",
label: "Copilot Proxy (local)",
hint: "Local proxy for VS Code Copilot models",
});
options.push({ value: "apiKey", label: "Anthropic API key" });
// Token flow is currently Anthropic-only; use CLI for advanced providers.
options.push({
@@ -1,14 +0,0 @@
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
export async function applyAuthChoiceCopilotProxy(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
return await applyAuthChoicePluginProvider(params, {
authChoice: "copilot-proxy",
pluginId: "copilot-proxy",
providerId: "copilot-proxy",
methodId: "local",
label: "Copilot Proxy",
});
}
@@ -1,14 +0,0 @@
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
export async function applyAuthChoiceGoogleAntigravity(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
return await applyAuthChoicePluginProvider(params, {
authChoice: "google-antigravity",
pluginId: "google-antigravity-auth",
providerId: "google-antigravity",
methodId: "oauth",
label: "Google Antigravity",
});
}
@@ -1,14 +0,0 @@
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
export async function applyAuthChoiceGoogleGeminiCli(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
return await applyAuthChoicePluginProvider(params, {
authChoice: "google-gemini-cli",
pluginId: "google-gemini-cli-auth",
providerId: "google-gemini-cli",
methodId: "oauth",
label: "Google Gemini CLI",
});
}
@@ -1,197 +0,0 @@
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
import {
resolveDefaultAgentId,
resolveAgentDir,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import type { ClawdbotConfig } from "../config/config.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import { resolvePluginProviders } from "../plugins/providers.js";
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthProfileConfig } from "./onboard-auth.js";
import { openUrl } from "./onboard-helpers.js";
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
import { isRemoteEnvironment } from "./oauth-env.js";
export type PluginProviderAuthChoiceOptions = {
authChoice: string;
pluginId: string;
providerId: string;
methodId?: string;
label: string;
};
function resolveProviderMatch(
providers: ProviderPlugin[],
rawProvider: string,
): ProviderPlugin | null {
const normalized = normalizeProviderId(rawProvider);
return (
providers.find((provider) => normalizeProviderId(provider.id) === normalized) ??
providers.find(
(provider) =>
provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false,
) ??
null
);
}
function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null {
const raw = rawMethod?.trim();
if (!raw) return null;
const normalized = raw.toLowerCase();
return (
provider.auth.find((method) => method.id.toLowerCase() === normalized) ??
provider.auth.find((method) => method.label.toLowerCase() === normalized) ??
null
);
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function mergeConfigPatch<T>(base: T, patch: unknown): T {
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
return patch as T;
}
const next: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(patch)) {
const existing = next[key];
if (isPlainRecord(existing) && isPlainRecord(value)) {
next[key] = mergeConfigPatch(existing, value);
} else {
next[key] = value;
}
}
return next as T;
}
function applyDefaultModel(cfg: ClawdbotConfig, model: string): ClawdbotConfig {
const models = { ...cfg.agents?.defaults?.models };
models[model] = models[model] ?? {};
const existingModel = cfg.agents?.defaults?.model;
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
model: {
...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel
? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks }
: undefined),
primary: model,
},
},
},
};
}
export async function applyAuthChoicePluginProvider(
params: ApplyAuthChoiceParams,
options: PluginProviderAuthChoiceOptions,
): Promise<ApplyAuthChoiceResult | null> {
if (params.authChoice !== options.authChoice) return null;
const enableResult = enablePluginInConfig(params.config, options.pluginId);
let nextConfig = enableResult.config;
if (!enableResult.enabled) {
await params.prompter.note(
`${options.label} plugin is disabled (${enableResult.reason ?? "blocked"}).`,
options.label,
);
return { config: nextConfig };
}
const agentId = params.agentId ?? resolveDefaultAgentId(nextConfig);
const defaultAgentId = resolveDefaultAgentId(nextConfig);
const agentDir =
params.agentDir ??
(agentId === defaultAgentId ? resolveClawdbotAgentDir() : resolveAgentDir(nextConfig, agentId));
const workspaceDir =
resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir();
const providers = resolvePluginProviders({ config: nextConfig, workspaceDir });
const provider = resolveProviderMatch(providers, options.providerId);
if (!provider) {
await params.prompter.note(
`${options.label} auth plugin is not available. Enable it and re-run the wizard.`,
options.label,
);
return { config: nextConfig };
}
const method = pickAuthMethod(provider, options.methodId) ?? provider.auth[0];
if (!method) {
await params.prompter.note(`${options.label} auth method missing.`, options.label);
return { config: nextConfig };
}
const isRemote = isRemoteEnvironment();
const result = await method.run({
config: nextConfig,
agentDir,
workspaceDir,
prompter: params.prompter,
runtime: params.runtime,
isRemote,
openUrl: async (url) => {
await openUrl(url);
},
oauth: {
createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts),
},
});
if (result.configPatch) {
nextConfig = mergeConfigPatch(nextConfig, result.configPatch);
}
for (const profile of result.profiles) {
upsertAuthProfile({
profileId: profile.profileId,
credential: profile.credential,
agentDir,
});
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: profile.profileId,
provider: profile.credential.provider,
mode: profile.credential.type === "token" ? "token" : profile.credential.type,
...("email" in profile.credential && profile.credential.email
? { email: profile.credential.email }
: {}),
});
}
let agentModelOverride: string | undefined;
if (result.defaultModel) {
if (params.setDefaultModel) {
nextConfig = applyDefaultModel(nextConfig, result.defaultModel);
await params.prompter.note(
`Default model set to ${result.defaultModel}`,
"Model configured",
);
} else if (params.agentId) {
agentModelOverride = result.defaultModel;
await params.prompter.note(
`Default model set to ${result.defaultModel} for agent "${params.agentId}".`,
"Model configured",
);
}
}
if (result.notes && result.notes.length > 0) {
await params.prompter.note(result.notes.join("\n"), "Provider notes");
}
return { config: nextConfig, agentModelOverride };
}
+188 -7
View File
@@ -1,14 +1,195 @@
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
import {
resolveDefaultAgentId,
resolveAgentDir,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolvePluginProviders } from "../plugins/providers.js";
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
import { applyAuthProfileConfig } from "./onboard-auth.js";
import { openUrl } from "./onboard-helpers.js";
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
import { isRemoteEnvironment } from "./oauth-env.js";
const PLUGIN_ID = "qwen-portal-auth";
const PROVIDER_ID = "qwen-portal";
function enableBundledPlugin(cfg: ClawdbotConfig): ClawdbotConfig {
const existingEntry = cfg.plugins?.entries?.[PLUGIN_ID];
return {
...cfg,
plugins: {
...cfg.plugins,
entries: {
...cfg.plugins?.entries,
[PLUGIN_ID]: {
...existingEntry,
enabled: true,
},
},
},
};
}
function resolveProviderMatch(
providers: ProviderPlugin[],
rawProvider: string,
): ProviderPlugin | null {
const normalized = normalizeProviderId(rawProvider);
return (
providers.find((provider) => normalizeProviderId(provider.id) === normalized) ??
providers.find(
(provider) =>
provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false,
) ??
null
);
}
function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null {
const raw = rawMethod?.trim();
if (!raw) return null;
const normalized = raw.toLowerCase();
return (
provider.auth.find((method) => method.id.toLowerCase() === normalized) ??
provider.auth.find((method) => method.label.toLowerCase() === normalized) ??
null
);
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function mergeConfigPatch<T>(base: T, patch: unknown): T {
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
return patch as T;
}
const next: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(patch)) {
const existing = next[key];
if (isPlainRecord(existing) && isPlainRecord(value)) {
next[key] = mergeConfigPatch(existing, value);
} else {
next[key] = value;
}
}
return next as T;
}
function applyDefaultModel(cfg: ClawdbotConfig, model: string): ClawdbotConfig {
const models = { ...cfg.agents?.defaults?.models };
models[model] = models[model] ?? {};
const existingModel = cfg.agents?.defaults?.model;
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
model: {
...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel
? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks }
: undefined),
primary: model,
},
},
},
};
}
export async function applyAuthChoiceQwenPortal(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
return await applyAuthChoicePluginProvider(params, {
authChoice: "qwen-portal",
pluginId: "qwen-portal-auth",
providerId: "qwen-portal",
methodId: "device",
label: "Qwen",
if (params.authChoice !== "qwen-portal") return null;
let nextConfig = enableBundledPlugin(params.config);
const agentId = params.agentId ?? resolveDefaultAgentId(nextConfig);
const defaultAgentId = resolveDefaultAgentId(nextConfig);
const agentDir =
params.agentDir ??
(agentId === defaultAgentId ? resolveClawdbotAgentDir() : resolveAgentDir(nextConfig, agentId));
const workspaceDir =
resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir();
const providers = resolvePluginProviders({ config: nextConfig, workspaceDir });
const provider = resolveProviderMatch(providers, PROVIDER_ID);
if (!provider) {
await params.prompter.note(
"Qwen auth plugin is not available. Run `clawdbot plugins enable qwen-portal-auth` and re-run the wizard.",
"Qwen",
);
return { config: nextConfig };
}
const method = pickAuthMethod(provider, "device") ?? provider.auth[0];
if (!method) {
await params.prompter.note("Qwen auth method missing.", "Qwen");
return { config: nextConfig };
}
const isRemote = isRemoteEnvironment();
const result = await method.run({
config: nextConfig,
agentDir,
workspaceDir,
prompter: params.prompter,
runtime: params.runtime,
isRemote,
openUrl: async (url) => {
await openUrl(url);
},
oauth: {
createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts),
},
});
if (result.configPatch) {
nextConfig = mergeConfigPatch(nextConfig, result.configPatch);
}
for (const profile of result.profiles) {
upsertAuthProfile({
profileId: profile.profileId,
credential: profile.credential,
agentDir,
});
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: profile.profileId,
provider: profile.credential.provider,
mode: profile.credential.type === "token" ? "token" : profile.credential.type,
...("email" in profile.credential && profile.credential.email
? { email: profile.credential.email }
: {}),
});
}
let agentModelOverride: string | undefined;
if (result.defaultModel) {
if (params.setDefaultModel) {
nextConfig = applyDefaultModel(nextConfig, result.defaultModel);
await params.prompter.note(`Default model set to ${result.defaultModel}`, "Model configured");
} else if (params.agentId) {
agentModelOverride = result.defaultModel;
await params.prompter.note(
`Default model set to ${result.defaultModel} for agent "${params.agentId}".`,
"Model configured",
);
}
}
if (result.notes && result.notes.length > 0) {
await params.prompter.note(result.notes.join("\n"), "Provider notes");
}
return { config: nextConfig, agentModelOverride };
}
-6
View File
@@ -3,10 +3,7 @@ import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js";
import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js";
import { applyAuthChoiceCopilotProxy } from "./auth-choice.apply.copilot-proxy.js";
import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot.js";
import { applyAuthChoiceGoogleAntigravity } from "./auth-choice.apply.google-antigravity.js";
import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js";
import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js";
import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js";
import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js";
@@ -38,9 +35,6 @@ export async function applyAuthChoice(
applyAuthChoiceApiProviders,
applyAuthChoiceMiniMax,
applyAuthChoiceGitHubCopilot,
applyAuthChoiceGoogleAntigravity,
applyAuthChoiceGoogleGeminiCli,
applyAuthChoiceCopilotProxy,
applyAuthChoiceQwenPortal,
];
@@ -15,12 +15,9 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
"moonshot-api-key": "moonshot",
"kimi-code-api-key": "kimi-code",
"gemini-api-key": "google",
"google-antigravity": "google-antigravity",
"google-gemini-cli": "google-gemini-cli",
"zai-api-key": "zai",
"synthetic-api-key": "synthetic",
"github-copilot": "github-copilot",
"copilot-proxy": "copilot-proxy",
"minimax-cloud": "minimax",
"minimax-api": "minimax",
"minimax-api-lightning": "minimax",
+2 -76
View File
@@ -1,13 +1,7 @@
import type { ClawdbotConfig } from "../config/config.js";
import { resolveGatewayPort } from "../config/config.js";
import { resolveGatewayLaunchAgentLabel, resolveNodeLaunchAgentLabel } from "../daemon/constants.js";
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import {
isLaunchAgentListed,
isLaunchAgentLoaded,
launchAgentPlistExists,
repairLaunchAgentBootstrap,
} from "../daemon/launchd.js";
import { resolveGatewayService } from "../daemon/service.js";
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
import { renderSystemdUnavailableHints } from "../daemon/systemd-hints.js";
@@ -27,53 +21,6 @@ import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
import { healthCommand } from "./health.js";
import { formatHealthCheckFailure } from "./health-format.js";
async function maybeRepairLaunchAgentBootstrap(params: {
env: Record<string, string | undefined>;
title: string;
runtime: RuntimeEnv;
prompter: DoctorPrompter;
}): Promise<boolean> {
if (process.platform !== "darwin") return false;
const listed = await isLaunchAgentListed({ env: params.env });
if (!listed) return false;
const loaded = await isLaunchAgentLoaded({ env: params.env });
if (loaded) return false;
const plistExists = await launchAgentPlistExists(params.env);
if (!plistExists) return false;
note(
"LaunchAgent is listed but not loaded in launchd.",
`${params.title} LaunchAgent`,
);
const shouldFix = await params.prompter.confirmSkipInNonInteractive({
message: `Repair ${params.title} LaunchAgent bootstrap now?`,
initialValue: true,
});
if (!shouldFix) return false;
params.runtime.log(`Bootstrapping ${params.title} LaunchAgent...`);
const repair = await repairLaunchAgentBootstrap({ env: params.env });
if (!repair.ok) {
params.runtime.error(
`${params.title} LaunchAgent bootstrap failed: ${repair.detail ?? "unknown error"}`,
);
return false;
}
const verified = await isLaunchAgentLoaded({ env: params.env });
if (!verified) {
params.runtime.error(`${params.title} LaunchAgent still not loaded after repair.`);
return false;
}
note(`${params.title} LaunchAgent repaired.`, `${params.title} LaunchAgent`);
return true;
}
export async function maybeRepairGatewayDaemon(params: {
cfg: ClawdbotConfig;
runtime: RuntimeEnv;
@@ -85,33 +32,12 @@ export async function maybeRepairGatewayDaemon(params: {
if (params.healthOk) return;
const service = resolveGatewayService();
let loaded = await service.isLoaded({ env: process.env });
const loaded = await service.isLoaded({ env: process.env });
let serviceRuntime: Awaited<ReturnType<typeof service.readRuntime>> | undefined;
if (loaded) {
serviceRuntime = await service.readRuntime(process.env).catch(() => undefined);
}
if (process.platform === "darwin" && params.cfg.gateway?.mode !== "remote") {
const gatewayRepaired = await maybeRepairLaunchAgentBootstrap({
env: process.env,
title: "Gateway",
runtime: params.runtime,
prompter: params.prompter,
});
await maybeRepairLaunchAgentBootstrap({
env: { ...process.env, CLAWDBOT_LAUNCHD_LABEL: resolveNodeLaunchAgentLabel() },
title: "Node",
runtime: params.runtime,
prompter: params.prompter,
});
if (gatewayRepaired) {
loaded = await service.isLoaded({ env: process.env });
if (loaded) {
serviceRuntime = await service.readRuntime(process.env).catch(() => undefined);
}
}
}
if (params.cfg.gateway?.mode !== "remote") {
const port = resolveGatewayPort(params.cfg, process.env);
const diagnostics = await inspectPortUsage(port);
+29 -98
View File
@@ -1,20 +1,13 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js";
import { listChannelPlugins, getChannelPlugin } from "../channels/plugins/index.js";
import type { ChannelMeta } from "../channels/plugins/types.js";
import {
formatChannelPrimerLine,
formatChannelSelectionLine,
listChatChannels,
} from "../channels/registry.js";
import { formatChannelPrimerLine, formatChannelSelectionLine } from "../channels/registry.js";
import type { ClawdbotConfig } from "../config/config.js";
import { isChannelConfigured } from "../config/plugin-auto-enable.js";
import type { DmPolicy } from "../config/types.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js";
import type { ChannelChoice } from "./onboard-types.js";
import {
@@ -122,20 +115,6 @@ async function collectChannelStatus(params: {
}),
),
);
const statusByChannel = new Map(statusEntries.map((entry) => [entry.channel, entry]));
const fallbackStatuses = listChatChannels()
.filter((meta) => !statusByChannel.has(meta.id))
.map((meta) => {
const configured = isChannelConfigured(params.cfg, meta.id);
const statusLabel = configured ? "configured (plugin disabled)" : "not configured";
return {
channel: meta.id,
configured,
statusLines: [`${meta.label}: ${statusLabel}`],
selectionHint: configured ? "configured · plugin disabled" : "not configured",
quickstartScore: 0,
};
});
const catalogStatuses = catalogEntries.map((entry) => ({
channel: entry.id,
configured: false,
@@ -143,13 +122,13 @@ async function collectChannelStatus(params: {
selectionHint: "plugin · install",
quickstartScore: 0,
}));
const combinedStatuses = [...statusEntries, ...fallbackStatuses, ...catalogStatuses];
const mergedStatusByChannel = new Map(combinedStatuses.map((entry) => [entry.channel, entry]));
const combinedStatuses = [...statusEntries, ...catalogStatuses];
const statusByChannel = new Map(combinedStatuses.map((entry) => [entry.channel, entry]));
const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines);
return {
installedPlugins,
catalogEntries,
statusByChannel: mergedStatusByChannel,
statusByChannel,
statusLines,
};
}
@@ -291,28 +270,17 @@ export async function setupChannels(
});
if (!shouldConfigure) return cfg;
const corePrimer = listChatChannels().map((meta) => ({
id: meta.id as ChannelChoice,
label: meta.label,
blurb: meta.blurb,
}));
const coreIds = new Set(corePrimer.map((entry) => entry.id));
const primerChannels = [
...corePrimer,
...installedPlugins
.filter((plugin) => !coreIds.has(plugin.id as ChannelChoice))
.map((plugin) => ({
id: plugin.id as ChannelChoice,
label: plugin.meta.label,
blurb: plugin.meta.blurb,
})),
...catalogEntries
.filter((entry) => !coreIds.has(entry.id as ChannelChoice))
.map((entry) => ({
id: entry.id as ChannelChoice,
label: entry.meta.label,
blurb: entry.meta.blurb,
})),
...installedPlugins.map((plugin) => ({
id: plugin.id as ChannelChoice,
label: plugin.meta.label,
blurb: plugin.meta.blurb,
})),
...catalogEntries.map((entry) => ({
id: entry.id as ChannelChoice,
label: entry.meta.label,
blurb: entry.meta.blurb,
})),
];
await noteChannelPrimer(prompter, primerChannels);
@@ -333,11 +301,7 @@ export async function setupChannels(
const resolveDisabledHint = (channel: ChannelChoice): string | undefined => {
const plugin = getChannelPlugin(channel);
if (!plugin) {
if (next.plugins?.entries?.[channel]?.enabled === false) return "plugin disabled";
if (next.plugins?.enabled === false) return "plugins disabled";
return undefined;
}
if (!plugin) return undefined;
const accountId = resolveChannelDefaultAccountId({ plugin, cfg: next });
const account = plugin.config.resolveAccount(next, accountId);
let enabled: boolean | undefined;
@@ -372,28 +336,21 @@ export async function setupChannels(
});
const getChannelEntries = () => {
const core = listChatChannels();
const installed = listChannelPlugins();
const installedIds = new Set(installed.map((plugin) => plugin.id));
const catalog = listChannelPluginCatalogEntries().filter(
(entry) => !installedIds.has(entry.id),
);
const metaById = new Map<string, ChannelMeta>();
for (const meta of core) {
metaById.set(meta.id, meta);
}
for (const plugin of installed) {
metaById.set(plugin.id, plugin.meta);
}
for (const entry of catalog) {
if (!metaById.has(entry.id)) {
metaById.set(entry.id, entry.meta);
}
}
const entries = Array.from(metaById, ([id, meta]) => ({
id: id as ChannelChoice,
meta,
}));
const entries = [
...installed.map((plugin) => ({
id: plugin.id as ChannelChoice,
meta: plugin.meta,
})),
...catalog.map((entry) => ({
id: entry.id as ChannelChoice,
meta: entry.meta,
})),
];
return {
entries,
catalog,
@@ -408,31 +365,6 @@ export async function setupChannels(
statusByChannel.set(channel, status);
};
const ensureBundledPluginEnabled = async (channel: ChannelChoice): Promise<boolean> => {
if (getChannelPlugin(channel)) return true;
const result = enablePluginInConfig(next, channel);
next = result.config;
if (!result.enabled) {
await prompter.note(
`Cannot enable ${channel}: ${result.reason ?? "plugin disabled"}.`,
"Channel setup",
);
return false;
}
const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
reloadOnboardingPluginRegistry({
cfg: next,
runtime,
workspaceDir,
});
if (!getChannelPlugin(channel)) {
await prompter.note(`${channel} plugin not available.`, "Channel setup");
return false;
}
await refreshStatus(channel);
return true;
};
const configureChannel = async (channel: ChannelChoice) => {
const adapter = getChannelOnboardingAdapter(channel);
if (!adapter) {
@@ -544,9 +476,6 @@ export async function setupChannels(
workspaceDir,
});
await refreshStatus(channel);
} else {
const enabled = await ensureBundledPluginEnabled(channel);
if (!enabled) return;
}
const plugin = getChannelPlugin(channel);
@@ -602,8 +531,10 @@ export async function setupChannels(
options?.onSelection?.(selection);
const selectionNotes = new Map<string, string>();
const { entries: selectionEntries } = getChannelEntries();
for (const entry of selectionEntries) {
for (const plugin of listChannelPlugins()) {
selectionNotes.set(plugin.id, formatChannelSelectionLine(plugin.meta, formatDocsLink));
}
for (const entry of listChannelPluginCatalogEntries()) {
selectionNotes.set(entry.id, formatChannelSelectionLine(entry.meta, formatDocsLink));
}
const selectedLines = selection
-38
View File
@@ -77,39 +77,6 @@ describe("onboard-hooks", () => {
configChecks: [],
install: [],
},
{
name: "command-logger",
description: "Log all command events to a centralized audit file",
source: "clawdbot-bundled",
pluginId: undefined,
filePath: "/mock/workspace/hooks/command-logger/HOOK.md",
baseDir: "/mock/workspace/hooks/command-logger",
handlerPath: "/mock/workspace/hooks/command-logger/handler.js",
hookKey: "command-logger",
emoji: "📝",
events: ["command"],
homepage: undefined,
always: false,
disabled: false,
eligible,
managedByPlugin: false,
requirements: {
bins: [],
anyBins: [],
env: [],
config: ["workspace.dir"],
os: [],
},
missing: {
bins: [],
anyBins: [],
env: [],
config: eligible ? [] : ["workspace.dir"],
os: [],
},
configChecks: [],
install: [],
},
],
});
@@ -138,11 +105,6 @@ describe("onboard-hooks", () => {
label: "💾 session-memory",
hint: "Save session context to memory when /new command is issued",
},
{
value: "command-logger",
label: "📝 command-logger",
hint: "Log all command events to a centralized audit file",
},
],
});
});
+4 -4
View File
@@ -23,10 +23,10 @@ export async function setupInternalHooks(
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const report = buildWorkspaceHookStatus(workspaceDir, { config: cfg });
// Show every eligible hook so users can opt in during onboarding.
const eligibleHooks = report.hooks.filter((h) => h.eligible);
// Filter for eligible and recommended hooks (session-memory is recommended)
const recommendedHooks = report.hooks.filter((h) => h.eligible && h.name === "session-memory");
if (eligibleHooks.length === 0) {
if (recommendedHooks.length === 0) {
await prompter.note(
"No eligible hooks found. You can configure hooks later in your config.",
"No Hooks Available",
@@ -38,7 +38,7 @@ export async function setupInternalHooks(
message: "Enable hooks?",
options: [
{ value: "__skip__", label: "Skip for now" },
...eligibleHooks.map((hook) => ({
...recommendedHooks.map((hook) => ({
value: hook.name,
label: `${hook.emoji ?? "🔗"} ${hook.name}`,
hint: hook.description,
-3
View File
@@ -19,8 +19,6 @@ export type AuthChoice =
| "codex-cli"
| "apiKey"
| "gemini-api-key"
| "google-antigravity"
| "google-gemini-cli"
| "zai-api-key"
| "minimax-cloud"
| "minimax"
@@ -28,7 +26,6 @@ export type AuthChoice =
| "minimax-api-lightning"
| "opencode-zen"
| "github-copilot"
| "copilot-proxy"
| "qwen-portal"
| "skip";
export type GatewayAuthChoice = "off" | "token" | "password";
+34 -4
View File
@@ -5,7 +5,6 @@ import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.j
import type { ClawdbotConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging.js";
import { recordPluginInstall } from "../../plugins/installs.js";
import { enablePluginInConfig } from "../../plugins/enable.js";
import { loadClawdbotPlugins } from "../../plugins/loader.js";
import { installPluginFromNpmSpec } from "../../plugins/install.js";
import type { RuntimeEnv } from "../../runtime.js";
@@ -49,6 +48,37 @@ function resolveLocalPath(
return null;
}
function ensurePluginEnabled(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig {
const entries = {
...cfg.plugins?.entries,
[pluginId]: {
...(cfg.plugins?.entries?.[pluginId] as Record<string, unknown> | undefined),
enabled: true,
},
};
const next: ClawdbotConfig = {
...cfg,
plugins: {
...cfg.plugins,
...(cfg.plugins?.enabled === false ? { enabled: true } : {}),
entries,
},
};
return ensurePluginAllowlist(next, pluginId);
}
function ensurePluginAllowlist(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig {
const allow = cfg.plugins?.allow;
if (!allow || allow.includes(pluginId)) return cfg;
return {
...cfg,
plugins: {
...cfg.plugins,
allow: [...allow, pluginId],
},
};
}
function addPluginLoadPath(cfg: ClawdbotConfig, pluginPath: string): ClawdbotConfig {
const existing = cfg.plugins?.load?.paths ?? [];
const merged = Array.from(new Set([...existing, pluginPath]));
@@ -115,7 +145,7 @@ export async function ensureOnboardingPluginInstalled(params: {
if (choice === "local" && localPath) {
next = addPluginLoadPath(next, localPath);
next = enablePluginInConfig(next, entry.id).config;
next = ensurePluginEnabled(next, entry.id);
return { cfg: next, installed: true };
}
@@ -128,7 +158,7 @@ export async function ensureOnboardingPluginInstalled(params: {
});
if (result.ok) {
next = enablePluginInConfig(next, result.pluginId).config;
next = ensurePluginEnabled(next, result.pluginId);
next = recordPluginInstall(next, {
pluginId: result.pluginId,
source: "npm",
@@ -151,7 +181,7 @@ export async function ensureOnboardingPluginInstalled(params: {
});
if (fallback) {
next = addPluginLoadPath(next, localPath);
next = enablePluginInConfig(next, entry.id).config;
next = ensurePluginEnabled(next, entry.id);
return { cfg: next, installed: true };
}
}
+8 -53
View File
@@ -19,18 +19,15 @@ import {
applySessionDefaults,
applyTalkApiKey,
} from "./defaults.js";
import { VERSION } from "../version.js";
import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js";
import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
import { applyLegacyMigrations, findLegacyConfigIssues } from "./legacy.js";
import { normalizeConfigPaths } from "./normalize-paths.js";
import { resolveConfigPath, resolveStateDir } from "./paths.js";
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
import { applyConfigOverrides } from "./runtime-overrides.js";
import type { ClawdbotConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
import { validateConfigObject } from "./validation.js";
import { ClawdbotSchema } from "./zod-schema.js";
import { compareClawdbotVersions } from "./version.js";
// Re-export for backwards compatibility
export { CircularIncludeError, ConfigIncludeError } from "./includes.js";
@@ -149,30 +146,6 @@ function formatLegacyMigrationLog(changes: string[]): string {
return `Auto-migrated config:\n${changes.map((entry) => `- ${entry}`).join("\n")}`;
}
function stampConfigVersion(cfg: ClawdbotConfig): ClawdbotConfig {
const now = new Date().toISOString();
return {
...cfg,
meta: {
...cfg.meta,
lastTouchedVersion: VERSION,
lastTouchedAt: now,
},
};
}
function warnIfConfigFromFuture(cfg: ClawdbotConfig, logger: Pick<typeof console, "warn">): void {
const touched = cfg.meta?.lastTouchedVersion;
if (!touched) return;
const cmp = compareClawdbotVersions(VERSION, touched);
if (cmp === null) return;
if (cmp < 0) {
logger.warn(
`Config was last written by a newer Clawdbot (${touched}); current version is ${VERSION}.`,
);
}
}
function applyConfigEnv(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): void {
const envConfig = cfg.env;
if (!envConfig) return;
@@ -232,9 +205,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const writeConfigFileSync = (cfg: ClawdbotConfig) => {
const dir = path.dirname(configPath);
deps.fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2)
.trimEnd()
.concat("\n");
const json = JSON.stringify(applyModelDefaults(cfg), null, 2).trimEnd().concat("\n");
const tmp = path.join(
dir,
@@ -306,13 +277,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const substituted = resolveConfigEnvVars(resolved, deps.env);
const migrated = applyLegacyMigrations(substituted);
let resolvedConfig = migrated.next ?? substituted;
const autoEnable = applyPluginAutoEnable({
config: coerceConfig(resolvedConfig),
env: deps.env,
});
resolvedConfig = autoEnable.config;
const migrationChanges = [...migrated.changes, ...autoEnable.changes];
const resolvedConfig = migrated.next ?? substituted;
warnOnConfigMiskeys(resolvedConfig, deps.logger);
if (typeof resolvedConfig !== "object" || resolvedConfig === null) return {};
const validated = ClawdbotSchema.safeParse(resolvedConfig);
@@ -323,9 +288,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}
return {};
}
warnIfConfigFromFuture(validated.data as ClawdbotConfig, deps.logger);
if (migrationChanges.length > 0) {
deps.logger.warn(formatLegacyMigrationLog(migrationChanges));
if (migrated.next && migrated.changes.length > 0) {
deps.logger.warn(formatLegacyMigrationLog(migrated.changes));
try {
writeConfigFileSync(resolvedConfig as ClawdbotConfig);
} catch (err) {
@@ -462,13 +426,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}
const migrated = applyLegacyMigrations(substituted);
let resolvedConfigRaw = migrated.next ?? substituted;
const autoEnable = applyPluginAutoEnable({
config: coerceConfig(resolvedConfigRaw),
env: deps.env,
});
resolvedConfigRaw = autoEnable.config;
const migrationChanges = [...migrated.changes, ...autoEnable.changes];
const resolvedConfigRaw = migrated.next ?? substituted;
const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw);
const validated = validateConfigObject(resolvedConfigRaw);
@@ -486,9 +444,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
};
}
warnIfConfigFromFuture(validated.config, deps.logger);
if (migrationChanges.length > 0) {
deps.logger.warn(formatLegacyMigrationLog(migrationChanges));
if (migrated.next && migrated.changes.length > 0) {
deps.logger.warn(formatLegacyMigrationLog(migrated.changes));
await writeConfigFile(validated.config).catch((err) => {
deps.logger.warn(`Failed to write migrated config at ${configPath}: ${String(err)}`);
});
@@ -529,9 +486,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
async function writeConfigFile(cfg: ClawdbotConfig) {
const dir = path.dirname(configPath);
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2)
.trimEnd()
.concat("\n");
const json = JSON.stringify(applyModelDefaults(cfg), null, 2).trimEnd().concat("\n");
const tmp = path.join(
dir,
-58
View File
@@ -1,58 +0,0 @@
import { describe, expect, it } from "vitest";
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
describe("applyPluginAutoEnable", () => {
it("enables configured channel plugins and updates allowlist", () => {
const result = applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x" } },
plugins: { allow: ["telegram"] },
},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBe(true);
expect(result.config.plugins?.allow).toEqual(["telegram", "slack"]);
expect(result.changes.join("\n")).toContain('Enabled plugin "slack"');
});
it("respects explicit disable", () => {
const result = applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x" } },
plugins: { entries: { slack: { enabled: false } } },
},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBe(false);
expect(result.changes).toEqual([]);
});
it("enables provider auth plugins when profiles exist", () => {
const result = applyPluginAutoEnable({
config: {
auth: {
profiles: {
"google-antigravity:default": {
provider: "google-antigravity",
mode: "oauth",
},
},
},
},
});
expect(result.config.plugins?.entries?.["google-antigravity-auth"]?.enabled).toBe(true);
});
it("skips when plugins are globally disabled", () => {
const result = applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x" } },
plugins: { enabled: false },
},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBeUndefined();
expect(result.changes).toEqual([]);
});
});
-328
View File
@@ -1,328 +0,0 @@
import type { ClawdbotConfig } from "./config.js";
import { hasAnyWhatsAppAuth } from "../web/accounts.js";
import { normalizeProviderId } from "../agents/model-selection.js";
type PluginEnableChange = {
pluginId: string;
reason: string;
};
export type PluginAutoEnableResult = {
config: ClawdbotConfig;
changes: string[];
};
const CHANNEL_PLUGIN_IDS = [
"whatsapp",
"telegram",
"discord",
"slack",
"signal",
"imessage",
"msteams",
"matrix",
"zalo",
"zalouser",
"bluebubbles",
] as const;
const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [
{ pluginId: "google-antigravity-auth", providerId: "google-antigravity" },
{ pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" },
{ pluginId: "qwen-portal-auth", providerId: "qwen-portal" },
{ pluginId: "copilot-proxy", providerId: "copilot-proxy" },
];
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function recordHasKeys(value: unknown): boolean {
return isRecord(value) && Object.keys(value).length > 0;
}
function accountsHaveKeys(
value: unknown,
keys: string[],
): boolean {
if (!isRecord(value)) return false;
for (const account of Object.values(value)) {
if (!isRecord(account)) continue;
for (const key of keys) {
if (hasNonEmptyString(account[key])) return true;
}
}
return false;
}
function resolveChannelConfig(cfg: ClawdbotConfig, channelId: string): Record<string, unknown> | null {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[channelId];
return isRecord(entry) ? entry : null;
}
function isTelegramConfigured(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): boolean {
if (hasNonEmptyString(env.TELEGRAM_BOT_TOKEN)) return true;
const entry = resolveChannelConfig(cfg, "telegram");
if (!entry) return false;
if (hasNonEmptyString(entry.botToken) || hasNonEmptyString(entry.tokenFile)) return true;
if (accountsHaveKeys(entry.accounts, ["botToken", "tokenFile"])) return true;
return recordHasKeys(entry);
}
function isDiscordConfigured(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): boolean {
if (hasNonEmptyString(env.DISCORD_BOT_TOKEN)) return true;
const entry = resolveChannelConfig(cfg, "discord");
if (!entry) return false;
if (hasNonEmptyString(entry.token)) return true;
if (accountsHaveKeys(entry.accounts, ["token"])) return true;
return recordHasKeys(entry);
}
function isSlackConfigured(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): boolean {
if (
hasNonEmptyString(env.SLACK_BOT_TOKEN) ||
hasNonEmptyString(env.SLACK_APP_TOKEN) ||
hasNonEmptyString(env.SLACK_USER_TOKEN)
) {
return true;
}
const entry = resolveChannelConfig(cfg, "slack");
if (!entry) return false;
if (
hasNonEmptyString(entry.botToken) ||
hasNonEmptyString(entry.appToken) ||
hasNonEmptyString(entry.userToken)
) {
return true;
}
if (accountsHaveKeys(entry.accounts, ["botToken", "appToken", "userToken"])) return true;
return recordHasKeys(entry);
}
function isSignalConfigured(cfg: ClawdbotConfig): boolean {
const entry = resolveChannelConfig(cfg, "signal");
if (!entry) return false;
if (
hasNonEmptyString(entry.account) ||
hasNonEmptyString(entry.httpUrl) ||
hasNonEmptyString(entry.httpHost) ||
typeof entry.httpPort === "number" ||
hasNonEmptyString(entry.cliPath)
) {
return true;
}
if (accountsHaveKeys(entry.accounts, ["account", "httpUrl", "httpHost", "cliPath"])) return true;
return recordHasKeys(entry);
}
function isIMessageConfigured(cfg: ClawdbotConfig): boolean {
const entry = resolveChannelConfig(cfg, "imessage");
if (!entry) return false;
if (hasNonEmptyString(entry.cliPath)) return true;
return recordHasKeys(entry);
}
function isWhatsAppConfigured(cfg: ClawdbotConfig): boolean {
if (hasAnyWhatsAppAuth(cfg)) return true;
const entry = resolveChannelConfig(cfg, "whatsapp");
if (!entry) return false;
return recordHasKeys(entry);
}
function isGenericChannelConfigured(cfg: ClawdbotConfig, channelId: string): boolean {
const entry = resolveChannelConfig(cfg, channelId);
return recordHasKeys(entry);
}
export function isChannelConfigured(
cfg: ClawdbotConfig,
channelId: string,
env: NodeJS.ProcessEnv = process.env,
): boolean {
switch (channelId) {
case "whatsapp":
return isWhatsAppConfigured(cfg);
case "telegram":
return isTelegramConfigured(cfg, env);
case "discord":
return isDiscordConfigured(cfg, env);
case "slack":
return isSlackConfigured(cfg, env);
case "signal":
return isSignalConfigured(cfg);
case "imessage":
return isIMessageConfigured(cfg);
default:
return isGenericChannelConfigured(cfg, channelId);
}
}
function collectModelRefs(cfg: ClawdbotConfig): string[] {
const refs: string[] = [];
const pushModelRef = (value: unknown) => {
if (typeof value === "string" && value.trim()) refs.push(value.trim());
};
const collectFromAgent = (agent: Record<string, unknown> | null | undefined) => {
if (!agent) return;
const model = agent.model;
if (typeof model === "string") {
pushModelRef(model);
} else if (isRecord(model)) {
pushModelRef(model.primary);
const fallbacks = model.fallbacks;
if (Array.isArray(fallbacks)) {
for (const entry of fallbacks) pushModelRef(entry);
}
}
const models = agent.models;
if (isRecord(models)) {
for (const key of Object.keys(models)) {
pushModelRef(key);
}
}
};
const defaults = cfg.agents?.defaults as Record<string, unknown> | undefined;
collectFromAgent(defaults);
const list = cfg.agents?.list;
if (Array.isArray(list)) {
for (const entry of list) {
if (isRecord(entry)) collectFromAgent(entry);
}
}
return refs;
}
function extractProviderFromModelRef(value: string): string | null {
const trimmed = value.trim();
const slash = trimmed.indexOf("/");
if (slash <= 0) return null;
return normalizeProviderId(trimmed.slice(0, slash));
}
function isProviderConfigured(cfg: ClawdbotConfig, providerId: string): boolean {
const normalized = normalizeProviderId(providerId);
const profiles = cfg.auth?.profiles;
if (profiles && typeof profiles === "object") {
for (const profile of Object.values(profiles)) {
if (!isRecord(profile)) continue;
const provider = normalizeProviderId(String(profile.provider ?? ""));
if (provider === normalized) return true;
}
}
const providerConfig = cfg.models?.providers;
if (providerConfig && typeof providerConfig === "object") {
for (const key of Object.keys(providerConfig)) {
if (normalizeProviderId(key) === normalized) return true;
}
}
const modelRefs = collectModelRefs(cfg);
for (const ref of modelRefs) {
const provider = extractProviderFromModelRef(ref);
if (provider && provider === normalized) return true;
}
return false;
}
function resolveConfiguredPlugins(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): PluginEnableChange[] {
const changes: PluginEnableChange[] = [];
for (const channelId of CHANNEL_PLUGIN_IDS) {
if (isChannelConfigured(cfg, channelId, env)) {
changes.push({
pluginId: channelId,
reason: `${channelId} configured`,
});
}
}
for (const mapping of PROVIDER_PLUGIN_IDS) {
if (isProviderConfigured(cfg, mapping.providerId)) {
changes.push({
pluginId: mapping.pluginId,
reason: `${mapping.providerId} auth configured`,
});
}
}
return changes;
}
function isPluginExplicitlyDisabled(cfg: ClawdbotConfig, pluginId: string): boolean {
const entry = cfg.plugins?.entries?.[pluginId];
return entry?.enabled === false;
}
function isPluginDenied(cfg: ClawdbotConfig, pluginId: string): boolean {
const deny = cfg.plugins?.deny;
return Array.isArray(deny) && deny.includes(pluginId);
}
function ensureAllowlisted(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig {
const allow = cfg.plugins?.allow;
if (!Array.isArray(allow) || allow.includes(pluginId)) return cfg;
return {
...cfg,
plugins: {
...cfg.plugins,
allow: [...allow, pluginId],
},
};
}
function enablePluginEntry(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig {
const entries = {
...cfg.plugins?.entries,
[pluginId]: {
...(cfg.plugins?.entries?.[pluginId] as Record<string, unknown> | undefined),
enabled: true,
},
};
return {
...cfg,
plugins: {
...cfg.plugins,
entries,
...(cfg.plugins?.enabled === false ? { enabled: true } : {}),
},
};
}
export function applyPluginAutoEnable(params: {
config: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
}): PluginAutoEnableResult {
const env = params.env ?? process.env;
const configured = resolveConfiguredPlugins(params.config, env);
if (configured.length === 0) {
return { config: params.config, changes: [] };
}
let next = params.config;
const changes: string[] = [];
if (next.plugins?.enabled === false) {
return { config: next, changes };
}
for (const entry of configured) {
if (isPluginDenied(next, entry.pluginId)) continue;
if (isPluginExplicitlyDisabled(next, entry.pluginId)) continue;
const allow = next.plugins?.allow;
const allowMissing = Array.isArray(allow) && !allow.includes(entry.pluginId);
const alreadyEnabled = next.plugins?.entries?.[entry.pluginId]?.enabled === true;
if (alreadyEnabled && !allowMissing) continue;
next = enablePluginEntry(next, entry.pluginId);
next = ensureAllowlisted(next, entry.pluginId);
changes.push(`Enabled plugin "${entry.pluginId}" (${entry.reason}).`);
}
return { config: next, changes };
}
+6 -10
View File
@@ -97,8 +97,6 @@ const GROUP_ORDER: Record<string, number> = {
};
const FIELD_LABELS: Record<string, string> = {
"meta.lastTouchedVersion": "Config Last Touched Version",
"meta.lastTouchedAt": "Config Last Touched At",
"update.channel": "Update Channel",
"update.checkOnStart": "Update Check on Start",
"gateway.remote.url": "Remote Gateway URL",
@@ -298,8 +296,6 @@ const FIELD_LABELS: Record<string, string> = {
};
const FIELD_HELP: Record<string, string> = {
"meta.lastTouchedVersion": "Auto-set when Clawdbot writes the config.",
"meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).",
"update.channel": 'Update channel for npm installs ("stable" or "beta").',
"update.checkOnStart": "Check for npm updates when the gateway starts (default: true).",
"gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).",
@@ -385,19 +381,19 @@ const FIELD_HELP: Record<string, string> = {
"agents.defaults.memorySearch.remote.headers":
"Extra headers for remote embeddings (merged; remote overrides OpenAI headers).",
"agents.defaults.memorySearch.remote.batch.enabled":
"Enable batch API for memory embeddings (OpenAI/Gemini; default: true).",
"Enable OpenAI Batch API for memory embeddings (default: true).",
"agents.defaults.memorySearch.remote.batch.wait":
"Wait for batch completion when indexing (default: true).",
"Wait for OpenAI batch completion when indexing (default: true).",
"agents.defaults.memorySearch.remote.batch.concurrency":
"Max concurrent embedding batch jobs for memory indexing (default: 2).",
"Max concurrent OpenAI batch jobs for memory indexing (default: 2).",
"agents.defaults.memorySearch.remote.batch.pollIntervalMs":
"Polling interval in ms for batch status (default: 2000).",
"Polling interval in ms for OpenAI batch status (default: 2000).",
"agents.defaults.memorySearch.remote.batch.timeoutMinutes":
"Timeout in minutes for batch indexing (default: 60).",
"Timeout in minutes for OpenAI batch indexing (default: 60).",
"agents.defaults.memorySearch.local.modelPath":
"Local GGUF model path or hf: URI (node-llama-cpp).",
"agents.defaults.memorySearch.fallback":
'Fallback provider when embeddings fail ("openai", "gemini", "local", or "none").',
'Fallback to OpenAI when local embeddings fail ("openai" or "none").',
"agents.defaults.memorySearch.store.path":
"SQLite index path (default: ~/.clawdbot/memory/{agentId}.sqlite).",
"agents.defaults.memorySearch.store.vector.enabled":
-6
View File
@@ -24,12 +24,6 @@ import type { SkillsConfig } from "./types.skills.js";
import type { ToolsConfig } from "./types.tools.js";
export type ClawdbotConfig = {
meta?: {
/** Last clawdbot version that wrote this config. */
lastTouchedVersion?: string;
/** ISO timestamp when this config was last written. */
lastTouchedAt?: string;
};
auth?: AuthConfig;
env?: {
/** Opt-in: import missing secrets from a login shell environment (exec `$SHELL -l -c 'env -0'`). */
+3 -17
View File
@@ -121,30 +121,16 @@ export type ToolPolicyConfig = {
};
export type ExecToolConfig = {
/** Exec host routing (default: sandbox). */
host?: "sandbox" | "gateway" | "node";
/** Exec security mode (default: deny). */
security?: "deny" | "allowlist" | "full";
/** Exec ask mode (default: on-miss). */
ask?: "off" | "on-miss" | "always";
/** Default node binding for exec.host=node (node id/name). */
node?: string;
/** Default time (ms) before an exec command auto-backgrounds. */
backgroundMs?: number;
/** Default timeout (seconds) before auto-killing exec commands. */
timeoutSec?: number;
/** How long to keep finished sessions in memory (ms). */
cleanupMs?: number;
/** Emit a system event and heartbeat when a backgrounded exec exits. */
notifyOnExit?: boolean;
/** apply_patch subtool configuration (experimental). */
applyPatch?: {
/** Enable apply_patch for OpenAI models (default: false). */
enabled?: boolean;
/**
* Optional allowlist of model ids that can use apply_patch.
* Accepts either raw ids (e.g. "gpt-5.2") or full ids (e.g. "openai/gpt-5.2").
*/
allowModels?: string[];
};
};
@@ -190,7 +176,7 @@ export type MemorySearchConfig = {
apiKey?: string;
headers?: Record<string, string>;
batch?: {
/** Enable batch API for embedding indexing (OpenAI/Gemini; default: true). */
/** Enable OpenAI Batch API for embedding indexing (default: true). */
enabled?: boolean;
/** Wait for batch completion (default: true). */
wait?: boolean;
@@ -202,8 +188,8 @@ export type MemorySearchConfig = {
timeoutMinutes?: number;
};
};
/** Fallback behavior when embeddings fail. */
fallback?: "openai" | "gemini" | "local" | "none";
/** Fallback behavior when local embeddings fail. */
fallback?: "openai" | "none";
/** Embedding model id (remote) or alias (local). */
model?: string;
/** Local embedding settings (node-llama-cpp). */
-32
View File
@@ -1,32 +0,0 @@
export type ClawdbotVersion = {
major: number;
minor: number;
patch: number;
revision: number;
};
const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-(\d+))?/;
export function parseClawdbotVersion(raw: string | null | undefined): ClawdbotVersion | null {
if (!raw) return null;
const match = raw.trim().match(VERSION_RE);
if (!match) return null;
const [, major, minor, patch, revision] = match;
return {
major: Number.parseInt(major, 10),
minor: Number.parseInt(minor, 10),
patch: Number.parseInt(patch, 10),
revision: revision ? Number.parseInt(revision, 10) : 0,
};
}
export function compareClawdbotVersions(a: string | null | undefined, b: string | null | undefined): number | null {
const parsedA = parseClawdbotVersion(a);
const parsedB = parseClawdbotVersion(b);
if (!parsedA || !parsedB) return null;
if (parsedA.major !== parsedB.major) return parsedA.major < parsedB.major ? -1 : 1;
if (parsedA.minor !== parsedB.minor) return parsedA.minor < parsedB.minor ? -1 : 1;
if (parsedA.patch !== parsedB.patch) return parsedA.patch < parsedB.patch ? -1 : 1;
if (parsedA.revision !== parsedB.revision) return parsedA.revision < parsedB.revision ? -1 : 1;
return 0;
}
+20 -4
View File
@@ -183,6 +183,24 @@ export const AgentToolsSchema = z
allowFrom: ElevatedAllowFromSchema,
})
.optional(),
exec: z
.object({
host: z.enum(["sandbox", "gateway", "node"]).optional(),
security: z.enum(["deny", "allowlist", "full"]).optional(),
ask: z.enum(["off", "on-miss", "always"]).optional(),
node: z.string().optional(),
backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(),
notifyOnExit: z.boolean().optional(),
applyPatch: z
.object({
enabled: z.boolean().optional(),
allowModels: z.array(z.string()).optional(),
})
.optional(),
})
.optional(),
sandbox: z
.object({
tools: ToolPolicySchema,
@@ -200,7 +218,7 @@ export const MemorySearchSchema = z
sessionMemory: z.boolean().optional(),
})
.optional(),
provider: z.union([z.literal("openai"), z.literal("local"), z.literal("gemini")]).optional(),
provider: z.union([z.literal("openai"), z.literal("gemini"), z.literal("local")]).optional(),
remote: z
.object({
baseUrl: z.string().optional(),
@@ -217,9 +235,7 @@ export const MemorySearchSchema = z
.optional(),
})
.optional(),
fallback: z
.union([z.literal("openai"), z.literal("gemini"), z.literal("local"), z.literal("none")])
.optional(),
fallback: z.union([z.literal("openai"), z.literal("none")]).optional(),
model: z.string().optional(),
local: z
.object({
-6
View File
@@ -8,12 +8,6 @@ import { CommandsSchema, MessagesSchema, SessionSchema } from "./zod-schema.sess
export const ClawdbotSchema = z
.object({
meta: z
.object({
lastTouchedVersion: z.string().optional(),
lastTouchedAt: z.string().optional(),
})
.optional(),
env: z
.object({
shellEnv: z
+1 -122
View File
@@ -5,88 +5,7 @@ import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest";
import {
installLaunchAgent,
isLaunchAgentListed,
parseLaunchctlPrint,
repairLaunchAgentBootstrap,
resolveLaunchAgentPlistPath,
} from "./launchd.js";
async function withLaunchctlStub(
options: { listOutput?: string },
run: (context: { env: Record<string, string | undefined>; logPath: string }) => Promise<void>,
) {
const originalPath = process.env.PATH;
const originalLogPath = process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG;
const originalListOutput = process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT;
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-launchctl-test-"));
try {
const binDir = path.join(tmpDir, "bin");
const homeDir = path.join(tmpDir, "home");
const logPath = path.join(tmpDir, "launchctl.log");
await fs.mkdir(binDir, { recursive: true });
await fs.mkdir(homeDir, { recursive: true });
const stubJsPath = path.join(binDir, "launchctl.js");
await fs.writeFile(
stubJsPath,
[
'import fs from "node:fs";',
"const args = process.argv.slice(2);",
"const logPath = process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG;",
"if (logPath) {",
' fs.appendFileSync(logPath, JSON.stringify(args) + "\\n", "utf8");',
"}",
'if (args[0] === "list") {',
" const output = process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT || \"\";",
" process.stdout.write(output);",
"}",
"process.exit(0);",
"",
].join("\n"),
"utf8",
);
if (process.platform === "win32") {
await fs.writeFile(
path.join(binDir, "launchctl.cmd"),
`@echo off\r\nnode "%~dp0\\launchctl.js" %*\r\n`,
"utf8",
);
} else {
const shPath = path.join(binDir, "launchctl");
await fs.writeFile(shPath, `#!/bin/sh\nnode "$(dirname "$0")/launchctl.js" "$@"\n`, "utf8");
await fs.chmod(shPath, 0o755);
}
process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG = logPath;
process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT = options.listOutput ?? "";
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`;
await run({
env: {
HOME: homeDir,
CLAWDBOT_PROFILE: "default",
},
logPath,
});
} finally {
process.env.PATH = originalPath;
if (originalLogPath === undefined) {
delete process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG;
} else {
process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG = originalLogPath;
}
if (originalListOutput === undefined) {
delete process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT;
} else {
process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT = originalListOutput;
}
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
import { installLaunchAgent, parseLaunchctlPrint, resolveLaunchAgentPlistPath } from "./launchd.js";
describe("launchd runtime parsing", () => {
it("parses state, pid, and exit status", () => {
@@ -105,46 +24,6 @@ describe("launchd runtime parsing", () => {
});
});
describe("launchctl list detection", () => {
it("detects the resolved label in launchctl list", async () => {
await withLaunchctlStub(
{ listOutput: "123 0 com.clawdbot.gateway\n" },
async ({ env }) => {
const listed = await isLaunchAgentListed({ env });
expect(listed).toBe(true);
},
);
});
it("returns false when the label is missing", async () => {
await withLaunchctlStub({ listOutput: "123 0 com.other.service\n" }, async ({ env }) => {
const listed = await isLaunchAgentListed({ env });
expect(listed).toBe(false);
});
});
});
describe("launchd bootstrap repair", () => {
it("bootstraps and kickstarts the resolved label", async () => {
await withLaunchctlStub({}, async ({ env, logPath }) => {
const repair = await repairLaunchAgentBootstrap({ env });
expect(repair.ok).toBe(true);
const calls = (await fs.readFile(logPath, "utf8"))
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as string[]);
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
const label = "com.clawdbot.gateway";
const plistPath = resolveLaunchAgentPlistPath(env);
expect(calls).toContainEqual(["bootstrap", domain, plistPath]);
expect(calls).toContainEqual(["kickstart", "-k", `${domain}/${label}`]);
});
});
});
describe("launchd install", () => {
it("enables service before bootstrap (clears persisted disabled state)", async () => {
const originalPath = process.env.PATH;
+3 -34
View File
@@ -170,22 +170,9 @@ export async function isLaunchAgentLoaded(args: {
return res.code === 0;
}
export async function isLaunchAgentListed(args: {
env?: Record<string, string | undefined>;
}): Promise<boolean> {
const label = resolveLaunchAgentLabel({ env: args.env });
const res = await execLaunchctl(["list"]);
if (res.code !== 0) return false;
return res.stdout
.split(/\r?\n/)
.some((line) => line.trim().split(/\s+/).at(-1) === label);
}
export async function launchAgentPlistExists(
env: Record<string, string | undefined>,
): Promise<boolean> {
async function hasLaunchAgentPlist(env: Record<string, string | undefined>): Promise<boolean> {
const plistPath = resolveLaunchAgentPlistPath(env);
try {
const plistPath = resolveLaunchAgentPlistPath(env);
await fs.access(plistPath);
return true;
} catch {
@@ -207,7 +194,7 @@ export async function readLaunchAgentRuntime(
};
}
const parsed = parseLaunchctlPrint(res.stdout || res.stderr || "");
const plistExists = await launchAgentPlistExists(env);
const plistExists = await hasLaunchAgentPlist(env);
const state = parsed.state?.toLowerCase();
const status = state === "running" || parsed.pid ? "running" : state ? "stopped" : "unknown";
return {
@@ -220,24 +207,6 @@ export async function readLaunchAgentRuntime(
};
}
export async function repairLaunchAgentBootstrap(args: {
env?: Record<string, string | undefined>;
}): Promise<{ ok: boolean; detail?: string }> {
const env = args.env ?? (process.env as Record<string, string | undefined>);
const domain = resolveGuiDomain();
const label = resolveLaunchAgentLabel({ env });
const plistPath = resolveLaunchAgentPlistPath(env);
const boot = await execLaunchctl(["bootstrap", domain, plistPath]);
if (boot.code !== 0) {
return { ok: false, detail: (boot.stderr || boot.stdout).trim() || undefined };
}
const kick = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
if (kick.code !== 0) {
return { ok: false, detail: (kick.stderr || kick.stdout).trim() || undefined };
}
return { ok: true };
}
export type LegacyLaunchAgent = {
label: string;
plistPath: string;
-2
View File
@@ -8,8 +8,6 @@ export const NodePairRequestParamsSchema = Type.Object(
displayName: Type.Optional(NonEmptyString),
platform: Type.Optional(NonEmptyString),
version: Type.Optional(NonEmptyString),
coreVersion: Type.Optional(NonEmptyString),
uiVersion: Type.Optional(NonEmptyString),
deviceFamily: Type.Optional(NonEmptyString),
modelIdentifier: Type.Optional(NonEmptyString),
caps: Type.Optional(Type.Array(NonEmptyString)),
-8
View File
@@ -42,8 +42,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
@@ -57,8 +55,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
displayName: p.displayName,
platform: p.platform,
version: p.version,
coreVersion: p.coreVersion,
uiVersion: p.uiVersion,
deviceFamily: p.deviceFamily,
modelIdentifier: p.modelIdentifier,
caps: p.caps,
@@ -219,8 +215,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
displayName: live?.displayName ?? paired?.displayName,
platform: live?.platform ?? paired?.platform,
version: live?.version ?? paired?.version,
coreVersion: live?.coreVersion ?? paired?.coreVersion,
uiVersion: live?.uiVersion ?? paired?.uiVersion,
deviceFamily: live?.deviceFamily ?? paired?.deviceFamily,
modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier,
remoteIp: live?.remoteIp ?? paired?.remoteIp,
@@ -281,8 +275,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
displayName: live?.displayName ?? paired?.displayName,
platform: live?.platform ?? paired?.platform,
version: live?.version ?? paired?.version,
coreVersion: live?.coreVersion ?? paired?.coreVersion,
uiVersion: live?.uiVersion ?? paired?.uiVersion,
deviceFamily: live?.deviceFamily ?? paired?.deviceFamily,
modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier,
remoteIp: live?.remoteIp ?? paired?.remoteIp,
+1 -23
View File
@@ -43,25 +43,6 @@ export async function startGatewayNodeBridge(params: {
}): Promise<GatewayNodeBridgeRuntime> {
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
const formatVersionLabel = (raw: string): string => {
const trimmed = raw.trim();
if (!trimmed) return raw;
if (trimmed.toLowerCase().startsWith("v")) return trimmed;
return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed;
};
const resolveNodeVersionLabel = (node: {
coreVersion?: string;
uiVersion?: string;
}): string | null => {
const core = node.coreVersion?.trim();
const ui = node.uiVersion?.trim();
const parts: string[] = [];
if (core) parts.push(`core ${formatVersionLabel(core)}`);
if (ui) parts.push(`ui ${formatVersionLabel(ui)}`);
return parts.length > 0 ? parts.join(" · ") : null;
};
const stopNodePresenceTimer = (nodeId: string) => {
const timer = nodePresenceTimers.get(nodeId);
if (timer) {
@@ -76,8 +57,6 @@ export async function startGatewayNodeBridge(params: {
displayName?: string;
remoteIp?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
platform?: string;
deviceFamily?: string;
modelIdentifier?: string;
@@ -87,7 +66,7 @@ export async function startGatewayNodeBridge(params: {
const host = node.displayName?.trim() || node.nodeId;
const rawIp = node.remoteIp?.trim();
const ip = rawIp && !isLoopbackAddress(rawIp) ? rawIp : undefined;
const version = resolveNodeVersionLabel(node) ?? node.version?.trim() ?? "unknown";
const version = node.version?.trim() || "unknown";
const platform = node.platform?.trim() || undefined;
const deviceFamily = node.deviceFamily?.trim() || undefined;
const modelIdentifier = node.modelIdentifier?.trim() || undefined;
@@ -148,7 +127,6 @@ export async function startGatewayNodeBridge(params: {
platform: node.platform,
deviceFamily: node.deviceFamily,
commands: node.commands,
remoteIp: node.remoteIp,
});
bumpSkillsSnapshotVersion({ reason: "remote-node" });
await refreshRemoteNodeBins({
+1 -3
View File
@@ -23,10 +23,8 @@ vi.mock("../logger.js", () => {
};
});
vi.mock("../logging.js", async () => {
const actual = await vi.importActual<typeof import("../logging.js")>("../logging.js");
vi.mock("../logging.js", () => {
return {
...actual,
getLogger: () => ({ info: (...args: unknown[]) => getLoggerInfo(...args) }),
};
});
-8
View File
@@ -168,8 +168,6 @@ export function createNodeBridgeConnectionHandler(params: {
displayName: verified.node.displayName ?? hello.displayName,
platform: verified.node.platform ?? hello.platform,
version: verified.node.version ?? hello.version,
coreVersion: verified.node.coreVersion ?? hello.coreVersion,
uiVersion: verified.node.uiVersion ?? hello.uiVersion,
deviceFamily: verified.node.deviceFamily ?? hello.deviceFamily,
modelIdentifier: verified.node.modelIdentifier ?? hello.modelIdentifier,
caps,
@@ -183,8 +181,6 @@ export function createNodeBridgeConnectionHandler(params: {
displayName: nodeInfo.displayName,
platform: nodeInfo.platform,
version: nodeInfo.version,
coreVersion: nodeInfo.coreVersion,
uiVersion: nodeInfo.uiVersion,
deviceFamily: nodeInfo.deviceFamily,
modelIdentifier: nodeInfo.modelIdentifier,
remoteIp: nodeInfo.remoteIp,
@@ -247,8 +243,6 @@ export function createNodeBridgeConnectionHandler(params: {
displayName: req.displayName,
platform: req.platform,
version: req.version,
coreVersion: req.coreVersion,
uiVersion: req.uiVersion,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
caps: Array.isArray(req.caps)
@@ -292,8 +286,6 @@ export function createNodeBridgeConnectionHandler(params: {
displayName: req.displayName,
platform: req.platform,
version: req.version,
coreVersion: req.coreVersion,
uiVersion: req.uiVersion,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
caps: Array.isArray(req.caps) ? req.caps.map((c) => String(c)).filter(Boolean) : undefined,
-6
View File
@@ -9,8 +9,6 @@ export type BridgeHelloFrame = {
token?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
@@ -24,8 +22,6 @@ export type BridgePairRequestFrame = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
@@ -117,8 +113,6 @@ export type NodeBridgeClientInfo = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string;
-109
View File
@@ -1,109 +0,0 @@
import crypto from "node:crypto";
import net from "node:net";
export type ExecHostRequest = {
command: string[];
rawCommand?: string | null;
cwd?: string | null;
env?: Record<string, string> | null;
timeoutMs?: number | null;
needsScreenRecording?: boolean | null;
agentId?: string | null;
sessionKey?: string | null;
};
export type ExecHostRunResult = {
exitCode?: number;
timedOut: boolean;
success: boolean;
stdout: string;
stderr: string;
error?: string | null;
};
export type ExecHostError = {
code: string;
message: string;
reason?: string;
};
export type ExecHostResponse =
| { ok: true; payload: ExecHostRunResult }
| { ok: false; error: ExecHostError };
export async function requestExecHostViaSocket(params: {
socketPath: string;
token: string;
request: ExecHostRequest;
timeoutMs?: number;
}): Promise<ExecHostResponse | null> {
const { socketPath, token, request } = params;
if (!socketPath || !token) return null;
const timeoutMs = params.timeoutMs ?? 20_000;
return await new Promise((resolve) => {
const client = new net.Socket();
let settled = false;
let buffer = "";
const finish = (value: ExecHostResponse | null) => {
if (settled) return;
settled = true;
try {
client.destroy();
} catch {
// ignore
}
resolve(value);
};
const requestJson = JSON.stringify(request);
const nonce = crypto.randomBytes(16).toString("hex");
const ts = Date.now();
const hmac = crypto
.createHmac("sha256", token)
.update(`${nonce}:${ts}:${requestJson}`)
.digest("hex");
const payload = JSON.stringify({
type: "exec",
id: crypto.randomUUID(),
nonce,
ts,
hmac,
requestJson,
});
const timer = setTimeout(() => finish(null), timeoutMs);
client.on("error", () => finish(null));
client.connect(socketPath, () => {
client.write(`${payload}\n`);
});
client.on("data", (data) => {
buffer += data.toString("utf8");
let idx = buffer.indexOf("\n");
while (idx !== -1) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
idx = buffer.indexOf("\n");
if (!line) continue;
try {
const msg = JSON.parse(line) as { type?: string; ok?: boolean; payload?: unknown; error?: unknown };
if (msg?.type === "exec-res") {
clearTimeout(timer);
if (msg.ok === true && msg.payload) {
finish({ ok: true, payload: msg.payload as ExecHostRunResult });
return;
}
if (msg.ok === false && msg.error) {
finish({ ok: false, error: msg.error as ExecHostError });
return;
}
finish(null);
return;
}
} catch {
// ignore
}
}
});
});
}
-10
View File
@@ -9,8 +9,6 @@ export type NodePairingPendingRequest = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
@@ -28,8 +26,6 @@ export type NodePairingPairedNode = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
@@ -190,8 +186,6 @@ export async function requestNodePairing(
displayName: req.displayName,
platform: req.platform,
version: req.version,
coreVersion: req.coreVersion,
uiVersion: req.uiVersion,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
caps: req.caps,
@@ -225,8 +219,6 @@ export async function approveNodePairing(
displayName: pending.displayName,
platform: pending.platform,
version: pending.version,
coreVersion: pending.coreVersion,
uiVersion: pending.uiVersion,
deviceFamily: pending.deviceFamily,
modelIdentifier: pending.modelIdentifier,
caps: pending.caps,
@@ -286,8 +278,6 @@ export async function updatePairedNodeMetadata(
displayName: patch.displayName ?? existing.displayName,
platform: patch.platform ?? existing.platform,
version: patch.version ?? existing.version,
coreVersion: patch.coreVersion ?? existing.coreVersion,
uiVersion: patch.uiVersion ?? existing.uiVersion,
deviceFamily: patch.deviceFamily ?? existing.deviceFamily,
modelIdentifier: patch.modelIdentifier ?? existing.modelIdentifier,
remoteIp: patch.remoteIp ?? existing.remoteIp,
+2 -60
View File
@@ -14,66 +14,12 @@ type RemoteNodeRecord = {
deviceFamily?: string;
commands?: string[];
bins: Set<string>;
remoteIp?: string;
};
const log = createSubsystemLogger("gateway/skills-remote");
const remoteNodes = new Map<string, RemoteNodeRecord>();
let remoteBridge: NodeBridgeServer | null = null;
function describeNode(nodeId: string): string {
const record = remoteNodes.get(nodeId);
const name = record?.displayName?.trim();
const base = name && name !== nodeId ? `${name} (${nodeId})` : nodeId;
const ip = record?.remoteIp?.trim();
return ip ? `${base} @ ${ip}` : base;
}
function extractErrorMessage(err: unknown): string | undefined {
if (!err) return undefined;
if (typeof err === "string") return err;
if (err instanceof Error) return err.message;
if (typeof err === "object" && "message" in err && typeof err.message === "string") {
return err.message;
}
if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") {
return String(err);
}
if (typeof err === "symbol") {
return err.toString();
}
if (typeof err === "object") {
try {
return JSON.stringify(err);
} catch {
return undefined;
}
}
return undefined;
}
function logRemoteBinProbeFailure(nodeId: string, err: unknown) {
const message = extractErrorMessage(err);
const label = describeNode(nodeId);
if (message?.includes("UNAVAILABLE: node not connected")) {
log.info(
`remote bin probe skipped: node not connected (${label}); check nodes list/status for ${label}`,
);
return;
}
if (message?.includes("UNAVAILABLE: invoke timeout")) {
log.warn(`remote bin probe timed out (${label}); check node connectivity for ${label}`);
return;
}
if (message?.includes("bridge connection closed")) {
log.warn(
`remote bin probe aborted: bridge connection closed (${label}); check nodes list/status for ${label}`,
);
return;
}
log.warn(`remote bin probe error (${label}): ${message ?? "unknown"}`);
}
function isMacPlatform(platform?: string, deviceFamily?: string): boolean {
const platformNorm = String(platform ?? "")
.trim()
@@ -101,7 +47,6 @@ function upsertNode(record: {
platform?: string;
deviceFamily?: string;
commands?: string[];
remoteIp?: string;
bins?: string[];
}) {
const existing = remoteNodes.get(record.nodeId);
@@ -112,7 +57,6 @@ function upsertNode(record: {
platform: record.platform ?? existing?.platform,
deviceFamily: record.deviceFamily ?? existing?.deviceFamily,
commands: record.commands ?? existing?.commands,
remoteIp: record.remoteIp ?? existing?.remoteIp,
bins,
});
}
@@ -132,7 +76,6 @@ export async function primeRemoteSkillsCache() {
platform: node.platform,
deviceFamily: node.deviceFamily,
commands: node.commands,
remoteIp: node.remoteIp,
bins: node.bins,
});
if (isMacPlatform(node.platform, node.deviceFamily) && supportsSystemRun(node.commands)) {
@@ -153,7 +96,6 @@ export function recordRemoteNodeInfo(node: {
platform?: string;
deviceFamily?: string;
commands?: string[];
remoteIp?: string;
}) {
upsertNode(node);
}
@@ -261,7 +203,7 @@ export async function refreshRemoteNodeBins(params: {
},
);
if (!res.ok) {
logRemoteBinProbeFailure(params.nodeId, res.error?.message ?? "unknown");
log.warn(`remote bin probe failed (${params.nodeId}): ${res.error?.message ?? "unknown"}`);
return;
}
const bins = parseBinProbePayload(res.payloadJSON);
@@ -269,7 +211,7 @@ export async function refreshRemoteNodeBins(params: {
await updatePairedNodeMetadata(params.nodeId, { bins });
bumpSkillsSnapshotVersion({ reason: "remote-node" });
} catch (err) {
logRemoteBinProbeFailure(params.nodeId, err);
log.warn(`remote bin probe error (${params.nodeId}): ${String(err)}`);
}
}
-407
View File
@@ -1,407 +0,0 @@
import { createSubsystemLogger } from "../logging.js";
import type { GeminiEmbeddingClient } from "./embeddings-gemini.js";
import { hashText } from "./internal.js";
export type GeminiBatchRequest = {
custom_id: string;
content: { parts: Array<{ text: string }> };
taskType: "RETRIEVAL_DOCUMENT" | "RETRIEVAL_QUERY";
};
export type GeminiBatchStatus = {
name?: string;
state?: string;
outputConfig?: { file?: string; fileId?: string };
metadata?: {
output?: {
responsesFile?: string;
};
};
error?: { message?: string };
};
export type GeminiBatchOutputLine = {
key?: string;
custom_id?: string;
request_id?: string;
embedding?: { values?: number[] };
response?: {
embedding?: { values?: number[] };
error?: { message?: string };
};
error?: { message?: string };
};
const GEMINI_BATCH_MAX_REQUESTS = 50000;
const debugEmbeddings = process.env.CLAWDBOT_DEBUG_MEMORY_EMBEDDINGS === "1";
const log = createSubsystemLogger("memory/embeddings");
const debugLog = (message: string, meta?: Record<string, unknown>) => {
if (!debugEmbeddings) return;
const suffix = meta ? ` ${JSON.stringify(meta)}` : "";
log.raw(`${message}${suffix}`);
};
function getGeminiBaseUrl(gemini: GeminiEmbeddingClient): string {
return gemini.baseUrl?.replace(/\/$/, "") ?? "";
}
function getGeminiHeaders(
gemini: GeminiEmbeddingClient,
params: { json: boolean },
): Record<string, string> {
const headers = gemini.headers ? { ...gemini.headers } : {};
if (params.json) {
if (!headers["Content-Type"] && !headers["content-type"]) {
headers["Content-Type"] = "application/json";
}
} else {
delete headers["Content-Type"];
delete headers["content-type"];
}
return headers;
}
function getGeminiUploadUrl(baseUrl: string): string {
if (baseUrl.includes("/v1beta")) {
return baseUrl.replace(/\/v1beta\/?$/, "/upload/v1beta");
}
return `${baseUrl.replace(/\/$/, "")}/upload`;
}
function splitGeminiBatchRequests(requests: GeminiBatchRequest[]): GeminiBatchRequest[][] {
if (requests.length <= GEMINI_BATCH_MAX_REQUESTS) return [requests];
const groups: GeminiBatchRequest[][] = [];
for (let i = 0; i < requests.length; i += GEMINI_BATCH_MAX_REQUESTS) {
groups.push(requests.slice(i, i + GEMINI_BATCH_MAX_REQUESTS));
}
return groups;
}
function buildGeminiUploadBody(params: { jsonl: string; displayName: string }): {
body: Blob;
contentType: string;
} {
const boundary = `clawdbot-${hashText(params.displayName)}`;
const jsonPart = JSON.stringify({
file: {
displayName: params.displayName,
mimeType: "application/jsonl",
},
});
const delimiter = `--${boundary}\r\n`;
const closeDelimiter = `--${boundary}--\r\n`;
const parts = [
`${delimiter}Content-Type: application/json; charset=UTF-8\r\n\r\n${jsonPart}\r\n`,
`${delimiter}Content-Type: application/jsonl; charset=UTF-8\r\n\r\n${params.jsonl}\r\n`,
closeDelimiter,
];
const body = new Blob([parts.join("")], { type: "multipart/related" });
return {
body,
contentType: `multipart/related; boundary=${boundary}`,
};
}
async function submitGeminiBatch(params: {
gemini: GeminiEmbeddingClient;
requests: GeminiBatchRequest[];
agentId: string;
}): Promise<GeminiBatchStatus> {
const baseUrl = getGeminiBaseUrl(params.gemini);
const jsonl = params.requests
.map((request) =>
JSON.stringify({
key: request.custom_id,
request: {
content: request.content,
task_type: request.taskType,
},
}),
)
.join("\n");
const displayName = `memory-embeddings-${hashText(String(Date.now()))}`;
const uploadPayload = buildGeminiUploadBody({ jsonl, displayName });
const uploadUrl = `${getGeminiUploadUrl(baseUrl)}/files?uploadType=multipart`;
debugLog("memory embeddings: gemini batch upload", {
uploadUrl,
baseUrl,
requests: params.requests.length,
});
const fileRes = await fetch(uploadUrl, {
method: "POST",
headers: {
...getGeminiHeaders(params.gemini, { json: false }),
"Content-Type": uploadPayload.contentType,
},
body: uploadPayload.body,
});
if (!fileRes.ok) {
const text = await fileRes.text();
throw new Error(`gemini batch file upload failed: ${fileRes.status} ${text}`);
}
const filePayload = (await fileRes.json()) as { name?: string; file?: { name?: string } };
const fileId = filePayload.name ?? filePayload.file?.name;
if (!fileId) {
throw new Error("gemini batch file upload failed: missing file id");
}
const batchBody = {
batch: {
displayName: `memory-embeddings-${params.agentId}`,
inputConfig: {
file_name: fileId,
},
},
};
const batchEndpoint = `${baseUrl}/${params.gemini.modelPath}:asyncBatchEmbedContent`;
debugLog("memory embeddings: gemini batch create", {
batchEndpoint,
fileId,
});
const batchRes = await fetch(batchEndpoint, {
method: "POST",
headers: getGeminiHeaders(params.gemini, { json: true }),
body: JSON.stringify(batchBody),
});
if (batchRes.ok) {
return (await batchRes.json()) as GeminiBatchStatus;
}
const text = await batchRes.text();
if (batchRes.status === 404) {
throw new Error(
"gemini batch create failed: 404 (asyncBatchEmbedContent not available for this model/baseUrl). Disable remote.batch.enabled or switch providers.",
);
}
throw new Error(`gemini batch create failed: ${batchRes.status} ${text}`);
}
async function fetchGeminiBatchStatus(params: {
gemini: GeminiEmbeddingClient;
batchName: string;
}): Promise<GeminiBatchStatus> {
const baseUrl = getGeminiBaseUrl(params.gemini);
const name = params.batchName.startsWith("batches/") ? params.batchName : `batches/${params.batchName}`;
const statusUrl = `${baseUrl}/${name}`;
debugLog("memory embeddings: gemini batch status", { statusUrl });
const res = await fetch(statusUrl, {
headers: getGeminiHeaders(params.gemini, { json: true }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`gemini batch status failed: ${res.status} ${text}`);
}
return (await res.json()) as GeminiBatchStatus;
}
async function fetchGeminiFileContent(params: {
gemini: GeminiEmbeddingClient;
fileId: string;
}): Promise<string> {
const baseUrl = getGeminiBaseUrl(params.gemini);
const file = params.fileId.startsWith("files/") ? params.fileId : `files/${params.fileId}`;
const downloadUrl = `${baseUrl}/${file}:download`;
debugLog("memory embeddings: gemini batch download", { downloadUrl });
const res = await fetch(downloadUrl, {
headers: getGeminiHeaders(params.gemini, { json: true }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`gemini batch file content failed: ${res.status} ${text}`);
}
return await res.text();
}
function parseGeminiBatchOutput(text: string): GeminiBatchOutputLine[] {
if (!text.trim()) return [];
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as GeminiBatchOutputLine);
}
async function waitForGeminiBatch(params: {
gemini: GeminiEmbeddingClient;
batchName: string;
wait: boolean;
pollIntervalMs: number;
timeoutMs: number;
debug?: (message: string, data?: Record<string, unknown>) => void;
initial?: GeminiBatchStatus;
}): Promise<{ outputFileId: string }> {
const start = Date.now();
let current: GeminiBatchStatus | undefined = params.initial;
while (true) {
const status =
current ??
(await fetchGeminiBatchStatus({
gemini: params.gemini,
batchName: params.batchName,
}));
const state = status.state ?? "UNKNOWN";
if (["SUCCEEDED", "COMPLETED", "DONE"].includes(state)) {
const outputFileId =
status.outputConfig?.file ??
status.outputConfig?.fileId ??
status.metadata?.output?.responsesFile;
if (!outputFileId) {
throw new Error(`gemini batch ${params.batchName} completed without output file`);
}
return { outputFileId };
}
if (["FAILED", "CANCELLED", "CANCELED", "EXPIRED"].includes(state)) {
const message = status.error?.message ?? "unknown error";
throw new Error(`gemini batch ${params.batchName} ${state}: ${message}`);
}
if (!params.wait) {
throw new Error(`gemini batch ${params.batchName} still ${state}; wait disabled`);
}
if (Date.now() - start > params.timeoutMs) {
throw new Error(`gemini batch ${params.batchName} timed out after ${params.timeoutMs}ms`);
}
params.debug?.(`gemini batch ${params.batchName} ${state}; waiting ${params.pollIntervalMs}ms`);
await new Promise((resolve) => setTimeout(resolve, params.pollIntervalMs));
current = undefined;
}
}
async function runWithConcurrency<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<T[]> {
if (tasks.length === 0) return [];
const resolvedLimit = Math.max(1, Math.min(limit, tasks.length));
const results: T[] = Array.from({ length: tasks.length });
let next = 0;
let firstError: unknown = null;
const workers = Array.from({ length: resolvedLimit }, async () => {
while (true) {
if (firstError) return;
const index = next;
next += 1;
if (index >= tasks.length) return;
try {
results[index] = await tasks[index]();
} catch (err) {
firstError = err;
return;
}
}
});
await Promise.allSettled(workers);
if (firstError) throw firstError;
return results;
}
export async function runGeminiEmbeddingBatches(params: {
gemini: GeminiEmbeddingClient;
agentId: string;
requests: GeminiBatchRequest[];
wait: boolean;
pollIntervalMs: number;
timeoutMs: number;
concurrency: number;
debug?: (message: string, data?: Record<string, unknown>) => void;
}): Promise<Map<string, number[]>> {
if (params.requests.length === 0) return new Map();
const groups = splitGeminiBatchRequests(params.requests);
const byCustomId = new Map<string, number[]>();
const tasks = groups.map((group, groupIndex) => async () => {
const batchInfo = await submitGeminiBatch({
gemini: params.gemini,
requests: group,
agentId: params.agentId,
});
const batchName = batchInfo.name ?? "";
if (!batchName) {
throw new Error("gemini batch create failed: missing batch name");
}
params.debug?.("memory embeddings: gemini batch created", {
batchName,
state: batchInfo.state,
group: groupIndex + 1,
groups: groups.length,
requests: group.length,
});
if (!params.wait && batchInfo.state && !["SUCCEEDED", "COMPLETED", "DONE"].includes(batchInfo.state)) {
throw new Error(
`gemini batch ${batchName} submitted; enable remote.batch.wait to await completion`,
);
}
const completed =
batchInfo.state && ["SUCCEEDED", "COMPLETED", "DONE"].includes(batchInfo.state)
? {
outputFileId:
batchInfo.outputConfig?.file ??
batchInfo.outputConfig?.fileId ??
batchInfo.metadata?.output?.responsesFile ??
"",
}
: await waitForGeminiBatch({
gemini: params.gemini,
batchName,
wait: params.wait,
pollIntervalMs: params.pollIntervalMs,
timeoutMs: params.timeoutMs,
debug: params.debug,
initial: batchInfo,
});
if (!completed.outputFileId) {
throw new Error(`gemini batch ${batchName} completed without output file`);
}
const content = await fetchGeminiFileContent({
gemini: params.gemini,
fileId: completed.outputFileId,
});
const outputLines = parseGeminiBatchOutput(content);
const errors: string[] = [];
const remaining = new Set(group.map((request) => request.custom_id));
for (const line of outputLines) {
const customId = line.key ?? line.custom_id ?? line.request_id;
if (!customId) continue;
remaining.delete(customId);
if (line.error?.message) {
errors.push(`${customId}: ${line.error.message}`);
continue;
}
if (line.response?.error?.message) {
errors.push(`${customId}: ${line.response.error.message}`);
continue;
}
const embedding =
line.embedding?.values ?? line.response?.embedding?.values ?? [];
if (embedding.length === 0) {
errors.push(`${customId}: empty embedding`);
continue;
}
byCustomId.set(customId, embedding);
}
if (errors.length > 0) {
throw new Error(`gemini batch ${batchName} failed: ${errors.join("; ")}`);
}
if (remaining.size > 0) {
throw new Error(`gemini batch ${batchName} missing ${remaining.size} embedding responses`);
}
});
params.debug?.("memory embeddings: gemini batch submit", {
requests: params.requests.length,
groups: groups.length,
wait: params.wait,
concurrency: params.concurrency,
pollIntervalMs: params.pollIntervalMs,
timeoutMs: params.timeoutMs,
});
await runWithConcurrency(tasks, params.concurrency);
return byCustomId;
}
-382
View File
@@ -1,382 +0,0 @@
import { retryAsync } from "../infra/retry.js";
import type { OpenAiEmbeddingClient } from "./embeddings-openai.js";
import { hashText } from "./internal.js";
export type OpenAiBatchRequest = {
custom_id: string;
method: "POST";
url: "/v1/embeddings";
body: {
model: string;
input: string;
};
};
export type OpenAiBatchStatus = {
id?: string;
status?: string;
output_file_id?: string | null;
error_file_id?: string | null;
};
export type OpenAiBatchOutputLine = {
custom_id?: string;
response?: {
status_code?: number;
body?: {
data?: Array<{ embedding?: number[]; index?: number }>;
error?: { message?: string };
};
};
error?: { message?: string };
};
export const OPENAI_BATCH_ENDPOINT = "/v1/embeddings";
const OPENAI_BATCH_COMPLETION_WINDOW = "24h";
const OPENAI_BATCH_MAX_REQUESTS = 50000;
function getOpenAiBaseUrl(openAi: OpenAiEmbeddingClient): string {
return openAi.baseUrl?.replace(/\/$/, "") ?? "";
}
function getOpenAiHeaders(
openAi: OpenAiEmbeddingClient,
params: { json: boolean },
): Record<string, string> {
const headers = openAi.headers ? { ...openAi.headers } : {};
if (params.json) {
if (!headers["Content-Type"] && !headers["content-type"]) {
headers["Content-Type"] = "application/json";
}
} else {
delete headers["Content-Type"];
delete headers["content-type"];
}
return headers;
}
function splitOpenAiBatchRequests(requests: OpenAiBatchRequest[]): OpenAiBatchRequest[][] {
if (requests.length <= OPENAI_BATCH_MAX_REQUESTS) return [requests];
const groups: OpenAiBatchRequest[][] = [];
for (let i = 0; i < requests.length; i += OPENAI_BATCH_MAX_REQUESTS) {
groups.push(requests.slice(i, i + OPENAI_BATCH_MAX_REQUESTS));
}
return groups;
}
async function submitOpenAiBatch(params: {
openAi: OpenAiEmbeddingClient;
requests: OpenAiBatchRequest[];
agentId: string;
}): Promise<OpenAiBatchStatus> {
const baseUrl = getOpenAiBaseUrl(params.openAi);
const jsonl = params.requests.map((request) => JSON.stringify(request)).join("\n");
const form = new FormData();
form.append("purpose", "batch");
form.append(
"file",
new Blob([jsonl], { type: "application/jsonl" }),
`memory-embeddings.${hashText(String(Date.now()))}.jsonl`,
);
const fileRes = await fetch(`${baseUrl}/files`, {
method: "POST",
headers: getOpenAiHeaders(params.openAi, { json: false }),
body: form,
});
if (!fileRes.ok) {
const text = await fileRes.text();
throw new Error(`openai batch file upload failed: ${fileRes.status} ${text}`);
}
const filePayload = (await fileRes.json()) as { id?: string };
if (!filePayload.id) {
throw new Error("openai batch file upload failed: missing file id");
}
const batchRes = await retryAsync(
async () => {
const res = await fetch(`${baseUrl}/batches`, {
method: "POST",
headers: getOpenAiHeaders(params.openAi, { json: true }),
body: JSON.stringify({
input_file_id: filePayload.id,
endpoint: OPENAI_BATCH_ENDPOINT,
completion_window: OPENAI_BATCH_COMPLETION_WINDOW,
metadata: {
source: "clawdbot-memory",
agent: params.agentId,
},
}),
});
if (!res.ok) {
const text = await res.text();
const err = new Error(`openai batch create failed: ${res.status} ${text}`) as Error & {
status?: number;
};
err.status = res.status;
throw err;
}
return res;
},
{
attempts: 3,
minDelayMs: 300,
maxDelayMs: 2000,
jitter: 0.2,
shouldRetry: (err) => {
const status = (err as { status?: number }).status;
return status === 429 || (typeof status === "number" && status >= 500);
},
},
);
return (await batchRes.json()) as OpenAiBatchStatus;
}
async function fetchOpenAiBatchStatus(params: {
openAi: OpenAiEmbeddingClient;
batchId: string;
}): Promise<OpenAiBatchStatus> {
const baseUrl = getOpenAiBaseUrl(params.openAi);
const res = await fetch(`${baseUrl}/batches/${params.batchId}`, {
headers: getOpenAiHeaders(params.openAi, { json: true }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`openai batch status failed: ${res.status} ${text}`);
}
return (await res.json()) as OpenAiBatchStatus;
}
async function fetchOpenAiFileContent(params: {
openAi: OpenAiEmbeddingClient;
fileId: string;
}): Promise<string> {
const baseUrl = getOpenAiBaseUrl(params.openAi);
const res = await fetch(`${baseUrl}/files/${params.fileId}/content`, {
headers: getOpenAiHeaders(params.openAi, { json: true }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`openai batch file content failed: ${res.status} ${text}`);
}
return await res.text();
}
function parseOpenAiBatchOutput(text: string): OpenAiBatchOutputLine[] {
if (!text.trim()) return [];
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as OpenAiBatchOutputLine);
}
async function readOpenAiBatchError(params: {
openAi: OpenAiEmbeddingClient;
errorFileId: string;
}): Promise<string | undefined> {
try {
const content = await fetchOpenAiFileContent({
openAi: params.openAi,
fileId: params.errorFileId,
});
const lines = parseOpenAiBatchOutput(content);
const first = lines.find((line) => line.error?.message || line.response?.body?.error);
const message =
first?.error?.message ??
(typeof first?.response?.body?.error?.message === "string"
? first?.response?.body?.error?.message
: undefined);
return message;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return message ? `error file unavailable: ${message}` : undefined;
}
}
async function waitForOpenAiBatch(params: {
openAi: OpenAiEmbeddingClient;
batchId: string;
wait: boolean;
pollIntervalMs: number;
timeoutMs: number;
debug?: (message: string, data?: Record<string, unknown>) => void;
initial?: OpenAiBatchStatus;
}): Promise<{ outputFileId: string; errorFileId?: string }> {
const start = Date.now();
let current: OpenAiBatchStatus | undefined = params.initial;
while (true) {
const status =
current ??
(await fetchOpenAiBatchStatus({
openAi: params.openAi,
batchId: params.batchId,
}));
const state = status.status ?? "unknown";
if (state === "completed") {
if (!status.output_file_id) {
throw new Error(`openai batch ${params.batchId} completed without output file`);
}
return {
outputFileId: status.output_file_id,
errorFileId: status.error_file_id ?? undefined,
};
}
if (["failed", "expired", "cancelled", "canceled"].includes(state)) {
const detail = status.error_file_id
? await readOpenAiBatchError({ openAi: params.openAi, errorFileId: status.error_file_id })
: undefined;
const suffix = detail ? `: ${detail}` : "";
throw new Error(`openai batch ${params.batchId} ${state}${suffix}`);
}
if (!params.wait) {
throw new Error(`openai batch ${params.batchId} still ${state}; wait disabled`);
}
if (Date.now() - start > params.timeoutMs) {
throw new Error(`openai batch ${params.batchId} timed out after ${params.timeoutMs}ms`);
}
params.debug?.(`openai batch ${params.batchId} ${state}; waiting ${params.pollIntervalMs}ms`);
await new Promise((resolve) => setTimeout(resolve, params.pollIntervalMs));
current = undefined;
}
}
async function runWithConcurrency<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<T[]> {
if (tasks.length === 0) return [];
const resolvedLimit = Math.max(1, Math.min(limit, tasks.length));
const results: T[] = Array.from({ length: tasks.length });
let next = 0;
let firstError: unknown = null;
const workers = Array.from({ length: resolvedLimit }, async () => {
while (true) {
if (firstError) return;
const index = next;
next += 1;
if (index >= tasks.length) return;
try {
results[index] = await tasks[index]();
} catch (err) {
firstError = err;
return;
}
}
});
await Promise.allSettled(workers);
if (firstError) throw firstError;
return results;
}
export async function runOpenAiEmbeddingBatches(params: {
openAi: OpenAiEmbeddingClient;
agentId: string;
requests: OpenAiBatchRequest[];
wait: boolean;
pollIntervalMs: number;
timeoutMs: number;
concurrency: number;
debug?: (message: string, data?: Record<string, unknown>) => void;
}): Promise<Map<string, number[]>> {
if (params.requests.length === 0) return new Map();
const groups = splitOpenAiBatchRequests(params.requests);
const byCustomId = new Map<string, number[]>();
const tasks = groups.map((group, groupIndex) => async () => {
const batchInfo = await submitOpenAiBatch({
openAi: params.openAi,
requests: group,
agentId: params.agentId,
});
if (!batchInfo.id) {
throw new Error("openai batch create failed: missing batch id");
}
params.debug?.("memory embeddings: openai batch created", {
batchId: batchInfo.id,
status: batchInfo.status,
group: groupIndex + 1,
groups: groups.length,
requests: group.length,
});
if (!params.wait && batchInfo.status !== "completed") {
throw new Error(
`openai batch ${batchInfo.id} submitted; enable remote.batch.wait to await completion`,
);
}
const completed =
batchInfo.status === "completed"
? {
outputFileId: batchInfo.output_file_id ?? "",
errorFileId: batchInfo.error_file_id ?? undefined,
}
: await waitForOpenAiBatch({
openAi: params.openAi,
batchId: batchInfo.id,
wait: params.wait,
pollIntervalMs: params.pollIntervalMs,
timeoutMs: params.timeoutMs,
debug: params.debug,
initial: batchInfo,
});
if (!completed.outputFileId) {
throw new Error(`openai batch ${batchInfo.id} completed without output file`);
}
const content = await fetchOpenAiFileContent({
openAi: params.openAi,
fileId: completed.outputFileId,
});
const outputLines = parseOpenAiBatchOutput(content);
const errors: string[] = [];
const remaining = new Set(group.map((request) => request.custom_id));
for (const line of outputLines) {
const customId = line.custom_id;
if (!customId) continue;
remaining.delete(customId);
if (line.error?.message) {
errors.push(`${customId}: ${line.error.message}`);
continue;
}
const response = line.response;
const statusCode = response?.status_code ?? 0;
if (statusCode >= 400) {
const message =
response?.body?.error?.message ??
(typeof response?.body === "string" ? response.body : undefined) ??
"unknown error";
errors.push(`${customId}: ${message}`);
continue;
}
const data = response?.body?.data ?? [];
const embedding = data[0]?.embedding ?? [];
if (embedding.length === 0) {
errors.push(`${customId}: empty embedding`);
continue;
}
byCustomId.set(customId, embedding);
}
if (errors.length > 0) {
throw new Error(`openai batch ${batchInfo.id} failed: ${errors.join("; ")}`);
}
if (remaining.size > 0) {
throw new Error(`openai batch ${batchInfo.id} missing ${remaining.size} embedding responses`);
}
});
params.debug?.("memory embeddings: openai batch submit", {
requests: params.requests.length,
groups: groups.length,
wait: params.wait,
concurrency: params.concurrency,
pollIntervalMs: params.pollIntervalMs,
timeoutMs: params.timeoutMs,
});
await runWithConcurrency(tasks, params.concurrency);
return byCustomId;
}
-145
View File
@@ -1,145 +0,0 @@
import { resolveApiKeyForProvider } from "../agents/model-auth.js";
import { createSubsystemLogger } from "../logging.js";
import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js";
export type GeminiEmbeddingClient = {
baseUrl: string;
headers: Record<string, string>;
model: string;
modelPath: string;
};
const DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
export const DEFAULT_GEMINI_EMBEDDING_MODEL = "gemini-embedding-001";
const debugEmbeddings = process.env.CLAWDBOT_DEBUG_MEMORY_EMBEDDINGS === "1";
const log = createSubsystemLogger("memory/embeddings");
const debugLog = (message: string, meta?: Record<string, unknown>) => {
if (!debugEmbeddings) return;
const suffix = meta ? ` ${JSON.stringify(meta)}` : "";
log.raw(`${message}${suffix}`);
};
function resolveRemoteApiKey(remoteApiKey?: string): string | undefined {
const trimmed = remoteApiKey?.trim();
if (!trimmed) return undefined;
if (trimmed === "GOOGLE_API_KEY" || trimmed === "GEMINI_API_KEY") {
return process.env[trimmed]?.trim();
}
return trimmed;
}
function normalizeGeminiModel(model: string): string {
const trimmed = model.trim();
if (!trimmed) return DEFAULT_GEMINI_EMBEDDING_MODEL;
const withoutPrefix = trimmed.replace(/^models\//, "");
if (withoutPrefix.startsWith("gemini/")) return withoutPrefix.slice("gemini/".length);
if (withoutPrefix.startsWith("google/")) return withoutPrefix.slice("google/".length);
return withoutPrefix;
}
function normalizeGeminiBaseUrl(raw: string): string {
const trimmed = raw.replace(/\/+$/, "");
const openAiIndex = trimmed.indexOf("/openai");
if (openAiIndex > -1) return trimmed.slice(0, openAiIndex);
return trimmed;
}
function buildGeminiModelPath(model: string): string {
return model.startsWith("models/") ? model : `models/${model}`;
}
export async function createGeminiEmbeddingProvider(
options: EmbeddingProviderOptions,
): Promise<{ provider: EmbeddingProvider; client: GeminiEmbeddingClient }> {
const client = await resolveGeminiEmbeddingClient(options);
const baseUrl = client.baseUrl.replace(/\/$/, "");
const embedUrl = `${baseUrl}/${client.modelPath}:embedContent`;
const batchUrl = `${baseUrl}/${client.modelPath}:batchEmbedContents`;
const embedQuery = async (text: string): Promise<number[]> => {
if (!text.trim()) return [];
const res = await fetch(embedUrl, {
method: "POST",
headers: client.headers,
body: JSON.stringify({
content: { parts: [{ text }] },
taskType: "RETRIEVAL_QUERY",
}),
});
if (!res.ok) {
const payload = await res.text();
throw new Error(`gemini embeddings failed: ${res.status} ${payload}`);
}
const payload = (await res.json()) as { embedding?: { values?: number[] } };
return payload.embedding?.values ?? [];
};
const embedBatch = async (texts: string[]): Promise<number[][]> => {
if (texts.length === 0) return [];
const requests = texts.map((text) => ({
model: client.modelPath,
content: { parts: [{ text }] },
taskType: "RETRIEVAL_DOCUMENT",
}));
const res = await fetch(batchUrl, {
method: "POST",
headers: client.headers,
body: JSON.stringify({ requests }),
});
if (!res.ok) {
const payload = await res.text();
throw new Error(`gemini embeddings failed: ${res.status} ${payload}`);
}
const payload = (await res.json()) as { embeddings?: Array<{ values?: number[] }> };
const embeddings = Array.isArray(payload.embeddings) ? payload.embeddings : [];
return texts.map((_, index) => embeddings[index]?.values ?? []);
};
return {
provider: {
id: "gemini",
model: client.model,
embedQuery,
embedBatch,
},
client,
};
}
export async function resolveGeminiEmbeddingClient(
options: EmbeddingProviderOptions,
): Promise<GeminiEmbeddingClient> {
const remote = options.remote;
const remoteApiKey = resolveRemoteApiKey(remote?.apiKey);
const remoteBaseUrl = remote?.baseUrl?.trim();
const { apiKey } = remoteApiKey
? { apiKey: remoteApiKey }
: await resolveApiKeyForProvider({
provider: "google",
cfg: options.config,
agentDir: options.agentDir,
});
const providerConfig = options.config.models?.providers?.google;
const rawBaseUrl = remoteBaseUrl || providerConfig?.baseUrl?.trim() || DEFAULT_GEMINI_BASE_URL;
const baseUrl = normalizeGeminiBaseUrl(rawBaseUrl);
const headerOverrides = Object.assign({}, providerConfig?.headers, remote?.headers);
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-goog-api-key": apiKey,
...headerOverrides,
};
const model = normalizeGeminiModel(options.model);
const modelPath = buildGeminiModelPath(model);
debugLog("memory embeddings: gemini client", {
rawBaseUrl,
baseUrl,
model,
modelPath,
embedEndpoint: `${baseUrl}/${modelPath}:embedContent`,
batchEndpoint: `${baseUrl}/${modelPath}:batchEmbedContents`,
});
return { baseUrl, headers, model, modelPath };
}
-83
View File
@@ -1,83 +0,0 @@
import { resolveApiKeyForProvider } from "../agents/model-auth.js";
import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js";
export type OpenAiEmbeddingClient = {
baseUrl: string;
headers: Record<string, string>;
model: string;
};
export const DEFAULT_OPENAI_EMBEDDING_MODEL = "text-embedding-3-small";
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
export function normalizeOpenAiModel(model: string): string {
const trimmed = model.trim();
if (!trimmed) return DEFAULT_OPENAI_EMBEDDING_MODEL;
if (trimmed.startsWith("openai/")) return trimmed.slice("openai/".length);
return trimmed;
}
export async function createOpenAiEmbeddingProvider(
options: EmbeddingProviderOptions,
): Promise<{ provider: EmbeddingProvider; client: OpenAiEmbeddingClient }> {
const client = await resolveOpenAiEmbeddingClient(options);
const url = `${client.baseUrl.replace(/\/$/, "")}/embeddings`;
const embed = async (input: string[]): Promise<number[][]> => {
if (input.length === 0) return [];
const res = await fetch(url, {
method: "POST",
headers: client.headers,
body: JSON.stringify({ model: client.model, input }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`openai embeddings failed: ${res.status} ${text}`);
}
const payload = (await res.json()) as {
data?: Array<{ embedding?: number[] }>;
};
const data = payload.data ?? [];
return data.map((entry) => entry.embedding ?? []);
};
return {
provider: {
id: "openai",
model: client.model,
embedQuery: async (text) => {
const [vec] = await embed([text]);
return vec ?? [];
},
embedBatch: embed,
},
client,
};
}
export async function resolveOpenAiEmbeddingClient(
options: EmbeddingProviderOptions,
): Promise<OpenAiEmbeddingClient> {
const remote = options.remote;
const remoteApiKey = remote?.apiKey?.trim();
const remoteBaseUrl = remote?.baseUrl?.trim();
const { apiKey } = remoteApiKey
? { apiKey: remoteApiKey }
: await resolveApiKeyForProvider({
provider: "openai",
cfg: options.config,
agentDir: options.agentDir,
});
const providerConfig = options.config.models?.providers?.openai;
const baseUrl = remoteBaseUrl || providerConfig?.baseUrl?.trim() || DEFAULT_OPENAI_BASE_URL;
const headerOverrides = Object.assign({}, providerConfig?.headers, remote?.headers);
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
...headerOverrides,
};
const model = normalizeOpenAiModel(options.model);
return { baseUrl, headers, model };
}
+6 -71
View File
@@ -108,7 +108,7 @@ describe("embedding provider remote overrides", () => {
expect(headers.Authorization).toBe("Bearer provider-key");
});
it("builds Gemini embeddings requests with api key header", async () => {
it("uses gemini embedContent endpoint with x-goog-api-key", async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
@@ -119,94 +119,29 @@ describe("embedding provider remote overrides", () => {
const { createEmbeddingProvider } = await import("./embeddings.js");
const authModule = await import("../agents/model-auth.js");
vi.mocked(authModule.resolveApiKeyForProvider).mockResolvedValue({
apiKey: "provider-key",
apiKey: "gemini-key",
});
const cfg = {
models: {
providers: {
google: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
},
},
},
};
const result = await createEmbeddingProvider({
config: cfg as never,
config: {} as never,
provider: "gemini",
remote: {
apiKey: "gemini-key",
baseUrl: "https://gemini.example/v1beta",
},
model: "text-embedding-004",
model: "gemini-embedding-001",
fallback: "openai",
});
await result.provider.embedQuery("hello");
const [url, init] = fetchMock.mock.calls[0] ?? [];
expect(url).toBe(
"https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent",
);
expect(url).toBe("https://gemini.example/v1beta/models/gemini-embedding-001:embedContent");
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers["x-goog-api-key"]).toBe("gemini-key");
expect(headers["Content-Type"]).toBe("application/json");
});
});
describe("embedding provider auto selection", () => {
afterEach(() => {
vi.resetAllMocks();
vi.resetModules();
vi.unstubAllGlobals();
});
it("prefers openai when a key resolves", async () => {
const { createEmbeddingProvider } = await import("./embeddings.js");
const authModule = await import("../agents/model-auth.js");
vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => {
if (provider === "openai") {
return { apiKey: "openai-key", source: "env: OPENAI_API_KEY" };
}
throw new Error(`No API key found for provider "${provider}".`);
});
const result = await createEmbeddingProvider({
config: {} as never,
provider: "auto",
model: "",
fallback: "none",
});
expect(result.requestedProvider).toBe("auto");
expect(result.provider.id).toBe("openai");
});
it("uses gemini when openai is missing", async () => {
const { createEmbeddingProvider } = await import("./embeddings.js");
const authModule = await import("../agents/model-auth.js");
vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => {
if (provider === "openai") {
throw new Error('No API key found for provider "openai".');
}
if (provider === "google") {
return { apiKey: "gemini-key", source: "env: GEMINI_API_KEY" };
}
throw new Error(`Unexpected provider ${provider}`);
});
const result = await createEmbeddingProvider({
config: {} as never,
provider: "auto",
model: "",
fallback: "none",
});
expect(result.requestedProvider).toBe("auto");
expect(result.provider.id).toBe("gemini");
});
});
describe("embedding provider local fallback", () => {
afterEach(() => {
vi.resetAllMocks();
+222 -99
View File
@@ -1,21 +1,8 @@
import fsSync from "node:fs";
import type { Llama, LlamaEmbeddingContext, LlamaModel } from "node-llama-cpp";
import { resolveApiKeyForProvider } from "../agents/model-auth.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
import {
createGeminiEmbeddingProvider,
type GeminiEmbeddingClient,
} from "./embeddings-gemini.js";
import {
createOpenAiEmbeddingProvider,
type OpenAiEmbeddingClient,
} from "./embeddings-openai.js";
import { importNodeLlamaCpp } from "./node-llama.js";
export type { GeminiEmbeddingClient } from "./embeddings-gemini.js";
export type { OpenAiEmbeddingClient } from "./embeddings-openai.js";
export type EmbeddingProvider = {
id: string;
model: string;
@@ -25,49 +12,230 @@ export type EmbeddingProvider = {
export type EmbeddingProviderResult = {
provider: EmbeddingProvider;
requestedProvider: "openai" | "local" | "gemini" | "auto";
fallbackFrom?: "openai" | "local" | "gemini";
requestedProvider: "openai" | "gemini" | "local";
fallbackFrom?: "local";
fallbackReason?: string;
openAi?: OpenAiEmbeddingClient;
gemini?: GeminiEmbeddingClient;
};
export type OpenAiEmbeddingClient = {
baseUrl: string;
headers: Record<string, string>;
model: string;
};
export type GeminiEmbeddingClient = {
baseUrl: string;
headers: Record<string, string>;
model: string;
};
export type EmbeddingProviderOptions = {
config: ClawdbotConfig;
agentDir?: string;
provider: "openai" | "local" | "gemini" | "auto";
provider: "openai" | "gemini" | "local";
remote?: {
baseUrl?: string;
apiKey?: string;
headers?: Record<string, string>;
};
model: string;
fallback: "openai" | "gemini" | "local" | "none";
fallback: "openai" | "none";
local?: {
modelPath?: string;
modelCacheDir?: string;
};
};
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
const DEFAULT_LOCAL_MODEL = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
const DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
const DEFAULT_GEMINI_MODEL = "gemini-embedding-001";
function canAutoSelectLocal(options: EmbeddingProviderOptions): boolean {
const modelPath = options.local?.modelPath?.trim();
if (!modelPath) return false;
if (/^(hf:|https?:)/i.test(modelPath)) return false;
const resolved = resolveUserPath(modelPath);
try {
return fsSync.statSync(resolved).isFile();
} catch {
return false;
function normalizeOpenAiModel(model: string): string {
const trimmed = model.trim();
if (!trimmed) return "text-embedding-3-small";
if (trimmed.startsWith("openai/")) return trimmed.slice("openai/".length);
return trimmed;
}
function normalizeGeminiModel(model: string): string {
const trimmed = model.trim();
if (!trimmed) return DEFAULT_GEMINI_MODEL;
if (trimmed.startsWith("models/")) return trimmed.slice("models/".length);
if (trimmed.startsWith("google/")) return trimmed.slice("google/".length);
return trimmed;
}
async function createOpenAiEmbeddingProvider(
options: EmbeddingProviderOptions,
): Promise<{ provider: EmbeddingProvider; client: OpenAiEmbeddingClient }> {
const client = await resolveOpenAiEmbeddingClient(options);
const url = `${client.baseUrl.replace(/\/$/, "")}/embeddings`;
const embed = async (input: string[]): Promise<number[][]> => {
if (input.length === 0) return [];
const res = await fetch(url, {
method: "POST",
headers: client.headers,
body: JSON.stringify({ model: client.model, input }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`openai embeddings failed: ${res.status} ${text}`);
}
const payload = (await res.json()) as {
data?: Array<{ embedding?: number[] }>;
};
const data = payload.data ?? [];
return data.map((entry) => entry.embedding ?? []);
};
return {
provider: {
id: "openai",
model: client.model,
embedQuery: async (text) => {
const [vec] = await embed([text]);
return vec ?? [];
},
embedBatch: embed,
},
client,
};
}
function extractGeminiEmbeddingValues(entry: unknown): number[] {
if (!entry || typeof entry !== "object") return [];
const record = entry as { values?: unknown; embedding?: { values?: unknown } };
const values = record.values ?? record.embedding?.values;
if (!Array.isArray(values)) return [];
return values.filter((value): value is number => typeof value === "number");
}
function parseGeminiEmbeddings(payload: unknown): number[][] {
if (!payload || typeof payload !== "object") return [];
const data = payload as { embedding?: unknown; embeddings?: unknown[] };
if (Array.isArray(data.embeddings)) {
return data.embeddings.map((entry) => extractGeminiEmbeddingValues(entry));
}
if (data.embedding) {
return [extractGeminiEmbeddingValues(data.embedding)];
}
return [];
}
function isMissingApiKeyError(err: unknown): boolean {
const message = formatError(err);
return message.includes("No API key found for provider");
async function createGeminiEmbeddingProvider(
options: EmbeddingProviderOptions,
): Promise<{ provider: EmbeddingProvider; client: GeminiEmbeddingClient }> {
const client = await resolveGeminiEmbeddingClient(options);
const baseUrl = client.baseUrl.replace(/\/$/, "");
const model = `models/${client.model}`;
const embedContent = async (input: string): Promise<number[]> => {
const res = await fetch(`${baseUrl}/${model}:embedContent`, {
method: "POST",
headers: client.headers,
body: JSON.stringify({
model,
content: { parts: [{ text: input }] },
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`gemini embeddings failed: ${res.status} ${text}`);
}
const payload = await res.json();
const embeddings = parseGeminiEmbeddings(payload);
return embeddings[0] ?? [];
};
const embedBatch = async (input: string[]): Promise<number[][]> => {
if (input.length === 0) return [];
const res = await fetch(`${baseUrl}/${model}:batchEmbedContents`, {
method: "POST",
headers: client.headers,
body: JSON.stringify({
requests: input.map((text) => ({
model,
content: { parts: [{ text }] },
})),
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`gemini embeddings failed: ${res.status} ${text}`);
}
const payload = await res.json();
const embeddings = parseGeminiEmbeddings(payload);
return embeddings;
};
return {
provider: {
id: "gemini",
model: client.model,
embedQuery: embedContent,
embedBatch,
},
client,
};
}
async function resolveOpenAiEmbeddingClient(
options: EmbeddingProviderOptions,
): Promise<OpenAiEmbeddingClient> {
const remote = options.remote;
const remoteApiKey = remote?.apiKey?.trim();
const remoteBaseUrl = remote?.baseUrl?.trim();
const { apiKey } = remoteApiKey
? { apiKey: remoteApiKey }
: await resolveApiKeyForProvider({
provider: "openai",
cfg: options.config,
agentDir: options.agentDir,
});
const providerConfig = options.config.models?.providers?.openai;
const baseUrl = remoteBaseUrl || providerConfig?.baseUrl?.trim() || DEFAULT_OPENAI_BASE_URL;
const headerOverrides = Object.assign({}, providerConfig?.headers, remote?.headers);
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
...headerOverrides,
};
const model = normalizeOpenAiModel(options.model);
return { baseUrl, headers, model };
}
async function resolveGeminiEmbeddingClient(
options: EmbeddingProviderOptions,
): Promise<GeminiEmbeddingClient> {
const remote = options.remote;
const remoteApiKey = remote?.apiKey?.trim();
const remoteBaseUrl = remote?.baseUrl?.trim();
const { apiKey } = remoteApiKey
? { apiKey: remoteApiKey }
: await resolveApiKeyForProvider({
provider: "google",
cfg: options.config,
agentDir: options.agentDir,
});
const providerConfig = options.config.models?.providers?.google;
const baseUrl = remoteBaseUrl || providerConfig?.baseUrl?.trim() || DEFAULT_GEMINI_BASE_URL;
const headerOverrides = Object.assign({}, providerConfig?.headers, remote?.headers);
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-goog-api-key": apiKey,
...headerOverrides,
};
const model = normalizeGeminiModel(options.model);
return { baseUrl, headers, model };
}
async function createLocalEmbeddingProvider(
options: EmbeddingProviderOptions,
@@ -121,80 +289,35 @@ export async function createEmbeddingProvider(
options: EmbeddingProviderOptions,
): Promise<EmbeddingProviderResult> {
const requestedProvider = options.provider;
const fallback = options.fallback;
const createProvider = async (id: "openai" | "local" | "gemini") => {
if (id === "local") {
if (options.provider === "gemini") {
const { provider, client } = await createGeminiEmbeddingProvider(options);
return { provider, requestedProvider, gemini: client };
}
if (options.provider === "local") {
try {
const provider = await createLocalEmbeddingProvider(options);
return { provider };
}
if (id === "gemini") {
const { provider, client } = await createGeminiEmbeddingProvider(options);
return { provider, gemini: client };
}
const { provider, client } = await createOpenAiEmbeddingProvider(options);
return { provider, openAi: client };
};
const formatPrimaryError = (err: unknown, provider: "openai" | "local" | "gemini") =>
provider === "local" ? formatLocalSetupError(err) : formatError(err);
if (requestedProvider === "auto") {
const missingKeyErrors: string[] = [];
let localError: string | null = null;
if (canAutoSelectLocal(options)) {
try {
const local = await createProvider("local");
return { ...local, requestedProvider };
} catch (err) {
localError = formatLocalSetupError(err);
}
}
for (const provider of ["openai", "gemini"] as const) {
try {
const result = await createProvider(provider);
return { ...result, requestedProvider };
} catch (err) {
const message = formatPrimaryError(err, provider);
if (isMissingApiKeyError(err)) {
missingKeyErrors.push(message);
continue;
return { provider, requestedProvider };
} catch (err) {
const reason = formatLocalSetupError(err);
if (options.fallback === "openai") {
try {
const { provider, client } = await createOpenAiEmbeddingProvider(options);
return {
provider,
requestedProvider,
fallbackFrom: "local",
fallbackReason: reason,
openAi: client,
};
} catch (fallbackErr) {
throw new Error(`${reason}\n\nFallback to OpenAI failed: ${formatError(fallbackErr)}`);
}
throw new Error(message);
}
throw new Error(reason);
}
const details = [...missingKeyErrors, localError].filter(Boolean) as string[];
if (details.length > 0) {
throw new Error(details.join("\n\n"));
}
throw new Error("No embeddings provider available.");
}
try {
const primary = await createProvider(requestedProvider);
return { ...primary, requestedProvider };
} catch (primaryErr) {
const reason = formatPrimaryError(primaryErr, requestedProvider);
if (fallback && fallback !== "none" && fallback !== requestedProvider) {
try {
const fallbackResult = await createProvider(fallback);
return {
...fallbackResult,
requestedProvider,
fallbackFrom: requestedProvider,
fallbackReason: reason,
};
} catch (fallbackErr) {
throw new Error(
`${reason}\n\nFallback to ${fallback} failed: ${formatError(fallbackErr)}`,
);
}
}
throw new Error(reason);
}
const { provider, client } = await createOpenAiEmbeddingProvider(options);
return { provider, requestedProvider, openAi: client };
}
function formatError(err: unknown): string {
-42
View File
@@ -7,7 +7,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
let embedBatchCalls = 0;
let failEmbeddings = false;
vi.mock("./embeddings.js", () => {
const embedText = (text: string) => {
@@ -25,9 +24,6 @@ vi.mock("./embeddings.js", () => {
embedQuery: async (text: string) => embedText(text),
embedBatch: async (texts: string[]) => {
embedBatchCalls += 1;
if (failEmbeddings) {
throw new Error("mock embeddings failed");
}
return texts.map(embedText);
},
},
@@ -42,7 +38,6 @@ describe("memory index", () => {
beforeEach(async () => {
embedBatchCalls = 0;
failEmbeddings = false;
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-mem-"));
indexPath = path.join(workspaceDir, "index.sqlite");
await fs.mkdir(path.join(workspaceDir, "memory"));
@@ -186,43 +181,6 @@ describe("memory index", () => {
expect(embedBatchCalls).toBe(afterFirst);
});
it("preserves existing index when forced reindex fails", async () => {
const cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: indexPath, vector: { enabled: false } },
sync: { watch: false, onSessionStart: false, onSearch: false },
query: { minScore: 0 },
cache: { enabled: false },
},
},
list: [{ id: "main", default: true }],
},
};
const result = await getMemorySearchManager({ cfg, agentId: "main" });
expect(result.manager).not.toBeNull();
if (!result.manager) throw new Error("manager missing");
manager = result.manager;
await manager.sync({ force: true });
const before = manager.status();
expect(before.files).toBeGreaterThan(0);
failEmbeddings = true;
await expect(manager.sync({ force: true })).rejects.toThrow(/mock embeddings failed/i);
const after = manager.status();
expect(after.files).toBe(before.files);
expect(after.chunks).toBe(before.chunks);
const files = await fs.readdir(workspaceDir);
expect(files.some((name) => name.includes(".tmp-"))).toBe(false);
});
it("finds keyword matches via hybrid search when query embedding is zero", async () => {
const cfg = {
agents: {
-98
View File
@@ -148,102 +148,4 @@ describe("memory indexing with OpenAI batches", () => {
expect(fetchMock).toHaveBeenCalled();
expect(labels.some((label) => label.toLowerCase().includes("batch"))).toBe(true);
});
it("retries OpenAI batch create on transient failures", async () => {
const content = ["retry", "the", "batch"].join("\n\n");
await fs.writeFile(path.join(workspaceDir, "memory", "2026-01-08.md"), content);
let uploadedRequests: Array<{ custom_id?: string }> = [];
let batchCreates = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.endsWith("/files")) {
const body = init?.body;
if (!(body instanceof FormData)) {
throw new Error("expected FormData upload");
}
for (const [key, value] of body.entries()) {
if (key !== "file") continue;
if (typeof value === "string") {
uploadedRequests = value
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as { custom_id?: string });
} else {
const text = await value.text();
uploadedRequests = text
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as { custom_id?: string });
}
}
return new Response(JSON.stringify({ id: "file_1" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url.endsWith("/batches")) {
batchCreates += 1;
if (batchCreates === 1) {
return new Response("upstream connect error", { status: 503 });
}
return new Response(JSON.stringify({ id: "batch_1", status: "in_progress" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url.endsWith("/batches/batch_1")) {
return new Response(
JSON.stringify({ id: "batch_1", status: "completed", output_file_id: "file_out" }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url.endsWith("/files/file_out/content")) {
const lines = uploadedRequests.map((request, index) =>
JSON.stringify({
custom_id: request.custom_id,
response: {
status_code: 200,
body: { data: [{ embedding: [index + 1, 0, 0], index: 0 }] },
},
}),
);
return new Response(lines.join("\n"), {
status: 200,
headers: { "Content-Type": "application/jsonl" },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "text-embedding-3-small",
store: { path: indexPath },
sync: { watch: false, onSessionStart: false, onSearch: false },
query: { minScore: 0 },
remote: { batch: { enabled: true, wait: true } },
},
},
list: [{ id: "main", default: true }],
},
};
const result = await getMemorySearchManager({ cfg, agentId: "main" });
expect(result.manager).not.toBeNull();
if (!result.manager) throw new Error("manager missing");
manager = result.manager;
await manager.sync({ force: true });
const status = manager.status();
expect(status.chunks).toBeGreaterThan(0);
expect(batchCreates).toBe(2);
});
});
+271 -651
View File
File diff suppressed because it is too large. Load diff
+362 -2
View File
@@ -1,2 +1,362 @@
// Deprecated: use ./batch-openai.js
export * from "./batch-openai.js";
import type { OpenAiEmbeddingClient } from "./embeddings.js";
import { hashText } from "./internal.js";
export type OpenAiBatchRequest = {
custom_id: string;
method: "POST";
url: "/v1/embeddings";
body: {
model: string;
input: string;
};
};
export type OpenAiBatchStatus = {
id?: string;
status?: string;
output_file_id?: string | null;
error_file_id?: string | null;
};
export type OpenAiBatchOutputLine = {
custom_id?: string;
response?: {
status_code?: number;
body?: {
data?: Array<{ embedding?: number[]; index?: number }>;
error?: { message?: string };
};
};
error?: { message?: string };
};
export const OPENAI_BATCH_ENDPOINT = "/v1/embeddings";
const OPENAI_BATCH_COMPLETION_WINDOW = "24h";
const OPENAI_BATCH_MAX_REQUESTS = 50000;
function getOpenAiBaseUrl(openAi: OpenAiEmbeddingClient): string {
return openAi.baseUrl?.replace(/\/$/, "") ?? "";
}
function getOpenAiHeaders(
openAi: OpenAiEmbeddingClient,
params: { json: boolean },
): Record<string, string> {
const headers = openAi.headers ? { ...openAi.headers } : {};
if (params.json) {
if (!headers["Content-Type"] && !headers["content-type"]) {
headers["Content-Type"] = "application/json";
}
} else {
delete headers["Content-Type"];
delete headers["content-type"];
}
return headers;
}
function splitOpenAiBatchRequests(requests: OpenAiBatchRequest[]): OpenAiBatchRequest[][] {
if (requests.length <= OPENAI_BATCH_MAX_REQUESTS) return [requests];
const groups: OpenAiBatchRequest[][] = [];
for (let i = 0; i < requests.length; i += OPENAI_BATCH_MAX_REQUESTS) {
groups.push(requests.slice(i, i + OPENAI_BATCH_MAX_REQUESTS));
}
return groups;
}
async function submitOpenAiBatch(params: {
openAi: OpenAiEmbeddingClient;
requests: OpenAiBatchRequest[];
agentId: string;
}): Promise<OpenAiBatchStatus> {
const baseUrl = getOpenAiBaseUrl(params.openAi);
const jsonl = params.requests.map((request) => JSON.stringify(request)).join("\n");
const form = new FormData();
form.append("purpose", "batch");
form.append(
"file",
new Blob([jsonl], { type: "application/jsonl" }),
`memory-embeddings.${hashText(String(Date.now()))}.jsonl`,
);
const fileRes = await fetch(`${baseUrl}/files`, {
method: "POST",
headers: getOpenAiHeaders(params.openAi, { json: false }),
body: form,
});
if (!fileRes.ok) {
const text = await fileRes.text();
throw new Error(`openai batch file upload failed: ${fileRes.status} ${text}`);
}
const filePayload = (await fileRes.json()) as { id?: string };
if (!filePayload.id) {
throw new Error("openai batch file upload failed: missing file id");
}
const batchRes = await fetch(`${baseUrl}/batches`, {
method: "POST",
headers: getOpenAiHeaders(params.openAi, { json: true }),
body: JSON.stringify({
input_file_id: filePayload.id,
endpoint: OPENAI_BATCH_ENDPOINT,
completion_window: OPENAI_BATCH_COMPLETION_WINDOW,
metadata: {
source: "clawdbot-memory",
agent: params.agentId,
},
}),
});
if (!batchRes.ok) {
const text = await batchRes.text();
throw new Error(`openai batch create failed: ${batchRes.status} ${text}`);
}
return (await batchRes.json()) as OpenAiBatchStatus;
}
async function fetchOpenAiBatchStatus(params: {
openAi: OpenAiEmbeddingClient;
batchId: string;
}): Promise<OpenAiBatchStatus> {
const baseUrl = getOpenAiBaseUrl(params.openAi);
const res = await fetch(`${baseUrl}/batches/${params.batchId}`, {
headers: getOpenAiHeaders(params.openAi, { json: true }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`openai batch status failed: ${res.status} ${text}`);
}
return (await res.json()) as OpenAiBatchStatus;
}
async function fetchOpenAiFileContent(params: {
openAi: OpenAiEmbeddingClient;
fileId: string;
}): Promise<string> {
const baseUrl = getOpenAiBaseUrl(params.openAi);
const res = await fetch(`${baseUrl}/files/${params.fileId}/content`, {
headers: getOpenAiHeaders(params.openAi, { json: true }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`openai batch file content failed: ${res.status} ${text}`);
}
return await res.text();
}
function parseOpenAiBatchOutput(text: string): OpenAiBatchOutputLine[] {
if (!text.trim()) return [];
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as OpenAiBatchOutputLine);
}
async function readOpenAiBatchError(params: {
openAi: OpenAiEmbeddingClient;
errorFileId: string;
}): Promise<string | undefined> {
try {
const content = await fetchOpenAiFileContent({
openAi: params.openAi,
fileId: params.errorFileId,
});
const lines = parseOpenAiBatchOutput(content);
const first = lines.find((line) => line.error?.message || line.response?.body?.error);
const message =
first?.error?.message ??
(typeof first?.response?.body?.error?.message === "string"
? first?.response?.body?.error?.message
: undefined);
return message;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return message ? `error file unavailable: ${message}` : undefined;
}
}
async function waitForOpenAiBatch(params: {
openAi: OpenAiEmbeddingClient;
batchId: string;
wait: boolean;
pollIntervalMs: number;
timeoutMs: number;
debug?: (message: string, data?: Record<string, unknown>) => void;
initial?: OpenAiBatchStatus;
}): Promise<{ outputFileId: string; errorFileId?: string }> {
const start = Date.now();
let current: OpenAiBatchStatus | undefined = params.initial;
while (true) {
const status =
current ??
(await fetchOpenAiBatchStatus({
openAi: params.openAi,
batchId: params.batchId,
}));
const state = status.status ?? "unknown";
if (state === "completed") {
if (!status.output_file_id) {
throw new Error(`openai batch ${params.batchId} completed without output file`);
}
return {
outputFileId: status.output_file_id,
errorFileId: status.error_file_id ?? undefined,
};
}
if (["failed", "expired", "cancelled", "canceled"].includes(state)) {
const detail = status.error_file_id
? await readOpenAiBatchError({ openAi: params.openAi, errorFileId: status.error_file_id })
: undefined;
const suffix = detail ? `: ${detail}` : "";
throw new Error(`openai batch ${params.batchId} ${state}${suffix}`);
}
if (!params.wait) {
throw new Error(`openai batch ${params.batchId} still ${state}; wait disabled`);
}
if (Date.now() - start > params.timeoutMs) {
throw new Error(`openai batch ${params.batchId} timed out after ${params.timeoutMs}ms`);
}
params.debug?.(`openai batch ${params.batchId} ${state}; waiting ${params.pollIntervalMs}ms`);
await new Promise((resolve) => setTimeout(resolve, params.pollIntervalMs));
current = undefined;
}
}
async function runWithConcurrency<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<T[]> {
if (tasks.length === 0) return [];
const resolvedLimit = Math.max(1, Math.min(limit, tasks.length));
const results: T[] = Array.from({ length: tasks.length });
let next = 0;
let firstError: unknown = null;
const workers = Array.from({ length: resolvedLimit }, async () => {
while (true) {
if (firstError) return;
const index = next;
next += 1;
if (index >= tasks.length) return;
try {
results[index] = await tasks[index]();
} catch (err) {
firstError = err;
return;
}
}
});
await Promise.allSettled(workers);
if (firstError) throw firstError;
return results;
}
export async function runOpenAiEmbeddingBatches(params: {
openAi: OpenAiEmbeddingClient;
agentId: string;
requests: OpenAiBatchRequest[];
wait: boolean;
pollIntervalMs: number;
timeoutMs: number;
concurrency: number;
debug?: (message: string, data?: Record<string, unknown>) => void;
}): Promise<Map<string, number[]>> {
if (params.requests.length === 0) return new Map();
const groups = splitOpenAiBatchRequests(params.requests);
const byCustomId = new Map<string, number[]>();
const tasks = groups.map((group, groupIndex) => async () => {
const batchInfo = await submitOpenAiBatch({
openAi: params.openAi,
requests: group,
agentId: params.agentId,
});
if (!batchInfo.id) {
throw new Error("openai batch create failed: missing batch id");
}
params.debug?.("memory embeddings: openai batch created", {
batchId: batchInfo.id,
status: batchInfo.status,
group: groupIndex + 1,
groups: groups.length,
requests: group.length,
});
if (!params.wait && batchInfo.status !== "completed") {
throw new Error(
`openai batch ${batchInfo.id} submitted; enable remote.batch.wait to await completion`,
);
}
const completed =
batchInfo.status === "completed"
? {
outputFileId: batchInfo.output_file_id ?? "",
errorFileId: batchInfo.error_file_id ?? undefined,
}
: await waitForOpenAiBatch({
openAi: params.openAi,
batchId: batchInfo.id,
wait: params.wait,
pollIntervalMs: params.pollIntervalMs,
timeoutMs: params.timeoutMs,
debug: params.debug,
initial: batchInfo,
});
if (!completed.outputFileId) {
throw new Error(`openai batch ${batchInfo.id} completed without output file`);
}
const content = await fetchOpenAiFileContent({
openAi: params.openAi,
fileId: completed.outputFileId,
});
const outputLines = parseOpenAiBatchOutput(content);
const errors: string[] = [];
const remaining = new Set(group.map((request) => request.custom_id));
for (const line of outputLines) {
const customId = line.custom_id;
if (!customId) continue;
remaining.delete(customId);
if (line.error?.message) {
errors.push(`${customId}: ${line.error.message}`);
continue;
}
const response = line.response;
const statusCode = response?.status_code ?? 0;
if (statusCode >= 400) {
const message =
response?.body?.error?.message ??
(typeof response?.body === "string" ? response.body : undefined) ??
"unknown error";
errors.push(`${customId}: ${message}`);
continue;
}
const data = response?.body?.data ?? [];
const embedding = data[0]?.embedding ?? [];
if (embedding.length === 0) {
errors.push(`${customId}: empty embedding`);
continue;
}
byCustomId.set(customId, embedding);
}
if (errors.length > 0) {
throw new Error(`openai batch ${batchInfo.id} failed: ${errors.join("; ")}`);
}
if (remaining.size > 0) {
throw new Error(`openai batch ${batchInfo.id} missing ${remaining.size} embedding responses`);
}
});
params.debug?.("memory embeddings: openai batch submit", {
requests: params.requests.length,
groups: groups.length,
wait: params.wait,
concurrency: params.concurrency,
pollIntervalMs: params.pollIntervalMs,
timeoutMs: params.timeoutMs,
});
await runWithConcurrency(tasks, params.concurrency);
return byCustomId;
}
-6
View File
@@ -27,8 +27,6 @@ export type BridgeClientOptions = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
@@ -174,8 +172,6 @@ export class BridgeClient {
displayName: this.opts.displayName,
platform: this.opts.platform,
version: this.opts.version,
coreVersion: this.opts.coreVersion,
uiVersion: this.opts.uiVersion,
deviceFamily: this.opts.deviceFamily,
modelIdentifier: this.opts.modelIdentifier,
caps: this.opts.caps,
@@ -192,8 +188,6 @@ export class BridgeClient {
displayName: this.opts.displayName,
platform: this.opts.platform,
version: this.opts.version,
coreVersion: this.opts.coreVersion,
uiVersion: this.opts.uiVersion,
deviceFamily: this.opts.deviceFamily,
modelIdentifier: this.opts.modelIdentifier,
caps: this.opts.caps,
-103
View File
@@ -19,12 +19,6 @@ import {
saveExecApprovals,
type ExecApprovalsFile,
} from "../infra/exec-approvals.js";
import {
requestExecHostViaSocket,
type ExecHostRequest,
type ExecHostResponse,
type ExecHostRunResult,
} from "../infra/exec-host.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { VERSION } from "../version.js";
@@ -92,9 +86,6 @@ type ExecEventPayload = {
const OUTPUT_CAP = 200_000;
const OUTPUT_EVENT_TAIL = 20_000;
const execHostEnforced = process.env.CLAWDBOT_NODE_EXEC_HOST?.trim().toLowerCase() === "app";
const execHostFallbackAllowed = process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim() === "1";
const blockedEnvKeys = new Set([
"PATH",
"NODE_OPTIONS",
@@ -314,18 +305,6 @@ function buildExecEventPayload(payload: ExecEventPayload): ExecEventPayload {
return { ...payload, output: text };
}
async function runViaMacAppExecHost(params: {
approvals: ReturnType<typeof resolveExecApprovals>;
request: ExecHostRequest;
}): Promise<ExecHostResponse | null> {
const { approvals, request } = params;
return await requestExecHostViaSocket({
socketPath: approvals.socketPath,
token: approvals.token,
request,
});
}
export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
const config = await ensureNodeHostConfig();
const nodeId = opts.nodeId?.trim() || config.nodeId;
@@ -367,7 +346,6 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
displayName,
platform: process.platform,
version: VERSION,
coreVersion: VERSION,
deviceFamily: os.platform(),
modelIdentifier: os.hostname(),
caps: ["system"],
@@ -576,87 +554,6 @@ async function handleInvoke(
const skillAllow =
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
const useMacAppExec = process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed);
if (useMacAppExec) {
const execRequest: ExecHostRequest = {
command: argv,
rawCommand: rawCommand || null,
cwd: params.cwd ?? null,
env: params.env ?? null,
timeoutMs: params.timeoutMs ?? null,
needsScreenRecording: params.needsScreenRecording ?? null,
agentId: agentId ?? null,
sessionKey: sessionKey ?? null,
};
const response = await runViaMacAppExecHost({ approvals, request: execRequest });
if (!response) {
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "companion-unavailable",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: {
code: "UNAVAILABLE",
message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable",
},
});
return;
}
if (!response.ok) {
const reason = response.error.reason ?? "approval-required";
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason,
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "UNAVAILABLE", message: response.error.message },
});
return;
}
const result: ExecHostRunResult = response.payload;
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
client.sendEvent(
"exec.finished",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
output: combined,
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: true,
payloadJSON: JSON.stringify(result),
});
return;
}
if (security === "deny") {
client.sendEvent(
"exec.denied",
-45
View File
@@ -1,45 +0,0 @@
import type { ClawdbotConfig } from "../config/config.js";
export type PluginEnableResult = {
config: ClawdbotConfig;
enabled: boolean;
reason?: string;
};
function ensureAllowlisted(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig {
const allow = cfg.plugins?.allow;
if (!Array.isArray(allow) || allow.includes(pluginId)) return cfg;
return {
...cfg,
plugins: {
...cfg.plugins,
allow: [...allow, pluginId],
},
};
}
export function enablePluginInConfig(cfg: ClawdbotConfig, pluginId: string): PluginEnableResult {
if (cfg.plugins?.enabled === false) {
return { config: cfg, enabled: false, reason: "plugins disabled" };
}
if (cfg.plugins?.deny?.includes(pluginId)) {
return { config: cfg, enabled: false, reason: "blocked by denylist" };
}
const entries = {
...cfg.plugins?.entries,
[pluginId]: {
...(cfg.plugins?.entries?.[pluginId] as Record<string, unknown> | undefined),
enabled: true,
},
};
let next: ClawdbotConfig = {
...cfg,
plugins: {
...cfg.plugins,
entries,
},
};
next = ensureAllowlisted(next, pluginId);
return { config: next, enabled: true };
}
+2 -3
View File
@@ -12,7 +12,6 @@ import { initializeGlobalHookRunner } from "./hook-runner-global.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { createPluginRuntime } from "./runtime/index.js";
import { setActivePluginRegistry } from "./runtime.js";
import { defaultSlotIdForKey } from "./slots.js";
import type {
ClawdbotPluginConfigSchema,
ClawdbotPluginDefinition,
@@ -47,7 +46,7 @@ const registryCache = new Map<string, PluginRegistry>();
const defaultLogger = () => createSubsystemLogger("plugins");
const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>();
const BUNDLED_ENABLED_BY_DEFAULT = new Set(["telegram", "whatsapp", "discord", "slack", "signal"]);
const normalizeList = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
@@ -93,7 +92,7 @@ const normalizePluginsConfig = (config?: ClawdbotConfig["plugins"]): NormalizedP
deny: normalizeList(config?.deny),
loadPaths: normalizeList(config?.load?.paths),
slots: {
memory: memorySlot ?? defaultSlotIdForKey("memory"),
memory: memorySlot ?? "memory-core",
},
entries: normalizePluginEntries(config?.entries),
};
-96
View File
@@ -1,96 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { applyExclusiveSlotSelection } from "./slots.js";
describe("applyExclusiveSlotSelection", () => {
it("selects the slot and disables other entries for the same kind", () => {
const config: ClawdbotConfig = {
plugins: {
slots: { memory: "memory-core" },
entries: {
"memory-core": { enabled: true },
memory: { enabled: true },
},
},
};
const result = applyExclusiveSlotSelection({
config,
selectedId: "memory",
selectedKind: "memory",
registry: {
plugins: [
{ id: "memory-core", kind: "memory" },
{ id: "memory", kind: "memory" },
],
},
});
expect(result.changed).toBe(true);
expect(result.config.plugins?.slots?.memory).toBe("memory");
expect(result.config.plugins?.entries?.["memory-core"]?.enabled).toBe(false);
expect(result.warnings).toContain(
'Exclusive slot "memory" switched from "memory-core" to "memory".',
);
expect(result.warnings).toContain(
'Disabled other "memory" slot plugins: memory-core.',
);
});
it("does nothing when the slot already matches", () => {
const config: ClawdbotConfig = {
plugins: {
slots: { memory: "memory" },
entries: {
memory: { enabled: true },
},
},
};
const result = applyExclusiveSlotSelection({
config,
selectedId: "memory",
selectedKind: "memory",
registry: { plugins: [{ id: "memory", kind: "memory" }] },
});
expect(result.changed).toBe(false);
expect(result.warnings).toHaveLength(0);
expect(result.config).toBe(config);
});
it("warns when the slot falls back to a default", () => {
const config: ClawdbotConfig = {
plugins: {
entries: {
memory: { enabled: true },
},
},
};
const result = applyExclusiveSlotSelection({
config,
selectedId: "memory",
selectedKind: "memory",
registry: { plugins: [{ id: "memory", kind: "memory" }] },
});
expect(result.changed).toBe(true);
expect(result.warnings).toContain(
'Exclusive slot "memory" switched from "memory-core" to "memory".',
);
});
it("skips changes when no exclusive slot applies", () => {
const config: ClawdbotConfig = {};
const result = applyExclusiveSlotSelection({
config,
selectedId: "custom",
});
expect(result.changed).toBe(false);
expect(result.warnings).toHaveLength(0);
expect(result.config).toBe(config);
});
});
-102
View File
@@ -1,102 +0,0 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { PluginSlotsConfig } from "../config/types.plugins.js";
import type { PluginKind } from "./types.js";
export type PluginSlotKey = keyof PluginSlotsConfig;
type SlotPluginRecord = {
id: string;
kind?: PluginKind;
};
const SLOT_BY_KIND: Record<PluginKind, PluginSlotKey> = {
memory: "memory",
};
const DEFAULT_SLOT_BY_KEY: Record<PluginSlotKey, string> = {
memory: "memory-core",
};
export function slotKeyForPluginKind(kind?: PluginKind): PluginSlotKey | null {
if (!kind) return null;
return SLOT_BY_KIND[kind] ?? null;
}
export function defaultSlotIdForKey(slotKey: PluginSlotKey): string {
return DEFAULT_SLOT_BY_KEY[slotKey];
}
export type SlotSelectionResult = {
config: ClawdbotConfig;
warnings: string[];
changed: boolean;
};
export function applyExclusiveSlotSelection(params: {
config: ClawdbotConfig;
selectedId: string;
selectedKind?: PluginKind;
registry?: { plugins: SlotPluginRecord[] };
}): SlotSelectionResult {
const slotKey = slotKeyForPluginKind(params.selectedKind);
if (!slotKey) {
return { config: params.config, warnings: [], changed: false };
}
const warnings: string[] = [];
const pluginsConfig = params.config.plugins ?? {};
const prevSlot = pluginsConfig.slots?.[slotKey];
const slots = {
...pluginsConfig.slots,
[slotKey]: params.selectedId,
};
const inferredPrevSlot = prevSlot ?? defaultSlotIdForKey(slotKey);
if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) {
warnings.push(
`Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`,
);
}
const entries = { ...pluginsConfig.entries };
const disabledIds: string[] = [];
if (params.registry) {
for (const plugin of params.registry.plugins) {
if (plugin.id === params.selectedId) continue;
if (plugin.kind !== params.selectedKind) continue;
const entry = entries[plugin.id];
if (!entry || entry.enabled !== false) {
entries[plugin.id] = {
...entry,
enabled: false,
};
disabledIds.push(plugin.id);
}
}
}
if (disabledIds.length > 0) {
warnings.push(
`Disabled other "${slotKey}" slot plugins: ${disabledIds.sort().join(", ")}.`,
);
}
const changed = prevSlot !== params.selectedId || disabledIds.length > 0;
if (!changed) {
return { config: params.config, warnings: [], changed: false };
}
return {
config: {
...params.config,
plugins: {
...pluginsConfig,
slots,
entries,
},
},
warnings,
changed: true,
};
}
+1 -9
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
const loadJsonFile = vi.fn();
const saveJsonFile = vi.fn();
@@ -14,14 +14,6 @@ vi.mock("../config/paths.js", () => ({
}));
describe("github-copilot token", () => {
beforeEach(() => {
vi.resetModules();
loadJsonFile.mockReset();
saveJsonFile.mockReset();
resolveStateDir.mockReset();
resolveStateDir.mockReturnValue("/tmp/clawdbot-state");
});
it("derives baseUrl from token", async () => {
const { deriveCopilotApiBaseUrlFromToken } = await import("./github-copilot-token.js");
+2 -1
View File
@@ -26,7 +26,8 @@ import { normalizeAllowList } from "./allow-list.js";
import type { MonitorSlackOpts } from "./types.js";
const { App, HTTPReceiver } = SlackBolt as typeof import("@slack/bolt");
const { App, HTTPReceiver } = SlackBolt;
function parseApiAppIdFromAppToken(raw?: string) {
const token = raw?.trim();
if (!token) return undefined;
-8
View File
@@ -8,14 +8,6 @@ export async function ensureDir(dir: string) {
await fs.promises.mkdir(dir, { recursive: true });
}
export function clampNumber(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
export function clampInt(value: number, min: number, max: number): number {
return clampNumber(Math.floor(value), min, max);
}
export type WebChannel = "web";
export function assertWebChannel(input: string): asserts input is WebChannel {