Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4917c2cfa | ||
|
|
205a5fd522 | ||
|
|
9ba7c8874b | ||
|
|
acd40e1780 | ||
|
|
b5fd66c92d | ||
|
|
45c1ccdfcf | ||
|
|
76600e80ba | ||
|
|
483a50f107 | ||
|
|
31943dcecb | ||
|
|
717fb9e413 | ||
|
|
ad7ef27f66 | ||
|
|
0d3b8f6ac3 | ||
|
|
6492e90c1b | ||
|
|
e4b3c8b98d | ||
|
|
8b8e078ef8 | ||
|
|
44a3539ffa | ||
|
|
0daaa5b592 | ||
|
|
6866cca6d7 | ||
|
|
c145a0d116 | ||
|
|
6c0a01dc90 | ||
|
|
41c9c214fc | ||
|
|
9f999f6554 | ||
|
|
9f59ff325b | ||
|
|
c415ccaed5 | ||
|
|
403904ecd1 | ||
|
|
32550154f9 | ||
|
|
6996c0f330 | ||
|
|
cf4f1ed03a | ||
|
|
c913f05fb5 | ||
|
|
88d76d4be5 | ||
|
|
b52ab96e2c | ||
|
|
f0a8b34198 | ||
|
|
64d29b0c31 | ||
|
|
9b47f463b7 | ||
|
|
9605ad76c5 | ||
|
|
c129f0bbaa | ||
|
|
9e22f019db | ||
|
|
6f58d508b8 | ||
|
|
84eadd92a1 | ||
|
|
fd918bf6bf | ||
|
|
4e1806947d | ||
|
|
8aca606a6f | ||
|
|
56799a21be | ||
|
|
d2a0e416ea | ||
|
|
43afad9f51 | ||
|
|
5d73a412c6 | ||
|
|
d0e8faea97 | ||
|
|
cd25d69b4d | ||
|
|
c3adc50cb2 | ||
|
|
cbb9872478 | ||
|
|
9b9bbae501 | ||
|
|
7bfc32fe33 | ||
|
|
b073deee20 |
No files matched your search
+10
-2
@@ -10,16 +10,24 @@ Docs: https://docs.clawd.bot
|
|||||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||||
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
||||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||||
|
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
||||||
|
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
||||||
|
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
||||||
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||||
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
||||||
|
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
|
||||||
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
||||||
|
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
|
||||||
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
||||||
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
||||||
- Model picker: list the full catalog when no model allowlist is configured.
|
- Model picker: list the full catalog when no model allowlist is configured.
|
||||||
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1369) Thanks @tyler6204.
|
- Chat: include configured defaults/providers in `/models` output and normalize all-mode paging. (#1398) Thanks @vignesh07.
|
||||||
- Infra: preserve fetch helper methods when wrapping abort signals. (#1369)
|
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
|
||||||
|
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
|
||||||
|
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
|
||||||
|
|
||||||
## 2026.1.20
|
## 2026.1.20
|
||||||
|
|
||||||
|
|||||||
@@ -284,13 +284,16 @@ enum CommandResolver {
|
|||||||
|
|
||||||
var args: [String] = [
|
var args: [String] = [
|
||||||
"-o", "BatchMode=yes",
|
"-o", "BatchMode=yes",
|
||||||
"-o", "IdentitiesOnly=yes",
|
|
||||||
"-o", "StrictHostKeyChecking=accept-new",
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "UpdateHostKeys=yes",
|
"-o", "UpdateHostKeys=yes",
|
||||||
]
|
]
|
||||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||||
if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
args.append(contentsOf: ["-i", settings.identity])
|
if !identity.isEmpty {
|
||||||
|
// Only use IdentitiesOnly when an explicit identity file is provided.
|
||||||
|
// This allows 1Password SSH agent and other SSH agents to provide keys.
|
||||||
|
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
|
||||||
|
args.append(contentsOf: ["-i", identity])
|
||||||
}
|
}
|
||||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||||
args.append(userHost)
|
args.append(userHost)
|
||||||
|
|||||||
@@ -484,6 +484,22 @@ struct DebugSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(
|
||||||
|
"Note: macOS may require restarting Clawdbot after enabling Accessibility or Screen Recording.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
LaunchdManager.startClawdbot()
|
||||||
|
} label: {
|
||||||
|
Label("Restart Clawdbot", systemImage: "arrow.counterclockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Button("Restart app") { DebugActions.restartApp() }
|
Button("Restart app") { DebugActions.restartApp() }
|
||||||
Button("Restart onboarding") { DebugActions.restartOnboarding() }
|
Button("Restart onboarding") { DebugActions.restartOnboarding() }
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ struct GeneralSettings: View {
|
|||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
||||||
localDisplayName: InstanceIdentity.displayName)
|
localDisplayName: InstanceIdentity.displayName)
|
||||||
@State private var isInstallingCLI = false
|
|
||||||
@State private var cliStatus: String?
|
|
||||||
@State private var cliInstalled = false
|
|
||||||
@State private var cliInstallLocation: String?
|
|
||||||
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
||||||
@State private var remoteStatus: RemoteStatus = .idle
|
@State private var remoteStatus: RemoteStatus = .idle
|
||||||
@State private var showRemoteAdvanced = false
|
@State private var showRemoteAdvanced = false
|
||||||
@@ -29,25 +25,6 @@ struct GeneralSettings: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
if !self.state.onboardingSeen {
|
|
||||||
Button {
|
|
||||||
DebugActions.restartOnboarding()
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Label("Complete onboarding to finish setup", systemImage: "arrow.counterclockwise")
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
.foregroundStyle(Color.accentColor)
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(.bottom, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
SettingsToggleRow(
|
SettingsToggleRow(
|
||||||
title: "Clawdbot active",
|
title: "Clawdbot active",
|
||||||
@@ -83,8 +60,6 @@ struct GeneralSettings: View {
|
|||||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||||
binding: self.$cameraEnabled)
|
binding: self.$cameraEnabled)
|
||||||
|
|
||||||
SystemRunSettingsView()
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Location Access")
|
Text("Location Access")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
@@ -130,7 +105,6 @@ struct GeneralSettings: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
self.refreshCLIStatus()
|
|
||||||
self.refreshGatewayStatus()
|
self.refreshGatewayStatus()
|
||||||
self.lastLocationModeRaw = self.locationModeRaw
|
self.lastLocationModeRaw = self.locationModeRaw
|
||||||
}
|
}
|
||||||
@@ -187,13 +161,14 @@ struct GeneralSettings: View {
|
|||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
Picker("", selection: self.$state.connectionMode) {
|
Picker("Mode", selection: self.$state.connectionMode) {
|
||||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||||
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.menu)
|
||||||
.frame(width: 380, alignment: .leading)
|
.labelsHidden()
|
||||||
|
.frame(width: 260, alignment: .leading)
|
||||||
|
|
||||||
if self.state.connectionMode == .unconfigured {
|
if self.state.connectionMode == .unconfigured {
|
||||||
Text("Pick Local or Remote to start the Gateway.")
|
Text("Pick Local or Remote to start the Gateway.")
|
||||||
@@ -217,7 +192,6 @@ struct GeneralSettings: View {
|
|||||||
self.remoteCard
|
self.remoteCard
|
||||||
}
|
}
|
||||||
|
|
||||||
self.cliInstaller
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,59 +320,6 @@ struct GeneralSettings: View {
|
|||||||
return message == self.controlStatusLine
|
return message == self.controlStatusLine
|
||||||
}
|
}
|
||||||
|
|
||||||
private var cliInstaller: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Button {
|
|
||||||
Task { await self.installCLI() }
|
|
||||||
} label: {
|
|
||||||
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI"
|
|
||||||
ZStack {
|
|
||||||
Text(title)
|
|
||||||
.opacity(self.isInstallingCLI ? 0 : 1)
|
|
||||||
if self.isInstallingCLI {
|
|
||||||
ProgressView()
|
|
||||||
.controlSize(.mini)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(minWidth: 150)
|
|
||||||
}
|
|
||||||
.disabled(self.isInstallingCLI)
|
|
||||||
|
|
||||||
if self.isInstallingCLI {
|
|
||||||
Text("Working...")
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else if self.cliInstalled {
|
|
||||||
Label("Installed", systemImage: "checkmark.circle.fill")
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
Text("Not installed")
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let status = cliStatus {
|
|
||||||
Text(status)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(2)
|
|
||||||
} else if let installLocation = self.cliInstallLocation {
|
|
||||||
Text("Found at \(installLocation)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(2)
|
|
||||||
} else {
|
|
||||||
Text("Installs a user-space Node 22+ runtime and the CLI (no Homebrew).")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var gatewayInstallerCard: some View {
|
private var gatewayInstallerCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
@@ -454,22 +375,6 @@ struct GeneralSettings: View {
|
|||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func installCLI() async {
|
|
||||||
guard !self.isInstallingCLI else { return }
|
|
||||||
self.isInstallingCLI = true
|
|
||||||
defer { isInstallingCLI = false }
|
|
||||||
await CLIInstaller.install { status in
|
|
||||||
self.cliStatus = status
|
|
||||||
self.refreshCLIStatus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshCLIStatus() {
|
|
||||||
let installLocation = CLIInstaller.installedLocation()
|
|
||||||
self.cliInstallLocation = installLocation
|
|
||||||
self.cliInstalled = installLocation != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshGatewayStatus() {
|
private func refreshGatewayStatus() {
|
||||||
Task {
|
Task {
|
||||||
let status = await Task.detached(priority: .utility) {
|
let status = await Task.detached(priority: .utility) {
|
||||||
@@ -763,9 +668,6 @@ extension GeneralSettings {
|
|||||||
message: "Gateway ready")
|
message: "Gateway ready")
|
||||||
view.remoteStatus = .failed("SSH failed")
|
view.remoteStatus = .failed("SSH failed")
|
||||||
view.showRemoteAdvanced = true
|
view.showRemoteAdvanced = true
|
||||||
view.cliInstalled = true
|
|
||||||
view.cliInstallLocation = "/usr/local/bin/clawdbot"
|
|
||||||
view.cliStatus = "Installed"
|
|
||||||
_ = view.body
|
_ = view.body
|
||||||
|
|
||||||
state.connectionMode = .unconfigured
|
state.connectionMode = .unconfigured
|
||||||
|
|||||||
@@ -2,14 +2,28 @@ import Foundation
|
|||||||
import JavaScriptCore
|
import JavaScriptCore
|
||||||
|
|
||||||
enum ModelCatalogLoader {
|
enum ModelCatalogLoader {
|
||||||
static let defaultPath: String = FileManager().homeDirectoryForCurrentUser
|
static var defaultPath: String { self.resolveDefaultPath() }
|
||||||
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
|
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
|
||||||
|
private nonisolated static let appSupportDir: URL = {
|
||||||
|
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
|
return base.appendingPathComponent("Clawdbot", isDirectory: true)
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static var cachePath: URL {
|
||||||
|
self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
static func load(from path: String) async throws -> [ModelChoice] {
|
static func load(from path: String) async throws -> [ModelChoice] {
|
||||||
let expanded = (path as NSString).expandingTildeInPath
|
let expanded = (path as NSString).expandingTildeInPath
|
||||||
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: expanded).lastPathComponent)")
|
guard let resolved = self.resolvePath(preferred: expanded) else {
|
||||||
let source = try String(contentsOfFile: expanded, encoding: .utf8)
|
self.logger.error("model catalog load failed: file not found")
|
||||||
|
throw NSError(
|
||||||
|
domain: "ModelCatalogLoader",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"])
|
||||||
|
}
|
||||||
|
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)")
|
||||||
|
let source = try String(contentsOfFile: resolved.path, encoding: .utf8)
|
||||||
let sanitized = self.sanitize(source: source)
|
let sanitized = self.sanitize(source: source)
|
||||||
|
|
||||||
let ctx = JSContext()
|
let ctx = JSContext()
|
||||||
@@ -45,9 +59,82 @@ enum ModelCatalogLoader {
|
|||||||
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
|
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
|
||||||
}
|
}
|
||||||
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
|
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
|
||||||
|
if resolved.shouldCache {
|
||||||
|
self.cacheCatalog(sourcePath: resolved.path)
|
||||||
|
}
|
||||||
return sorted
|
return sorted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func resolveDefaultPath() -> String {
|
||||||
|
let cache = self.cachePath.path
|
||||||
|
if FileManager().isReadableFile(atPath: cache) { return cache }
|
||||||
|
if let bundlePath = self.bundleCatalogPath() { return bundlePath }
|
||||||
|
if let nodePath = self.nodeModulesCatalogPath() { return nodePath }
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? {
|
||||||
|
if FileManager().isReadableFile(atPath: preferred) {
|
||||||
|
return (preferred, preferred != self.cachePath.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred {
|
||||||
|
self.logger.warning("model catalog path missing; falling back to bundled catalog")
|
||||||
|
return (bundlePath, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache = self.cachePath.path
|
||||||
|
if cache != preferred, FileManager().isReadableFile(atPath: cache) {
|
||||||
|
self.logger.warning("model catalog path missing; falling back to cached catalog")
|
||||||
|
return (cache, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred {
|
||||||
|
self.logger.warning("model catalog path missing; falling back to node_modules catalog")
|
||||||
|
return (nodePath, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func bundleCatalogPath() -> String? {
|
||||||
|
guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return url.path
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nodeModulesCatalogPath() -> String? {
|
||||||
|
let roots = [
|
||||||
|
URL(fileURLWithPath: CommandResolver.projectRootPath()),
|
||||||
|
URL(fileURLWithPath: FileManager().currentDirectoryPath),
|
||||||
|
]
|
||||||
|
for root in roots {
|
||||||
|
let candidate = root
|
||||||
|
.appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js")
|
||||||
|
if FileManager().isReadableFile(atPath: candidate.path) {
|
||||||
|
return candidate.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func cacheCatalog(sourcePath: String) {
|
||||||
|
let destination = self.cachePath
|
||||||
|
do {
|
||||||
|
try FileManager().createDirectory(
|
||||||
|
at: destination.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
if FileManager().fileExists(atPath: destination.path) {
|
||||||
|
try FileManager().removeItem(at: destination)
|
||||||
|
}
|
||||||
|
try FileManager().copyItem(atPath: sourcePath, toPath: destination.path)
|
||||||
|
self.logger.debug("model catalog cached file=\(destination.lastPathComponent)")
|
||||||
|
} catch {
|
||||||
|
self.logger.warning("model catalog cache failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func sanitize(source: String) -> String {
|
private static func sanitize(source: String) -> String {
|
||||||
guard let exportRange = source.range(of: "export const MODELS"),
|
guard let exportRange = source.range(of: "export const MODELS"),
|
||||||
let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"),
|
let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"),
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ struct PermissionsSettings: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
SystemRunSettingsView()
|
||||||
|
|
||||||
Text("Allow these so Clawdbot can notify and capture when needed.")
|
Text("Allow these so Clawdbot can notify and capture when needed.")
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
|
|
||||||
@@ -46,24 +48,6 @@ struct PermissionStatusList: View {
|
|||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
.help("Refresh status")
|
.help("Refresh status")
|
||||||
|
|
||||||
if (self.status[.accessibility] ?? false) == false || (self.status[.screenRecording] ?? false) == false {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(
|
|
||||||
"Note: macOS may require restarting Clawdbot after enabling Accessibility or Screen Recording.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
LaunchdManager.startClawdbot()
|
|
||||||
} label: {
|
|
||||||
Label("Restart Clawdbot", systemImage: "arrow.counterclockwise")
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ final class RemotePortTunnel {
|
|||||||
}
|
}
|
||||||
var args: [String] = [
|
var args: [String] = [
|
||||||
"-o", "BatchMode=yes",
|
"-o", "BatchMode=yes",
|
||||||
"-o", "IdentitiesOnly=yes",
|
|
||||||
"-o", "ExitOnForwardFailure=yes",
|
"-o", "ExitOnForwardFailure=yes",
|
||||||
"-o", "StrictHostKeyChecking=accept-new",
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "UpdateHostKeys=yes",
|
"-o", "UpdateHostKeys=yes",
|
||||||
@@ -84,7 +83,12 @@ final class RemotePortTunnel {
|
|||||||
]
|
]
|
||||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !identity.isEmpty { args.append(contentsOf: ["-i", identity]) }
|
if !identity.isEmpty {
|
||||||
|
// Only use IdentitiesOnly when an explicit identity file is provided.
|
||||||
|
// This allows 1Password SSH agent and other SSH agents to provide keys.
|
||||||
|
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
|
||||||
|
args.append(contentsOf: ["-i", identity])
|
||||||
|
}
|
||||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||||
args.append(userHost)
|
args.append(userHost)
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,19 @@ Available actions:
|
|||||||
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
||||||
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`)
|
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`)
|
||||||
|
|
||||||
|
### Message IDs (short vs full)
|
||||||
|
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
|
||||||
|
- `MessageSid` / `ReplyToId` can be short IDs.
|
||||||
|
- `MessageSidFull` / `ReplyToIdFull` contain the provider full IDs.
|
||||||
|
- Short IDs are in-memory; they can expire on restart or cache eviction.
|
||||||
|
- Actions accept short or full `messageId`, but short IDs will error if no longer available.
|
||||||
|
|
||||||
|
Use full IDs for durable automations and storage:
|
||||||
|
- Templates: `{{MessageSidFull}}`, `{{ReplyToIdFull}}`
|
||||||
|
- Context: `MessageSidFull` / `ReplyToIdFull` in inbound payloads
|
||||||
|
|
||||||
|
See [Configuration](/gateway/configuration) for template variables.
|
||||||
|
|
||||||
## Block streaming
|
## Block streaming
|
||||||
Control whether responses are sent as a single message or streamed in blocks:
|
Control whether responses are sent as a single message or streamed in blocks:
|
||||||
```json5
|
```json5
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ Notes:
|
|||||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
|
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
|
||||||
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
|
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
|
||||||
- If `channels` is present, any channel not listed is denied by default.
|
- If `channels` is present, any channel not listed is denied by default.
|
||||||
|
- Use a `"*"` channel entry to apply defaults across all channels; explicit channel entries override the wildcard.
|
||||||
- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread channel id explicitly.
|
- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread channel id explicitly.
|
||||||
- Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered).
|
- Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered).
|
||||||
- Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
|
- Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
|
||||||
|
|||||||
@@ -484,6 +484,10 @@ The agent sees reactions as **system notifications** in the conversation history
|
|||||||
- Make sure your Telegram user ID is authorized (via pairing or `channels.telegram.allowFrom`)
|
- Make sure your Telegram user ID is authorized (via pairing or `channels.telegram.allowFrom`)
|
||||||
- Commands require authorization even in groups with `groupPolicy: "open"`
|
- Commands require authorization even in groups with `groupPolicy: "open"`
|
||||||
|
|
||||||
|
**Long-polling aborts immediately on Node 22+ (often with proxies/custom fetch):**
|
||||||
|
- Node 22+ is stricter about `AbortSignal` instances; foreign signals can abort `fetch` calls right away.
|
||||||
|
- Upgrade to a Clawdbot build that normalizes abort signals, or run the gateway on Node 20 until you can upgrade.
|
||||||
|
|
||||||
**Bot starts, then silently stops responding (or logs `HttpError: Network request ... failed`):**
|
**Bot starts, then silently stops responding (or logs `HttpError: Network request ... failed`):**
|
||||||
- Some hosts resolve `api.telegram.org` to IPv6 first. If your server does not have working IPv6 egress, grammY can get stuck on IPv6-only requests.
|
- Some hosts resolve `api.telegram.org` to IPv6 first. If your server does not have working IPv6 egress, grammY can get stuck on IPv6-only requests.
|
||||||
- Fix by enabling IPv6 egress **or** forcing IPv4 resolution for `api.telegram.org` (for example, add an `/etc/hosts` entry using the IPv4 A record, or prefer IPv4 in your OS DNS stack), then restart the gateway.
|
- Fix by enabling IPv6 egress **or** forcing IPv4 resolution for `api.telegram.org` (for example, add an `/etc/hosts` entry using the IPv4 A record, or prefer IPv4 in your OS DNS stack), then restart the gateway.
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "CLI reference for `clawdbot daemon` (install/uninstall/status for the Gateway service)"
|
|
||||||
read_when:
|
|
||||||
- You want to run the Gateway as a background service
|
|
||||||
- You’re debugging daemon install, status, or logs
|
|
||||||
---
|
|
||||||
|
|
||||||
# `clawdbot daemon`
|
|
||||||
|
|
||||||
Manage the Gateway daemon (background service).
|
|
||||||
|
|
||||||
Note: `clawdbot daemon …` is the preferred surface for Gateway service management; `daemon` remains
|
|
||||||
as a legacy alias for compatibility.
|
|
||||||
|
|
||||||
Related:
|
|
||||||
- Gateway CLI: [Gateway](/cli/gateway)
|
|
||||||
- macOS platform notes: [macOS](/platforms/macos)
|
|
||||||
|
|
||||||
Tip: run `clawdbot daemon --help` for platform-specific flags.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `daemon status` supports `--json` for scripting.
|
|
||||||
- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
|
|
||||||
+42
-5
@@ -25,6 +25,12 @@ Run a local Gateway process:
|
|||||||
clawdbot gateway
|
clawdbot gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Foreground alias:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot gateway run
|
||||||
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
|
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
|
||||||
- Binding beyond loopback without auth is blocked (safety guardrail).
|
- Binding beyond loopback without auth is blocked (safety guardrail).
|
||||||
@@ -34,7 +40,7 @@ Notes:
|
|||||||
### Options
|
### Options
|
||||||
|
|
||||||
- `--port <port>`: WebSocket port (default comes from config/env; usually `18789`).
|
- `--port <port>`: WebSocket port (default comes from config/env; usually `18789`).
|
||||||
- `--bind <loopback|lan|tailnet|auto>`: listener bind mode.
|
- `--bind <loopback|lan|tailnet|auto|custom>`: listener bind mode.
|
||||||
- `--auth <token|password>`: auth mode override.
|
- `--auth <token|password>`: auth mode override.
|
||||||
- `--token <token>`: token override (also sets `CLAWDBOT_GATEWAY_TOKEN` for the process).
|
- `--token <token>`: token override (also sets `CLAWDBOT_GATEWAY_TOKEN` for the process).
|
||||||
- `--password <password>`: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process).
|
- `--password <password>`: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process).
|
||||||
@@ -75,15 +81,32 @@ clawdbot gateway health --url ws://127.0.0.1:18789
|
|||||||
|
|
||||||
### `gateway status`
|
### `gateway status`
|
||||||
|
|
||||||
`gateway status` is the “debug everything” command. It always probes:
|
`gateway status` shows the Gateway service (launchd/systemd/schtasks) plus an optional RPC probe.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot gateway status
|
||||||
|
clawdbot gateway status --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--url <url>`: override the probe URL.
|
||||||
|
- `--token <token>`: token auth for the probe.
|
||||||
|
- `--password <password>`: password auth for the probe.
|
||||||
|
- `--timeout <ms>`: probe timeout (default `10000`).
|
||||||
|
- `--no-probe`: skip the RPC probe (service-only view).
|
||||||
|
- `--deep`: scan system-level services too.
|
||||||
|
|
||||||
|
### `gateway probe`
|
||||||
|
|
||||||
|
`gateway probe` is the “debug everything” command. It always probes:
|
||||||
- your configured remote gateway (if set), and
|
- your configured remote gateway (if set), and
|
||||||
- localhost (loopback) **even if remote is configured**.
|
- localhost (loopback) **even if remote is configured**.
|
||||||
|
|
||||||
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway.
|
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot gateway status
|
clawdbot gateway probe
|
||||||
clawdbot gateway status --json
|
clawdbot gateway probe --json
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Remote over SSH (Mac app parity)
|
#### Remote over SSH (Mac app parity)
|
||||||
@@ -93,7 +116,7 @@ The macOS app “Remote over SSH” mode uses a local port-forward so the remote
|
|||||||
CLI equivalent:
|
CLI equivalent:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot gateway status --ssh user@gateway-host
|
clawdbot gateway probe --ssh user@gateway-host
|
||||||
```
|
```
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
@@ -114,6 +137,20 @@ clawdbot gateway call status
|
|||||||
clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
|
clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Manage the Gateway service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot gateway install
|
||||||
|
clawdbot gateway start
|
||||||
|
clawdbot gateway stop
|
||||||
|
clawdbot gateway restart
|
||||||
|
clawdbot gateway uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `gateway install` supports `--port`, `--runtime`, `--token`, `--force`, `--json`.
|
||||||
|
- Lifecycle commands accept `--json` for scripting.
|
||||||
|
|
||||||
## Discover gateways (Bonjour)
|
## Discover gateways (Bonjour)
|
||||||
|
|
||||||
`gateway discover` scans for Gateway beacons (`_clawdbot-gw._tcp`).
|
`gateway discover` scans for Gateway beacons (`_clawdbot-gw._tcp`).
|
||||||
|
|||||||
+28
-27
@@ -28,7 +28,6 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`health`](/cli/health)
|
- [`health`](/cli/health)
|
||||||
- [`sessions`](/cli/sessions)
|
- [`sessions`](/cli/sessions)
|
||||||
- [`gateway`](/cli/gateway)
|
- [`gateway`](/cli/gateway)
|
||||||
- [`daemon`](/cli/daemon)
|
|
||||||
- [`logs`](/cli/logs)
|
- [`logs`](/cli/logs)
|
||||||
- [`models`](/cli/models)
|
- [`models`](/cli/models)
|
||||||
- [`memory`](/cli/memory)
|
- [`memory`](/cli/memory)
|
||||||
@@ -137,14 +136,14 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
call
|
call
|
||||||
health
|
health
|
||||||
status
|
status
|
||||||
|
probe
|
||||||
discover
|
discover
|
||||||
daemon
|
|
||||||
status
|
|
||||||
install
|
install
|
||||||
uninstall
|
uninstall
|
||||||
start
|
start
|
||||||
stop
|
stop
|
||||||
restart
|
restart
|
||||||
|
run
|
||||||
logs
|
logs
|
||||||
models
|
models
|
||||||
list
|
list
|
||||||
@@ -175,14 +174,13 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
nodes
|
nodes
|
||||||
devices
|
devices
|
||||||
node
|
node
|
||||||
|
run
|
||||||
|
status
|
||||||
|
install
|
||||||
|
uninstall
|
||||||
start
|
start
|
||||||
daemon
|
stop
|
||||||
status
|
restart
|
||||||
install
|
|
||||||
uninstall
|
|
||||||
start
|
|
||||||
stop
|
|
||||||
restart
|
|
||||||
approvals
|
approvals
|
||||||
get
|
get
|
||||||
set
|
set
|
||||||
@@ -312,7 +310,7 @@ Options:
|
|||||||
- `--minimax-api-key <key>`
|
- `--minimax-api-key <key>`
|
||||||
- `--opencode-zen-api-key <key>`
|
- `--opencode-zen-api-key <key>`
|
||||||
- `--gateway-port <port>`
|
- `--gateway-port <port>`
|
||||||
- `--gateway-bind <loopback|lan|tailnet|auto>`
|
- `--gateway-bind <loopback|lan|tailnet|auto|custom>`
|
||||||
- `--gateway-auth <off|token|password>`
|
- `--gateway-auth <off|token|password>`
|
||||||
- `--gateway-token <token>`
|
- `--gateway-token <token>`
|
||||||
- `--gateway-password <password>`
|
- `--gateway-password <password>`
|
||||||
@@ -598,7 +596,7 @@ Run the WebSocket Gateway.
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
- `--port <port>`
|
- `--port <port>`
|
||||||
- `--bind <loopback|tailnet|lan|auto>`
|
- `--bind <loopback|tailnet|lan|auto|custom>`
|
||||||
- `--token <token>`
|
- `--token <token>`
|
||||||
- `--auth <token|password>`
|
- `--auth <token|password>`
|
||||||
- `--password <password>`
|
- `--password <password>`
|
||||||
@@ -615,25 +613,25 @@ Options:
|
|||||||
- `--raw-stream`
|
- `--raw-stream`
|
||||||
- `--raw-stream-path <path>`
|
- `--raw-stream-path <path>`
|
||||||
|
|
||||||
### `daemon`
|
### `gateway service`
|
||||||
Manage the Gateway service (launchd/systemd/schtasks).
|
Manage the Gateway service (launchd/systemd/schtasks).
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
- `daemon status` (probes the Gateway RPC by default)
|
- `gateway status` (probes the Gateway RPC by default)
|
||||||
- `daemon install` (service install)
|
- `gateway install` (service install)
|
||||||
- `daemon uninstall`
|
- `gateway uninstall`
|
||||||
- `daemon start`
|
- `gateway start`
|
||||||
- `daemon stop`
|
- `gateway stop`
|
||||||
- `daemon restart`
|
- `gateway restart`
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `daemon status` probes the Gateway RPC by default using the daemon’s resolved port/config (override with `--url/--token/--password`).
|
- `gateway status` probes the Gateway RPC by default using the service’s resolved port/config (override with `--url/--token/--password`).
|
||||||
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
|
- `gateway status` supports `--no-probe`, `--deep`, and `--json` for scripting.
|
||||||
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra".
|
- `gateway status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra".
|
||||||
- `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL.
|
- `gateway status` prints which config path the CLI uses vs which config the service likely uses (service env), plus the resolved probe target URL.
|
||||||
- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
|
- `gateway install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
|
||||||
- `daemon install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).
|
- `gateway install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).
|
||||||
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`.
|
- `gateway install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`.
|
||||||
|
|
||||||
### `logs`
|
### `logs`
|
||||||
Tail Gateway file logs via RPC.
|
Tail Gateway file logs via RPC.
|
||||||
@@ -652,13 +650,16 @@ clawdbot logs --no-color
|
|||||||
```
|
```
|
||||||
|
|
||||||
### `gateway <subcommand>`
|
### `gateway <subcommand>`
|
||||||
Gateway RPC helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for each).
|
Gateway CLI helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for RPC subcommands).
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
- `gateway call <method> [--params <json>]`
|
- `gateway call <method> [--params <json>]`
|
||||||
- `gateway health`
|
- `gateway health`
|
||||||
- `gateway status`
|
- `gateway status`
|
||||||
|
- `gateway probe`
|
||||||
- `gateway discover`
|
- `gateway discover`
|
||||||
|
- `gateway install|uninstall|start|stop|restart`
|
||||||
|
- `gateway run`
|
||||||
|
|
||||||
Common RPCs:
|
Common RPCs:
|
||||||
- `config.apply` (validate + write config + restart + wake)
|
- `config.apply` (validate + write config + restart + wake)
|
||||||
|
|||||||
@@ -36,4 +36,19 @@ filter to nodes that connected within a duration (e.g. `24h`, `7d`).
|
|||||||
```bash
|
```bash
|
||||||
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
|
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
|
||||||
clawdbot nodes run --node <id|name|ip> <command...>
|
clawdbot nodes run --node <id|name|ip> <command...>
|
||||||
|
clawdbot nodes run --raw "git status"
|
||||||
|
clawdbot nodes run --agent main --node <id|name|ip> --raw "git status"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Exec-style defaults
|
||||||
|
|
||||||
|
`nodes run` mirrors the model’s exec behavior (defaults + approvals):
|
||||||
|
|
||||||
|
- Reads `tools.exec.*` (plus `agents.list[].tools.exec.*` overrides).
|
||||||
|
- Uses exec approvals (`exec.approval.request`) before invoking `system.run`.
|
||||||
|
- `--node` can be omitted when `tools.exec.node` is set.
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
- `--raw <command>`: run a shell string (`/bin/sh -lc` or `cmd.exe /c`).
|
||||||
|
- `--agent <id>`: agent-scoped approvals/allowlists (defaults to configured agent).
|
||||||
|
- `--ask <off|on-miss|always>`, `--security <deny|allowlist|full>`: overrides.
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
summary: "CLI reference for `clawdbot update` (safe-ish source update + optional daemon restart)"
|
summary: "CLI reference for `clawdbot update` (safe-ish source update + optional gateway restart)"
|
||||||
read_when:
|
read_when:
|
||||||
- You want to update a source checkout safely
|
- You want to update a source checkout safely
|
||||||
- You need to understand `--update` shorthand behavior
|
- You need to understand `--update` shorthand behavior
|
||||||
@@ -26,7 +26,7 @@ clawdbot --update
|
|||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
- `--restart`: restart the Gateway daemon after a successful update.
|
- `--restart`: restart the Gateway service after a successful update.
|
||||||
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
|
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
|
||||||
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
||||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||||
|
|||||||
@@ -9,8 +9,22 @@ read_when:
|
|||||||
Session pruning trims **old tool results** from the in-memory context right before each LLM call. It does **not** rewrite the on-disk session history (`*.jsonl`).
|
Session pruning trims **old tool results** from the in-memory context right before each LLM call. It does **not** rewrite the on-disk session history (`*.jsonl`).
|
||||||
|
|
||||||
## When it runs
|
## When it runs
|
||||||
- Before each LLM request (context hook).
|
- When `mode: "cache-ttl"` is enabled and the last Anthropic call for the session is older than `ttl`.
|
||||||
- Only affects the messages sent to the model for that request.
|
- Only affects the messages sent to the model for that request.
|
||||||
|
- Only active for Anthropic API calls (and OpenRouter Anthropic models).
|
||||||
|
- For best results, match `ttl` to your model `cacheControlTtl`.
|
||||||
|
- After a prune, the TTL window resets so subsequent requests keep cache until `ttl` expires again.
|
||||||
|
|
||||||
|
## Smart defaults (Anthropic)
|
||||||
|
- **OAuth or setup-token** profiles: enable `cache-ttl` pruning and set heartbeat to `1h`.
|
||||||
|
- **API key** profiles: enable `cache-ttl` pruning, set heartbeat to `30m`, and default `cacheControlTtl` to `1h` on Anthropic models.
|
||||||
|
- If you set any of these values explicitly, Clawdbot does **not** override them.
|
||||||
|
|
||||||
|
## What this improves (cost + cache behavior)
|
||||||
|
- **Why prune:** Anthropic prompt caching only applies within the TTL. If a session goes idle past the TTL, the next request re-caches the full prompt unless you trim it first.
|
||||||
|
- **What gets cheaper:** pruning reduces the **cacheWrite** size for that first request after the TTL expires.
|
||||||
|
- **Why the TTL reset matters:** once pruning runs, the cache window resets, so follow‑up requests can reuse the freshly cached prompt instead of re-caching the full history again.
|
||||||
|
- **What it does not do:** pruning doesn’t add tokens or “double” costs; it only changes what gets cached on that first post‑TTL request.
|
||||||
|
|
||||||
## What can be pruned
|
## What can be pruned
|
||||||
- Only `toolResult` messages.
|
- Only `toolResult` messages.
|
||||||
@@ -26,14 +40,10 @@ Pruning uses an estimated context window (chars ≈ tokens × 4). The window siz
|
|||||||
3) `agents.defaults.contextTokens`.
|
3) `agents.defaults.contextTokens`.
|
||||||
4) Default `200000` tokens.
|
4) Default `200000` tokens.
|
||||||
|
|
||||||
## Modes
|
## Mode
|
||||||
### adaptive
|
### cache-ttl
|
||||||
- If estimated context ratio ≥ `softTrimRatio`: soft-trim oversized tool results.
|
- Pruning only runs if the last Anthropic call is older than `ttl` (default `5m`).
|
||||||
- If still ≥ `hardClearRatio` **and** prunable tool text ≥ `minPrunableToolChars`: hard-clear oldest eligible tool results.
|
- When it runs: same soft-trim + hard-clear behavior as before.
|
||||||
|
|
||||||
### aggressive
|
|
||||||
- Always hard-clears eligible tool results before the cutoff.
|
|
||||||
- Ignores `hardClear.enabled` (always clears when eligible).
|
|
||||||
|
|
||||||
## Soft vs hard pruning
|
## Soft vs hard pruning
|
||||||
- **Soft-trim**: only for oversized tool results.
|
- **Soft-trim**: only for oversized tool results.
|
||||||
@@ -52,6 +62,7 @@ Pruning uses an estimated context window (chars ≈ tokens × 4). The window siz
|
|||||||
- Compaction is separate: compaction summarizes and persists, pruning is transient per request. See [/concepts/compaction](/concepts/compaction).
|
- Compaction is separate: compaction summarizes and persists, pruning is transient per request. See [/concepts/compaction](/concepts/compaction).
|
||||||
|
|
||||||
## Defaults (when enabled)
|
## Defaults (when enabled)
|
||||||
|
- `ttl`: `"5m"`
|
||||||
- `keepLastAssistants`: `3`
|
- `keepLastAssistants`: `3`
|
||||||
- `softTrimRatio`: `0.3`
|
- `softTrimRatio`: `0.3`
|
||||||
- `hardClearRatio`: `0.5`
|
- `hardClearRatio`: `0.5`
|
||||||
@@ -60,16 +71,7 @@ Pruning uses an estimated context window (chars ≈ tokens × 4). The window siz
|
|||||||
- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }`
|
- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }`
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
Default (adaptive):
|
Default (off):
|
||||||
```json5
|
|
||||||
{
|
|
||||||
agent: {
|
|
||||||
contextPruning: { mode: "adaptive" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To disable:
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agent: {
|
agent: {
|
||||||
@@ -78,11 +80,11 @@ To disable:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Aggressive:
|
Enable TTL-aware pruning:
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agent: {
|
agent: {
|
||||||
contextPruning: { mode: "aggressive" }
|
contextPruning: { mode: "cache-ttl", ttl: "5m" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -92,7 +94,7 @@ Restrict pruning to specific tools:
|
|||||||
{
|
{
|
||||||
agent: {
|
agent: {
|
||||||
contextPruning: {
|
contextPruning: {
|
||||||
mode: "adaptive",
|
mode: "cache-ttl",
|
||||||
tools: { allow: ["exec", "read"], deny: ["*image*"] }
|
tools: { allow: ["exec", "read"], deny: ["*image*"] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
|||||||
- Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
|
- Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
|
||||||
- Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility.
|
- Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility.
|
||||||
- Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
|
- Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
|
||||||
|
- Per-channel overrides (optional): `resetByChannel` overrides the reset policy for a channel (applies to all session types for that channel and takes precedence over `reset`/`resetByType`).
|
||||||
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. `/new <model>` accepts a model alias, `provider/model`, or provider name (fuzzy match) to set the new session model. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
|
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. `/new <model>` accepts a model alias, `provider/model`, or provider name (fuzzy match) to set the new session model. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
|
||||||
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
||||||
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
|
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
|
||||||
@@ -109,6 +110,9 @@ Send these as standalone messages so they register.
|
|||||||
dm: { mode: "idle", idleMinutes: 240 },
|
dm: { mode: "idle", idleMinutes: 240 },
|
||||||
group: { mode: "idle", idleMinutes: 120 }
|
group: { mode: "idle", idleMinutes: 120 }
|
||||||
},
|
},
|
||||||
|
resetByChannel: {
|
||||||
|
discord: { mode: "idle", idleMinutes: 10080 }
|
||||||
|
},
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
||||||
mainKey: "main",
|
mainKey: "main",
|
||||||
|
|||||||
+1
-1
@@ -100,7 +100,7 @@ CLAWDBOT_PROFILE=dev clawdbot gateway --dev --reset
|
|||||||
Tip: if a non‑dev gateway is already running (launchd/systemd), stop it first:
|
Tip: if a non‑dev gateway is already running (launchd/systemd), stop it first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot daemon stop
|
clawdbot gateway stop
|
||||||
```
|
```
|
||||||
|
|
||||||
## Raw stream logging (Clawdbot)
|
## Raw stream logging (Clawdbot)
|
||||||
|
|||||||
@@ -829,8 +829,6 @@
|
|||||||
"cli/nodes",
|
"cli/nodes",
|
||||||
"cli/approvals",
|
"cli/approvals",
|
||||||
"cli/gateway",
|
"cli/gateway",
|
||||||
"cli/daemon",
|
|
||||||
"cli/service",
|
|
||||||
"cli/tui",
|
"cli/tui",
|
||||||
"cli/voicecall",
|
"cli/voicecall",
|
||||||
"cli/wake",
|
"cli/wake",
|
||||||
|
|||||||
@@ -151,7 +151,9 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
|||||||
atHour: 4,
|
atHour: 4,
|
||||||
idleMinutes: 60
|
idleMinutes: 60
|
||||||
},
|
},
|
||||||
heartbeatIdleMinutes: 120,
|
resetByChannel: {
|
||||||
|
discord: { mode: "idle", idleMinutes: 10080 }
|
||||||
|
},
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
store: "~/.clawdbot/agents/default/sessions/sessions.json",
|
store: "~/.clawdbot/agents/default/sessions/sessions.json",
|
||||||
typingIntervalSeconds: 5,
|
typingIntervalSeconds: 5,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Unknown keys, malformed types, or invalid values cause the Gateway to **refuse t
|
|||||||
|
|
||||||
When validation fails:
|
When validation fails:
|
||||||
- The Gateway does not boot.
|
- The Gateway does not boot.
|
||||||
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot daemon`, `clawdbot help`).
|
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot gateway status`, `clawdbot gateway probe`, `clawdbot help`).
|
||||||
- Run `clawdbot doctor` to see the exact issues.
|
- Run `clawdbot doctor` to see the exact issues.
|
||||||
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs.
|
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs.
|
||||||
|
|
||||||
@@ -1414,7 +1414,7 @@ Each `agents.defaults.models` entry can include:
|
|||||||
- `alias` (optional model shortcut, e.g. `/opus`).
|
- `alias` (optional model shortcut, e.g. `/opus`).
|
||||||
- `params` (optional provider-specific API params passed through to the model request).
|
- `params` (optional provider-specific API params passed through to the model request).
|
||||||
|
|
||||||
`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`, `cacheControlTtl` (`"5m"` or `"1h"`, Anthropic API + OpenRouter Anthropic models only; ignored for Anthropic OAuth/Claude Code tokens). These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change. Anthropic API defaults to `"1h"` unless you override (`cacheControlTtl: "5m"`). Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API; keep it if you override provider headers.
|
`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`, `cacheControlTtl` (`"5m"` or `"1h"`, Anthropic API + OpenRouter Anthropic models only; ignored for Anthropic OAuth/Claude Code tokens). These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change. Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API; keep it if you override provider headers.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -1569,7 +1569,7 @@ Example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `agents.defaults.contextPruning` (tool-result pruning)
|
#### `agents.defaults.contextPruning` (TTL-aware tool-result pruning)
|
||||||
|
|
||||||
`agents.defaults.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM.
|
`agents.defaults.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM.
|
||||||
It does **not** modify the session history on disk (`*.jsonl` remains complete).
|
It does **not** modify the session history on disk (`*.jsonl` remains complete).
|
||||||
@@ -1580,11 +1580,9 @@ High level:
|
|||||||
- Never touches user/assistant messages.
|
- Never touches user/assistant messages.
|
||||||
- Protects the last `keepLastAssistants` assistant messages (no tool results after that point are pruned).
|
- Protects the last `keepLastAssistants` assistant messages (no tool results after that point are pruned).
|
||||||
- Protects the bootstrap prefix (nothing before the first user message is pruned).
|
- Protects the bootstrap prefix (nothing before the first user message is pruned).
|
||||||
- Modes:
|
- Mode:
|
||||||
- `adaptive`: soft-trims oversized tool results (keep head/tail) when the estimated context ratio crosses `softTrimRatio`.
|
- `cache-ttl`: pruning only runs when the last Anthropic call for the session is **older** than `ttl`.
|
||||||
Then hard-clears the oldest eligible tool results when the estimated context ratio crosses `hardClearRatio` **and**
|
When it runs, it uses the same soft-trim + hard-clear behavior as before.
|
||||||
there’s enough prunable tool-result bulk (`minPrunableToolChars`).
|
|
||||||
- `aggressive`: always replaces eligible tool results before the cutoff with the `hardClear.placeholder` (no ratio checks).
|
|
||||||
|
|
||||||
Soft vs hard pruning (what changes in the context sent to the LLM):
|
Soft vs hard pruning (what changes in the context sent to the LLM):
|
||||||
- **Soft-trim**: only for *oversized* tool results. Keeps the beginning + end and inserts `...` in the middle.
|
- **Soft-trim**: only for *oversized* tool results. Keeps the beginning + end and inserts `...` in the middle.
|
||||||
@@ -1598,44 +1596,41 @@ Notes / current limitations:
|
|||||||
- Tool results containing **image blocks are skipped** (never trimmed/cleared) right now.
|
- Tool results containing **image blocks are skipped** (never trimmed/cleared) right now.
|
||||||
- The estimated “context ratio” is based on **characters** (approximate), not exact tokens.
|
- The estimated “context ratio” is based on **characters** (approximate), not exact tokens.
|
||||||
- If the session doesn’t contain at least `keepLastAssistants` assistant messages yet, pruning is skipped.
|
- If the session doesn’t contain at least `keepLastAssistants` assistant messages yet, pruning is skipped.
|
||||||
- In `aggressive` mode, `hardClear.enabled` is ignored (eligible tool results are always replaced with `hardClear.placeholder`).
|
- `cache-ttl` only activates for Anthropic API calls (and OpenRouter Anthropic models).
|
||||||
|
- After a prune, the TTL window resets so subsequent requests keep cache until `ttl` expires again.
|
||||||
|
- For best results, match `contextPruning.ttl` to the model `cacheControlTtl` you set in `agents.defaults.models.*.params`.
|
||||||
|
|
||||||
Default (adaptive):
|
Default (off, unless Anthropic auth profiles are detected):
|
||||||
```json5
|
|
||||||
{
|
|
||||||
agents: { defaults: { contextPruning: { mode: "adaptive" } } }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To disable:
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agents: { defaults: { contextPruning: { mode: "off" } } }
|
agents: { defaults: { contextPruning: { mode: "off" } } }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Defaults (when `mode` is `"adaptive"` or `"aggressive"`):
|
Enable TTL-aware pruning:
|
||||||
- `keepLastAssistants`: `3`
|
|
||||||
- `softTrimRatio`: `0.3` (adaptive only)
|
|
||||||
- `hardClearRatio`: `0.5` (adaptive only)
|
|
||||||
- `minPrunableToolChars`: `50000` (adaptive only)
|
|
||||||
- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }` (adaptive only)
|
|
||||||
- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }`
|
|
||||||
|
|
||||||
Example (aggressive, minimal):
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agents: { defaults: { contextPruning: { mode: "aggressive" } } }
|
agents: { defaults: { contextPruning: { mode: "cache-ttl" } } }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example (adaptive tuned):
|
Defaults (when `mode` is `"cache-ttl"`):
|
||||||
|
- `ttl`: `"5m"`
|
||||||
|
- `keepLastAssistants`: `3`
|
||||||
|
- `softTrimRatio`: `0.3`
|
||||||
|
- `hardClearRatio`: `0.5`
|
||||||
|
- `minPrunableToolChars`: `50000`
|
||||||
|
- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }`
|
||||||
|
- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }`
|
||||||
|
|
||||||
|
Example (cache-ttl tuned):
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
contextPruning: {
|
contextPruning: {
|
||||||
mode: "adaptive",
|
mode: "cache-ttl",
|
||||||
|
ttl: "5m",
|
||||||
keepLastAssistants: 3,
|
keepLastAssistants: 3,
|
||||||
softTrimRatio: 0.3,
|
softTrimRatio: 0.3,
|
||||||
hardClearRatio: 0.5,
|
hardClearRatio: 0.5,
|
||||||
@@ -1742,6 +1737,10 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
|||||||
`30m`. Set `0m` to disable.
|
`30m`. Set `0m` to disable.
|
||||||
- `model`: optional override model for heartbeat runs (`provider/model`).
|
- `model`: optional override model for heartbeat runs (`provider/model`).
|
||||||
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
|
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
|
||||||
|
- `activeHours`: optional local-time window that controls when heartbeats run.
|
||||||
|
- `start`: start time (HH:MM, 24h). Inclusive.
|
||||||
|
- `end`: end time (HH:MM, 24h). Exclusive. Use `"24:00"` for end-of-day.
|
||||||
|
- `timezone`: `"user"` (default), `"local"`, or an IANA timezone id.
|
||||||
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
|
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
|
||||||
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
|
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
|
||||||
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read.
|
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read.
|
||||||
@@ -2453,6 +2452,9 @@ Controls session scoping, reset policy, reset triggers, and where the session st
|
|||||||
dm: { mode: "idle", idleMinutes: 240 },
|
dm: { mode: "idle", idleMinutes: 240 },
|
||||||
group: { mode: "idle", idleMinutes: 120 }
|
group: { mode: "idle", idleMinutes: 120 }
|
||||||
},
|
},
|
||||||
|
resetByChannel: {
|
||||||
|
discord: { mode: "idle", idleMinutes: 10080 }
|
||||||
|
},
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
||||||
// You can override with {agentId} templating:
|
// You can override with {agentId} templating:
|
||||||
@@ -2488,7 +2490,7 @@ Fields:
|
|||||||
- `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins.
|
- `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins.
|
||||||
- `resetByType`: per-session overrides for `dm`, `group`, and `thread`.
|
- `resetByType`: per-session overrides for `dm`, `group`, and `thread`.
|
||||||
- If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility.
|
- If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility.
|
||||||
- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled).
|
- `resetByChannel`: channel-specific reset policy overrides (keyed by channel id, applies to all session types for that channel; overrides `reset`/`resetByType`).
|
||||||
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
||||||
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
||||||
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
||||||
@@ -2645,6 +2647,13 @@ Defaults:
|
|||||||
- bind: `loopback`
|
- bind: `loopback`
|
||||||
- port: `18789` (single port for WS + HTTP)
|
- port: `18789` (single port for WS + HTTP)
|
||||||
|
|
||||||
|
Bind modes:
|
||||||
|
- `loopback`: `127.0.0.1` (local-only)
|
||||||
|
- `lan`: `0.0.0.0` (all interfaces)
|
||||||
|
- `tailnet`: Tailscale IPv4 address (100.64.0.0/10)
|
||||||
|
- `auto`: prefer loopback, fall back to LAN if loopback cannot bind
|
||||||
|
- `custom`: `gateway.customBindHost` (IPv4), fallback to LAN if unavailable
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
gateway: {
|
gateway: {
|
||||||
@@ -2675,7 +2684,7 @@ Notes:
|
|||||||
- OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`.
|
- OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`.
|
||||||
- OpenResponses endpoint: **disabled by default**; enable with `gateway.http.endpoints.responses.enabled: true`.
|
- OpenResponses endpoint: **disabled by default**; enable with `gateway.http.endpoints.responses.enabled: true`.
|
||||||
- Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
|
- Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
|
||||||
- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
|
- Non-loopback binds (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
|
||||||
- The onboarding wizard generates a gateway token by default (even on loopback).
|
- The onboarding wizard generates a gateway token by default (even on loopback).
|
||||||
- `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored.
|
- `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored.
|
||||||
|
|
||||||
|
|||||||
@@ -225,10 +225,10 @@ Notes:
|
|||||||
- `clawdbot doctor --yes` accepts the default repair prompts.
|
- `clawdbot doctor --yes` accepts the default repair prompts.
|
||||||
- `clawdbot doctor --repair` applies recommended fixes without prompts.
|
- `clawdbot doctor --repair` applies recommended fixes without prompts.
|
||||||
- `clawdbot doctor --repair --force` overwrites custom supervisor configs.
|
- `clawdbot doctor --repair --force` overwrites custom supervisor configs.
|
||||||
- You can always force a full rewrite via `clawdbot daemon install --force`.
|
- You can always force a full rewrite via `clawdbot gateway install --force`.
|
||||||
|
|
||||||
### 16) Gateway runtime + port diagnostics
|
### 16) Gateway runtime + port diagnostics
|
||||||
Doctor inspects the daemon runtime (PID, last exit status) and warns when the
|
Doctor inspects the service runtime (PID, last exit status) and warns when the
|
||||||
service is installed but not actually running. It also checks for port collisions
|
service is installed but not actually running. It also checks for port collisions
|
||||||
on the gateway port (default `18789`) and reports likely causes (gateway already
|
on the gateway port (default `18789`) and reports likely causes (gateway already
|
||||||
running, SSH tunnel).
|
running, SSH tunnel).
|
||||||
@@ -236,7 +236,7 @@ running, SSH tunnel).
|
|||||||
### 17) Gateway runtime best practices
|
### 17) Gateway runtime best practices
|
||||||
Doctor warns when the gateway service runs on Bun or a version-managed Node path
|
Doctor warns when the gateway service runs on Bun or a version-managed Node path
|
||||||
(`nvm`, `fnm`, `volta`, `asdf`, etc.). WhatsApp + Telegram channels require Node,
|
(`nvm`, `fnm`, `volta`, `asdf`, etc.). WhatsApp + Telegram channels require Node,
|
||||||
and version-manager paths can break after upgrades because the daemon does not
|
and version-manager paths can break after upgrades because the service does not
|
||||||
load your shell init. Doctor offers to migrate to a system Node install when
|
load your shell init. Doctor offers to migrate to a system Node install when
|
||||||
available (Homebrew/apt/choco).
|
available (Homebrew/apt/choco).
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ surface anything that needs attention without spamming you.
|
|||||||
|
|
||||||
## Quick start (beginner)
|
## Quick start (beginner)
|
||||||
|
|
||||||
1. Leave heartbeats enabled (default is `30m`) or set your own cadence.
|
1. Leave heartbeats enabled (default is `30m`, or `1h` for Anthropic OAuth/setup-token) or set your own cadence.
|
||||||
2. Create a tiny `HEARTBEAT.md` checklist in the agent workspace (optional but recommended).
|
2. Create a tiny `HEARTBEAT.md` checklist in the agent workspace (optional but recommended).
|
||||||
3. Decide where heartbeat messages should go (`target: "last"` is the default).
|
3. Decide where heartbeat messages should go (`target: "last"` is the default).
|
||||||
4. Optional: enable heartbeat reasoning delivery for transparency.
|
4. Optional: enable heartbeat reasoning delivery for transparency.
|
||||||
|
5. Optional: restrict heartbeats to active hours (local time).
|
||||||
|
|
||||||
Example config:
|
Example config:
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ Example config:
|
|||||||
heartbeat: {
|
heartbeat: {
|
||||||
every: "30m",
|
every: "30m",
|
||||||
target: "last",
|
target: "last",
|
||||||
|
// activeHours: { start: "08:00", end: "24:00" },
|
||||||
// includeReasoning: true, // optional: send separate `Reasoning:` message too
|
// includeReasoning: true, // optional: send separate `Reasoning:` message too
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,11 +35,13 @@ Example config:
|
|||||||
|
|
||||||
## Defaults
|
## Defaults
|
||||||
|
|
||||||
- Interval: `30m` (set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable).
|
- Interval: `30m` (or `1h` when Anthropic OAuth/setup-token is the detected auth mode). Set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable.
|
||||||
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`):
|
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`):
|
||||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||||
- The heartbeat prompt is sent **verbatim** as the user message. The system
|
- The heartbeat prompt is sent **verbatim** as the user message. The system
|
||||||
prompt includes a “Heartbeat” section and the run is flagged internally.
|
prompt includes a “Heartbeat” section and the run is flagged internally.
|
||||||
|
- Active hours (`heartbeat.activeHours`) are checked in the configured timezone.
|
||||||
|
Outside the window, heartbeats are skipped until the next tick inside the window.
|
||||||
|
|
||||||
## What the heartbeat prompt is for
|
## What the heartbeat prompt is for
|
||||||
|
|
||||||
|
|||||||
+27
-27
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
summary: "Runbook for the Gateway daemon, lifecycle, and operations"
|
summary: "Runbook for the Gateway service, lifecycle, and operations"
|
||||||
read_when:
|
read_when:
|
||||||
- Running or debugging the gateway process
|
- Running or debugging the gateway process
|
||||||
---
|
---
|
||||||
# Gateway (daemon) runbook
|
# Gateway service runbook
|
||||||
|
|
||||||
Last updated: 2025-12-09
|
Last updated: 2025-12-09
|
||||||
|
|
||||||
@@ -101,10 +101,10 @@ Checklist per instance:
|
|||||||
- unique `agents.defaults.workspace`
|
- unique `agents.defaults.workspace`
|
||||||
- separate WhatsApp numbers (if using WA)
|
- separate WhatsApp numbers (if using WA)
|
||||||
|
|
||||||
Daemon install per profile:
|
Service install per profile:
|
||||||
```bash
|
```bash
|
||||||
clawdbot --profile main daemon install
|
clawdbot --profile main gateway install
|
||||||
clawdbot --profile rescue daemon install
|
clawdbot --profile rescue gateway install
|
||||||
```
|
```
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
@@ -175,49 +175,49 @@ See also: [Presence](/concepts/presence) for how presence is produced/deduped an
|
|||||||
- Events are not replayed. Clients detect seq gaps and should refresh (`health` + `system-presence`) before continuing. WebChat and macOS clients now auto-refresh on gap.
|
- Events are not replayed. Clients detect seq gaps and should refresh (`health` + `system-presence`) before continuing. WebChat and macOS clients now auto-refresh on gap.
|
||||||
|
|
||||||
## Supervision (macOS example)
|
## Supervision (macOS example)
|
||||||
- Use launchd to keep the daemon alive:
|
- Use launchd to keep the service alive:
|
||||||
- Program: path to `clawdbot`
|
- Program: path to `clawdbot`
|
||||||
- Arguments: `gateway`
|
- Arguments: `gateway`
|
||||||
- KeepAlive: true
|
- KeepAlive: true
|
||||||
- StandardOut/Err: file paths or `syslog`
|
- StandardOut/Err: file paths or `syslog`
|
||||||
- On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices.
|
- On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices.
|
||||||
- LaunchAgents are per-user and require a logged-in session; for headless setups use a custom LaunchDaemon (not shipped).
|
- LaunchAgents are per-user and require a logged-in session; for headless setups use a custom LaunchDaemon (not shipped).
|
||||||
- `clawdbot daemon install` writes `~/Library/LaunchAgents/com.clawdbot.gateway.plist`
|
- `clawdbot gateway install` writes `~/Library/LaunchAgents/com.clawdbot.gateway.plist`
|
||||||
(or `com.clawdbot.<profile>.plist`).
|
(or `com.clawdbot.<profile>.plist`).
|
||||||
- `clawdbot doctor` audits the LaunchAgent config and can update it to current defaults.
|
- `clawdbot doctor` audits the LaunchAgent config and can update it to current defaults.
|
||||||
|
|
||||||
## Daemon management (CLI)
|
## Gateway service management (CLI)
|
||||||
|
|
||||||
Use the CLI daemon manager for install/start/stop/restart/status:
|
Use the Gateway CLI for install/start/stop/restart/status:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot daemon status
|
clawdbot gateway status
|
||||||
clawdbot daemon install
|
clawdbot gateway install
|
||||||
clawdbot daemon stop
|
clawdbot gateway stop
|
||||||
clawdbot daemon restart
|
clawdbot gateway restart
|
||||||
clawdbot logs --follow
|
clawdbot logs --follow
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `daemon status` probes the Gateway RPC by default using the daemon’s resolved port/config (override with `--url`).
|
- `gateway status` probes the Gateway RPC by default using the service’s resolved port/config (override with `--url`).
|
||||||
- `daemon status --deep` adds system-level scans (LaunchDaemons/system units).
|
- `gateway status --deep` adds system-level scans (LaunchDaemons/system units).
|
||||||
- `daemon status --no-probe` skips the RPC probe (useful when networking is down).
|
- `gateway status --no-probe` skips the RPC probe (useful when networking is down).
|
||||||
- `daemon status --json` is stable for scripts.
|
- `gateway status --json` is stable for scripts.
|
||||||
- `daemon status` reports **supervisor runtime** (launchd/systemd running) separately from **RPC reachability** (WS connect + status RPC).
|
- `gateway status` reports **supervisor runtime** (launchd/systemd running) separately from **RPC reachability** (WS connect + status RPC).
|
||||||
- `daemon status` prints config path + probe target to avoid “localhost vs LAN bind” confusion and profile mismatches.
|
- `gateway status` prints config path + probe target to avoid “localhost vs LAN bind” confusion and profile mismatches.
|
||||||
- `daemon status` includes the last gateway error line when the service looks running but the port is closed.
|
- `gateway status` includes the last gateway error line when the service looks running but the port is closed.
|
||||||
- `logs` tails the Gateway file log via RPC (no manual `tail`/`grep` needed).
|
- `logs` tails the Gateway file log via RPC (no manual `tail`/`grep` needed).
|
||||||
- If other gateway-like services are detected, the CLI warns unless they are Clawdbot profile services.
|
- If other gateway-like services are detected, the CLI warns unless they are Clawdbot profile services.
|
||||||
We still recommend **one gateway per machine** for most setups; use isolated profiles/ports for redundancy or a rescue bot. See [Multiple gateways](/gateway/multiple-gateways).
|
We still recommend **one gateway per machine** for most setups; use isolated profiles/ports for redundancy or a rescue bot. See [Multiple gateways](/gateway/multiple-gateways).
|
||||||
- Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations).
|
- Cleanup: `clawdbot gateway uninstall` (current service) and `clawdbot doctor` (legacy migrations).
|
||||||
- `daemon install` is a no-op when already installed; use `clawdbot daemon install --force` to reinstall (profile/env/path changes).
|
- `gateway install` is a no-op when already installed; use `clawdbot gateway install --force` to reinstall (profile/env/path changes).
|
||||||
|
|
||||||
Bundled mac app:
|
Bundled mac app:
|
||||||
- Clawdbot.app can bundle a Node-based gateway relay and install a per-user LaunchAgent labeled
|
- Clawdbot.app can bundle a Node-based gateway relay and install a per-user LaunchAgent labeled
|
||||||
`com.clawdbot.gateway` (or `com.clawdbot.<profile>`).
|
`com.clawdbot.gateway` (or `com.clawdbot.<profile>`).
|
||||||
- To stop it cleanly, use `clawdbot daemon stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`).
|
- To stop it cleanly, use `clawdbot gateway stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`).
|
||||||
- To restart, use `clawdbot daemon restart` (or `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`).
|
- To restart, use `clawdbot gateway restart` (or `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`).
|
||||||
- `launchctl` only works if the LaunchAgent is installed; otherwise use `clawdbot daemon install` first.
|
- `launchctl` only works if the LaunchAgent is installed; otherwise use `clawdbot gateway install` first.
|
||||||
- Replace the label with `com.clawdbot.<profile>` when running a named profile.
|
- Replace the label with `com.clawdbot.<profile>` when running a named profile.
|
||||||
|
|
||||||
## Supervision (systemd user unit)
|
## Supervision (systemd user unit)
|
||||||
@@ -226,7 +226,7 @@ recommend user services for single-user machines (simpler env, per-user config).
|
|||||||
Use a **system service** for multi-user or always-on servers (no lingering
|
Use a **system service** for multi-user or always-on servers (no lingering
|
||||||
required, shared supervision).
|
required, shared supervision).
|
||||||
|
|
||||||
`clawdbot daemon install` writes the user unit. `clawdbot doctor` audits the
|
`clawdbot gateway install` writes the user unit. `clawdbot doctor` audits the
|
||||||
unit and can update it to match the current recommended defaults.
|
unit and can update it to match the current recommended defaults.
|
||||||
|
|
||||||
Create `~/.config/systemd/user/clawdbot-gateway[-<profile>].service`:
|
Create `~/.config/systemd/user/clawdbot-gateway[-<profile>].service`:
|
||||||
@@ -285,7 +285,7 @@ Windows installs should use **WSL2** and follow the Linux systemd section above.
|
|||||||
- `clawdbot message send --target <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
|
- `clawdbot message send --target <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
|
||||||
- `clawdbot agent --message "hi" --to <num>` — run an agent turn (waits for final by default).
|
- `clawdbot agent --message "hi" --to <num>` — run an agent turn (waits for final by default).
|
||||||
- `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
|
- `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
|
||||||
- `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd).
|
- `clawdbot gateway stop|restart` — stop/restart the supervised gateway service (launchd/systemd).
|
||||||
- Gateway helper subcommands assume a running gateway on `--url`; they no longer auto-spawn one.
|
- Gateway helper subcommands assume a running gateway on `--url`; they no longer auto-spawn one.
|
||||||
|
|
||||||
## Migration guidance
|
## Migration guidance
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ clawdbot --profile rescue setup
|
|||||||
clawdbot --profile rescue gateway --port 19001
|
clawdbot --profile rescue gateway --port 19001
|
||||||
```
|
```
|
||||||
|
|
||||||
Per-profile daemons:
|
Per-profile services:
|
||||||
```bash
|
```bash
|
||||||
clawdbot --profile main daemon install
|
clawdbot --profile main gateway install
|
||||||
clawdbot --profile rescue daemon install
|
clawdbot --profile rescue gateway install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rescue-bot guide
|
## Rescue-bot guide
|
||||||
@@ -55,7 +55,7 @@ Port spacing: leave at least 20 ports between base ports so the derived bridge/b
|
|||||||
# Main bot (existing or fresh, without --profile param)
|
# Main bot (existing or fresh, without --profile param)
|
||||||
# Runs on port 18789 + Chrome CDC/Canvas/... Ports
|
# Runs on port 18789 + Chrome CDC/Canvas/... Ports
|
||||||
clawdbot onboard
|
clawdbot onboard
|
||||||
clawdbot daemon install
|
clawdbot gateway install
|
||||||
|
|
||||||
# Rescue bot (isolated profile + ports)
|
# Rescue bot (isolated profile + ports)
|
||||||
clawdbot --profile rescue onboard
|
clawdbot --profile rescue onboard
|
||||||
@@ -65,8 +65,8 @@ clawdbot --profile rescue onboard
|
|||||||
# better choose completely different base port, like 19789,
|
# better choose completely different base port, like 19789,
|
||||||
# - rest of the onboarding is the same as normal
|
# - rest of the onboarding is the same as normal
|
||||||
|
|
||||||
# To install the daemon (if not happened automatically during onboarding)
|
# To install the service (if not happened automatically during onboarding)
|
||||||
clawdbot --profile rescue daemon install
|
clawdbot --profile rescue gateway install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Port mapping (derived)
|
## Port mapping (derived)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ Guide: [Tailscale](/gateway/tailscale) and [Web overview](/web).
|
|||||||
|
|
||||||
## Command flow (what runs where)
|
## Command flow (what runs where)
|
||||||
|
|
||||||
One gateway daemon owns state + channels. Nodes are peripherals.
|
One gateway service owns state + channels. Nodes are peripherals.
|
||||||
|
|
||||||
Flow example (Telegram → node):
|
Flow example (Telegram → node):
|
||||||
- Telegram message arrives at the **Gateway**.
|
- Telegram message arrives at the **Gateway**.
|
||||||
@@ -59,7 +59,7 @@ Flow example (Telegram → node):
|
|||||||
- Node returns the result; Gateway replies back out to Telegram.
|
- Node returns the result; Gateway replies back out to Telegram.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- **Nodes do not run the gateway daemon.** Only one gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)).
|
- **Nodes do not run the gateway service.** Only one gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)).
|
||||||
- macOS app “node mode” is just a node client over the Bridge.
|
- macOS app “node mode” is just a node client over the Bridge.
|
||||||
|
|
||||||
## SSH tunnel (CLI + tools)
|
## SSH tunnel (CLI + tools)
|
||||||
@@ -112,7 +112,7 @@ Runbook: [macOS remote access](/platforms/mac/remote).
|
|||||||
Short version: **keep the Gateway loopback-only** unless you’re sure you need a bind.
|
Short version: **keep the Gateway loopback-only** unless you’re sure you need a bind.
|
||||||
|
|
||||||
- **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure).
|
- **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure).
|
||||||
- **Non-loopback binds** (`lan`/`tailnet`/`auto`) must use auth tokens/passwords.
|
- **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords.
|
||||||
- `gateway.remote.token` is **only** for remote CLI calls — it does **not** enable local auth.
|
- `gateway.remote.token` is **only** for remote CLI calls — it does **not** enable local auth.
|
||||||
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
|
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
|
||||||
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
|
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ The Gateway multiplexes **WebSocket + HTTP** on a single port:
|
|||||||
|
|
||||||
Bind mode controls where the Gateway listens:
|
Bind mode controls where the Gateway listens:
|
||||||
- `gateway.bind: "loopback"` (default): only local clients can connect.
|
- `gateway.bind: "loopback"` (default): only local clients can connect.
|
||||||
- Non-loopback binds (`"lan"`, `"tailnet"`, `"auto"`) expand the attack surface. Only use them with `gateway.auth` enabled and a real firewall.
|
- Non-loopback binds (`"lan"`, `"tailnet"`, `"custom"`) expand the attack surface. Only use them with `gateway.auth` enabled and a real firewall.
|
||||||
|
|
||||||
Rules of thumb:
|
Rules of thumb:
|
||||||
- Prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback, and Tailscale handles access).
|
- Prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback, and Tailscale handles access).
|
||||||
|
|||||||
@@ -46,6 +46,25 @@ force `gateway.auth.mode: "password"`.
|
|||||||
|
|
||||||
Open: `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
|
Open: `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
|
||||||
|
|
||||||
|
### Tailnet-only (bind to Tailnet IP)
|
||||||
|
|
||||||
|
Use this when you want the Gateway to listen directly on the Tailnet IP (no Serve/Funnel).
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
bind: "tailnet",
|
||||||
|
auth: { mode: "token", token: "your-token" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Connect from another Tailnet device:
|
||||||
|
- Control UI: `http://<tailscale-ip>:18789/`
|
||||||
|
- WebSocket: `ws://<tailscale-ip>:18789`
|
||||||
|
|
||||||
|
Note: loopback (`http://127.0.0.1:18789`) will **not** work in this mode.
|
||||||
|
|
||||||
### Public internet (Funnel + shared password)
|
### Public internet (Funnel + shared password)
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@@ -73,6 +92,8 @@ clawdbot gateway --tailscale funnel --auth password
|
|||||||
- `tailscale.mode: "funnel"` refuses to start unless auth mode is `password` to avoid public exposure.
|
- `tailscale.mode: "funnel"` refuses to start unless auth mode is `password` to avoid public exposure.
|
||||||
- Set `gateway.tailscale.resetOnExit` if you want Clawdbot to undo `tailscale serve`
|
- Set `gateway.tailscale.resetOnExit` if you want Clawdbot to undo `tailscale serve`
|
||||||
or `tailscale funnel` configuration on shutdown.
|
or `tailscale funnel` configuration on shutdown.
|
||||||
|
- `gateway.bind: "tailnet"` is a direct Tailnet bind (no HTTPS, no Serve/Funnel).
|
||||||
|
- `gateway.bind: "auto"` prefers loopback; use `tailnet` if you want Tailnet-only.
|
||||||
- Serve/Funnel only expose the **Gateway control UI + WS**. Node **bridge** traffic
|
- Serve/Funnel only expose the **Gateway control UI + WS**. Node **bridge** traffic
|
||||||
uses the separate bridge port (default `18790`) and is **not** proxied by Serve.
|
uses the separate bridge port (default `18790`) and is **not** proxied by Serve.
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ Quick triage commands (in order):
|
|||||||
|
|
||||||
| Command | What it tells you | When to use it |
|
| Command | What it tells you | When to use it |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `clawdbot status` | Local summary: OS + update, gateway reachability/mode, daemon, agents/sessions, provider config state | First check, quick overview |
|
| `clawdbot status` | Local summary: OS + update, gateway reachability/mode, service, agents/sessions, provider config state | First check, quick overview |
|
||||||
| `clawdbot status --all` | Full local diagnosis (read-only, pasteable, safe-ish) incl. log tail | When you need to share a debug report |
|
| `clawdbot status --all` | Full local diagnosis (read-only, pasteable, safe-ish) incl. log tail | When you need to share a debug report |
|
||||||
| `clawdbot status --deep` | Runs gateway health checks (incl. provider probes; requires reachable gateway) | When “configured” doesn’t mean “working” |
|
| `clawdbot status --deep` | Runs gateway health checks (incl. provider probes; requires reachable gateway) | When “configured” doesn’t mean “working” |
|
||||||
| `clawdbot gateway status` | Gateway discovery + reachability (local + remote targets) | When you suspect you’re probing the wrong gateway |
|
| `clawdbot gateway probe` | Gateway discovery + reachability (local + remote targets) | When you suspect you’re probing the wrong gateway |
|
||||||
| `clawdbot channels status --probe` | Asks the running gateway for channel status (and optionally probes) | When gateway is reachable but channels misbehave |
|
| `clawdbot channels status --probe` | Asks the running gateway for channel status (and optionally probes) | When gateway is reachable but channels misbehave |
|
||||||
| `clawdbot daemon status` | Supervisor state (launchd/systemd/schtasks), runtime PID/exit, last gateway error | When the daemon “looks loaded” but nothing runs |
|
| `clawdbot gateway status` | Supervisor state (launchd/systemd/schtasks), runtime PID/exit, last gateway error | When the service “looks loaded” but nothing runs |
|
||||||
| `clawdbot logs --follow` | Live logs (best signal for runtime issues) | When you need the actual failure reason |
|
| `clawdbot logs --follow` | Live logs (best signal for runtime issues) | When you need the actual failure reason |
|
||||||
|
|
||||||
**Sharing output:** prefer `clawdbot status --all` (it redacts tokens). If you paste `clawdbot status`, consider setting `CLAWDBOT_SHOW_SECRETS=0` first (token previews).
|
**Sharing output:** prefer `clawdbot status --all` (it redacts tokens). If you paste `clawdbot status`, consider setting `CLAWDBOT_SHOW_SECRETS=0` first (token previews).
|
||||||
@@ -38,16 +38,16 @@ Follow [Secret scanning](/gateway/security#secret-scanning-detect-secrets).
|
|||||||
|
|
||||||
### Service Installed but Nothing is Running
|
### Service Installed but Nothing is Running
|
||||||
|
|
||||||
If the gateway service is installed but the process exits immediately, the daemon
|
If the gateway service is installed but the process exits immediately, the service
|
||||||
can appear “loaded” while nothing is running.
|
can appear “loaded” while nothing is running.
|
||||||
|
|
||||||
**Check:**
|
**Check:**
|
||||||
```bash
|
```bash
|
||||||
clawdbot daemon status
|
clawdbot gateway status
|
||||||
clawdbot doctor
|
clawdbot doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
Doctor/daemon will show runtime state (PID/last exit) and log hints.
|
Doctor/service will show runtime state (PID/last exit) and log hints.
|
||||||
|
|
||||||
**Logs:**
|
**Logs:**
|
||||||
- Preferred: `clawdbot logs --follow`
|
- Preferred: `clawdbot logs --follow`
|
||||||
@@ -71,12 +71,12 @@ See [/logging](/logging) for a full overview of formats, config, and access.
|
|||||||
|
|
||||||
### Service Environment (PATH + runtime)
|
### Service Environment (PATH + runtime)
|
||||||
|
|
||||||
The gateway daemon runs with a **minimal PATH** to avoid shell/manager cruft:
|
The gateway service runs with a **minimal PATH** to avoid shell/manager cruft:
|
||||||
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
|
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||||
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
|
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||||
|
|
||||||
This intentionally excludes version managers (nvm/fnm/volta/asdf) and package
|
This intentionally excludes version managers (nvm/fnm/volta/asdf) and package
|
||||||
managers (pnpm/npm) because the daemon does not load your shell init. Runtime
|
managers (pnpm/npm) because the service does not load your shell init. Runtime
|
||||||
variables like `DISPLAY` should live in `~/.clawdbot/.env` (loaded early by the
|
variables like `DISPLAY` should live in `~/.clawdbot/.env` (loaded early by the
|
||||||
gateway).
|
gateway).
|
||||||
Exec runs on `host=gateway` merge your login-shell `PATH` into the exec environment,
|
Exec runs on `host=gateway` merge your login-shell `PATH` into the exec environment,
|
||||||
@@ -106,31 +106,31 @@ the Gateway likely refused to bind.
|
|||||||
**What "running" means here**
|
**What "running" means here**
|
||||||
- `Runtime: running` means your supervisor (launchd/systemd/schtasks) thinks the process is alive.
|
- `Runtime: running` means your supervisor (launchd/systemd/schtasks) thinks the process is alive.
|
||||||
- `RPC probe` means the CLI could actually connect to the gateway WebSocket and call `status`.
|
- `RPC probe` means the CLI could actually connect to the gateway WebSocket and call `status`.
|
||||||
- Always trust `Probe target:` + `Config (daemon):` as the “what did we actually try?” lines.
|
- Always trust `Probe target:` + `Config (service):` as the “what did we actually try?” lines.
|
||||||
|
|
||||||
**Check:**
|
**Check:**
|
||||||
- `gateway.mode` must be `local` for `clawdbot gateway` and the daemon.
|
- `gateway.mode` must be `local` for `clawdbot gateway` and the service.
|
||||||
- If you set `gateway.mode=remote`, the **CLI defaults** to a remote URL. The daemon can still be running locally, but your CLI may be probing the wrong place. Use `clawdbot daemon status` to see the daemon’s resolved port + probe target (or pass `--url`).
|
- If you set `gateway.mode=remote`, the **CLI defaults** to a remote URL. The service can still be running locally, but your CLI may be probing the wrong place. Use `clawdbot gateway status` to see the service’s resolved port + probe target (or pass `--url`).
|
||||||
- `clawdbot daemon status` and `clawdbot doctor` surface the **last gateway error** from logs when the service looks running but the port is closed.
|
- `clawdbot gateway status` and `clawdbot doctor` surface the **last gateway error** from logs when the service looks running but the port is closed.
|
||||||
- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth:
|
- Non-loopback binds (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) require auth:
|
||||||
`gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
|
`gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
|
||||||
- `gateway.remote.token` is for remote CLI calls only; it does **not** enable local auth.
|
- `gateway.remote.token` is for remote CLI calls only; it does **not** enable local auth.
|
||||||
- `gateway.token` is ignored; use `gateway.auth.token`.
|
- `gateway.token` is ignored; use `gateway.auth.token`.
|
||||||
|
|
||||||
**If `clawdbot daemon status` shows a config mismatch**
|
**If `clawdbot gateway status` shows a config mismatch**
|
||||||
- `Config (cli): ...` and `Config (daemon): ...` should normally match.
|
- `Config (cli): ...` and `Config (service): ...` should normally match.
|
||||||
- If they don’t, you’re almost certainly editing one config while the daemon is running another.
|
- If they don’t, you’re almost certainly editing one config while the service is running another.
|
||||||
- Fix: rerun `clawdbot daemon install --force` from the same `--profile` / `CLAWDBOT_STATE_DIR` you want the daemon to use.
|
- Fix: rerun `clawdbot gateway install --force` from the same `--profile` / `CLAWDBOT_STATE_DIR` you want the service to use.
|
||||||
|
|
||||||
**If `clawdbot daemon status` reports service config issues**
|
**If `clawdbot gateway status` reports service config issues**
|
||||||
- The supervisor config (launchd/systemd/schtasks) is missing current defaults.
|
- The supervisor config (launchd/systemd/schtasks) is missing current defaults.
|
||||||
- Fix: run `clawdbot doctor` to update it (or `clawdbot daemon install --force` for a full rewrite).
|
- Fix: run `clawdbot doctor` to update it (or `clawdbot gateway install --force` for a full rewrite).
|
||||||
|
|
||||||
**If `Last gateway error:` mentions “refusing to bind … without auth”**
|
**If `Last gateway error:` mentions “refusing to bind … without auth”**
|
||||||
- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`auto`) but left auth off.
|
- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) but left auth off.
|
||||||
- Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the daemon.
|
- Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the service.
|
||||||
|
|
||||||
**If `clawdbot daemon status` says `bind=tailnet` but no tailnet interface was found**
|
**If `clawdbot gateway status` says `bind=tailnet` but no tailnet interface was found**
|
||||||
- The gateway tried to bind to a Tailscale IP (100.64.0.0/10) but none were detected on the host.
|
- The gateway tried to bind to a Tailscale IP (100.64.0.0/10) but none were detected on the host.
|
||||||
- Fix: bring up Tailscale on that machine (or change `gateway.bind` to `loopback`/`lan`).
|
- Fix: bring up Tailscale on that machine (or change `gateway.bind` to `loopback`/`lan`).
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ This means something is already listening on the gateway port.
|
|||||||
|
|
||||||
**Check:**
|
**Check:**
|
||||||
```bash
|
```bash
|
||||||
clawdbot daemon status
|
clawdbot gateway status
|
||||||
```
|
```
|
||||||
|
|
||||||
It will show the listener(s) and likely causes (gateway already running, SSH tunnel).
|
It will show the listener(s) and likely causes (gateway already running, SSH tunnel).
|
||||||
@@ -354,7 +354,7 @@ clawdbot doctor --fix
|
|||||||
Notes:
|
Notes:
|
||||||
- `clawdbot doctor` reports every invalid entry.
|
- `clawdbot doctor` reports every invalid entry.
|
||||||
- `clawdbot doctor --fix` applies migrations/repairs and rewrites the config.
|
- `clawdbot doctor --fix` applies migrations/repairs and rewrites the config.
|
||||||
- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, and `clawdbot daemon` still run even if the config is invalid.
|
- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot gateway status`, and `clawdbot gateway probe` still run even if the config is invalid.
|
||||||
|
|
||||||
### “All models failed” — what should I check first?
|
### “All models failed” — what should I check first?
|
||||||
|
|
||||||
@@ -407,7 +407,7 @@ git status # ensure you’re in the repo root
|
|||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
clawdbot doctor
|
clawdbot doctor
|
||||||
clawdbot daemon restart
|
clawdbot gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
Why: pnpm is the configured package manager for this repo.
|
Why: pnpm is the configured package manager for this repo.
|
||||||
@@ -432,7 +432,7 @@ Notes:
|
|||||||
- After switching, run:
|
- After switching, run:
|
||||||
```bash
|
```bash
|
||||||
clawdbot doctor
|
clawdbot doctor
|
||||||
clawdbot daemon restart
|
clawdbot gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
### Telegram block streaming isn’t splitting text between tool calls. Why?
|
### Telegram block streaming isn’t splitting text between tool calls. Why?
|
||||||
@@ -507,8 +507,8 @@ The app connects to a local gateway on port `18789`. If it stays stuck:
|
|||||||
**Fix 1: Stop the supervisor (preferred)**
|
**Fix 1: Stop the supervisor (preferred)**
|
||||||
If the gateway is supervised by launchd, killing the PID will just respawn it. Stop the supervisor first:
|
If the gateway is supervised by launchd, killing the PID will just respawn it. Stop the supervisor first:
|
||||||
```bash
|
```bash
|
||||||
clawdbot daemon status
|
clawdbot gateway status
|
||||||
clawdbot daemon stop
|
clawdbot gateway stop
|
||||||
# Or: launchctl bootout gui/$UID/com.clawdbot.gateway (replace with com.clawdbot.<profile> if needed)
|
# Or: launchctl bootout gui/$UID/com.clawdbot.gateway (replace with com.clawdbot.<profile> if needed)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -558,9 +558,9 @@ clawdbot channels login --verbose
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Supervisor + probe target + config paths
|
# Supervisor + probe target + config paths
|
||||||
clawdbot daemon status
|
clawdbot gateway status
|
||||||
# Include system-level scans (legacy/extra services, port listeners)
|
# Include system-level scans (legacy/extra services, port listeners)
|
||||||
clawdbot daemon status --deep
|
clawdbot gateway status --deep
|
||||||
|
|
||||||
# Is the gateway reachable?
|
# Is the gateway reachable?
|
||||||
clawdbot health --json
|
clawdbot health --json
|
||||||
@@ -581,13 +581,13 @@ tail -20 /tmp/clawdbot/clawdbot-*.log
|
|||||||
Nuclear option:
|
Nuclear option:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot daemon stop
|
clawdbot gateway stop
|
||||||
# If you installed a service and want a clean install:
|
# If you installed a service and want a clean install:
|
||||||
# clawdbot daemon uninstall
|
# clawdbot gateway uninstall
|
||||||
|
|
||||||
trash "${CLAWDBOT_STATE_DIR:-$HOME/.clawdbot}"
|
trash "${CLAWDBOT_STATE_DIR:-$HOME/.clawdbot}"
|
||||||
clawdbot channels login # re-pair WhatsApp
|
clawdbot channels login # re-pair WhatsApp
|
||||||
clawdbot daemon restart # or: clawdbot gateway
|
clawdbot gateway restart # or: clawdbot gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ This loses all sessions and requires re-pairing WhatsApp.
|
⚠️ This loses all sessions and requires re-pairing WhatsApp.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Run these in order:
|
|||||||
```bash
|
```bash
|
||||||
clawdbot status
|
clawdbot status
|
||||||
clawdbot status --all
|
clawdbot status --all
|
||||||
clawdbot daemon status
|
clawdbot gateway probe
|
||||||
clawdbot logs --follow
|
clawdbot logs --follow
|
||||||
clawdbot doctor
|
clawdbot doctor
|
||||||
```
|
```
|
||||||
@@ -38,10 +38,10 @@ Almost always a Node/npm PATH issue. Start here:
|
|||||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||||
- [Gateway authentication](/gateway/authentication)
|
- [Gateway authentication](/gateway/authentication)
|
||||||
|
|
||||||
### Daemon says running, but RPC probe fails
|
### Service says running, but RPC probe fails
|
||||||
|
|
||||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||||
- [Background process / daemon](/gateway/background-process)
|
- [Background process / service](/gateway/background-process)
|
||||||
|
|
||||||
### Model/auth failures (rate limit, billing, “all models failed”)
|
### Model/auth failures (rate limit, billing, “all models failed”)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -103,13 +103,13 @@ Runtime requirement: **Node ≥ 22**.
|
|||||||
npm install -g clawdbot@latest
|
npm install -g clawdbot@latest
|
||||||
# or: pnpm add -g clawdbot@latest
|
# or: pnpm add -g clawdbot@latest
|
||||||
|
|
||||||
# Onboard + install the daemon (launchd/systemd user service)
|
# Onboard + install the service (launchd/systemd user service)
|
||||||
clawdbot onboard --install-daemon
|
clawdbot onboard --install-daemon
|
||||||
|
|
||||||
# Pair WhatsApp Web (shows QR)
|
# Pair WhatsApp Web (shows QR)
|
||||||
clawdbot channels login
|
clawdbot channels login
|
||||||
|
|
||||||
# Gateway runs via daemon after onboarding; manual run is still possible:
|
# Gateway runs via the service after onboarding; manual run is still possible:
|
||||||
clawdbot gateway --port 18789
|
clawdbot gateway --port 18789
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ Manual steps (same result):
|
|||||||
1) Stop the gateway service:
|
1) Stop the gateway service:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot daemon stop
|
clawdbot gateway stop
|
||||||
```
|
```
|
||||||
|
|
||||||
2) Uninstall the gateway service (launchd/systemd/schtasks):
|
2) Uninstall the gateway service (launchd/systemd/schtasks):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot daemon uninstall
|
clawdbot gateway uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
3) Delete state + config:
|
3) Delete state + config:
|
||||||
|
|||||||
@@ -68,12 +68,12 @@ Then:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot doctor
|
clawdbot doctor
|
||||||
clawdbot daemon restart
|
clawdbot gateway restart
|
||||||
clawdbot health
|
clawdbot health
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- If your Gateway runs as a service, `clawdbot daemon restart` is preferred over killing PIDs.
|
- If your Gateway runs as a service, `clawdbot gateway restart` is preferred over killing PIDs.
|
||||||
- If you’re pinned to a specific version, see “Rollback / pinning” below.
|
- If you’re pinned to a specific version, see “Rollback / pinning” below.
|
||||||
|
|
||||||
## Update (`clawdbot update`)
|
## Update (`clawdbot update`)
|
||||||
@@ -148,9 +148,9 @@ Details: [Doctor](/gateway/doctor)
|
|||||||
CLI (works regardless of OS):
|
CLI (works regardless of OS):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot daemon status
|
clawdbot gateway status
|
||||||
clawdbot daemon stop
|
clawdbot gateway stop
|
||||||
clawdbot daemon restart
|
clawdbot gateway restart
|
||||||
clawdbot gateway --port 18789
|
clawdbot gateway --port 18789
|
||||||
clawdbot logs --follow
|
clawdbot logs --follow
|
||||||
```
|
```
|
||||||
@@ -159,7 +159,7 @@ If you’re supervised:
|
|||||||
- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/com.clawdbot.gateway` (use `com.clawdbot.<profile>` if set)
|
- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/com.clawdbot.gateway` (use `com.clawdbot.<profile>` if set)
|
||||||
- Linux systemd user service: `systemctl --user restart clawdbot-gateway[-<profile>].service`
|
- Linux systemd user service: `systemctl --user restart clawdbot-gateway[-<profile>].service`
|
||||||
- Windows (WSL2): `systemctl --user restart clawdbot-gateway[-<profile>].service`
|
- Windows (WSL2): `systemctl --user restart clawdbot-gateway[-<profile>].service`
|
||||||
- `launchctl`/`systemctl` only work if the service is installed; otherwise run `clawdbot daemon install`.
|
- `launchctl`/`systemctl` only work if the service is installed; otherwise run `clawdbot gateway install`.
|
||||||
|
|
||||||
Runbook + exact service labels: [Gateway runbook](/gateway)
|
Runbook + exact service labels: [Gateway runbook](/gateway)
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ Then restart + re-run doctor:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot doctor
|
clawdbot doctor
|
||||||
clawdbot daemon restart
|
clawdbot gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pin (source) by date
|
### Pin (source) by date
|
||||||
@@ -200,7 +200,7 @@ Then reinstall deps + restart:
|
|||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
clawdbot daemon restart
|
clawdbot gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
If you want to go back to latest later:
|
If you want to go back to latest later:
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ A **node** is a companion device (iOS/Android today) that connects to the Gatewa
|
|||||||
macOS can also run in **node mode**: the menubar app connects to the Gateway’s bridge and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
|
macOS can also run in **node mode**: the menubar app connects to the Gateway’s bridge and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Nodes are **peripherals**, not gateways. They don’t run the gateway daemon.
|
- Nodes are **peripherals**, not gateways. They don’t run the gateway service.
|
||||||
- Telegram/WhatsApp/etc. messages land on the **gateway**, not on nodes.
|
- Telegram/WhatsApp/etc. messages land on the **gateway**, not on nodes.
|
||||||
|
|
||||||
## Pairing + status
|
## Pairing + status
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ It can set up:
|
|||||||
- `~/.clawdbot/clawdbot.json` config
|
- `~/.clawdbot/clawdbot.json` config
|
||||||
- model auth profiles
|
- model auth profiles
|
||||||
- model provider config/login
|
- model provider config/login
|
||||||
- Linux systemd **user** service (daemon)
|
- Linux systemd **user** service (service)
|
||||||
|
|
||||||
If you’re doing OAuth on a headless VM: do OAuth on a normal machine first, then copy the auth profile to the VM (see [Help](/help)).
|
If you’re doing OAuth on a headless VM: do OAuth on a normal machine first, then copy the auth profile to the VM (see [Help](/help)).
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ export CLAWDBOT_GATEWAY_TOKEN="$(openssl rand -hex 32)"
|
|||||||
clawdbot gateway --bind lan --port 8080 --token "$CLAWDBOT_GATEWAY_TOKEN"
|
clawdbot gateway --bind lan --port 8080 --token "$CLAWDBOT_GATEWAY_TOKEN"
|
||||||
```
|
```
|
||||||
|
|
||||||
For daemon runs, persist it in `~/.clawdbot/clawdbot.json`:
|
For service runs, persist it in `~/.clawdbot/clawdbot.json`:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
@@ -159,7 +159,7 @@ Notes:
|
|||||||
|
|
||||||
Control UI details: [Control UI](/web/control-ui)
|
Control UI details: [Control UI](/web/control-ui)
|
||||||
|
|
||||||
## 6) Keep it running (daemon)
|
## 6) Keep it running (service)
|
||||||
|
|
||||||
On Linux, Clawdbot uses a systemd **user** service. After `--install-daemon`, verify:
|
On Linux, Clawdbot uses a systemd **user** service. After `--install-daemon`, verify:
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ More: [Linux](/platforms/linux)
|
|||||||
```bash
|
```bash
|
||||||
npm i -g clawdbot@latest
|
npm i -g clawdbot@latest
|
||||||
clawdbot doctor
|
clawdbot doctor
|
||||||
clawdbot daemon restart
|
clawdbot gateway restart
|
||||||
clawdbot health
|
clawdbot health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -31,15 +31,15 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
|
|||||||
- Install guide: [Getting Started](/start/getting-started)
|
- Install guide: [Getting Started](/start/getting-started)
|
||||||
- Gateway runbook: [Gateway](/gateway)
|
- Gateway runbook: [Gateway](/gateway)
|
||||||
- Gateway configuration: [Configuration](/gateway/configuration)
|
- Gateway configuration: [Configuration](/gateway/configuration)
|
||||||
- Service status: `clawdbot daemon status`
|
- Service status: `clawdbot gateway status`
|
||||||
|
|
||||||
## Gateway service install (CLI)
|
## Gateway service install (CLI)
|
||||||
|
|
||||||
Use one of these (all supported):
|
Use one of these (all supported):
|
||||||
|
|
||||||
- Wizard (recommended): `clawdbot onboard --install-daemon`
|
- Wizard (recommended): `clawdbot onboard --install-daemon`
|
||||||
- Direct: `clawdbot daemon install`
|
- Direct: `clawdbot gateway install`
|
||||||
- Configure flow: `clawdbot configure` → select **Gateway daemon**
|
- Configure flow: `clawdbot configure` → select **Gateway service**
|
||||||
- Repair/migrate: `clawdbot doctor` (offers to install or fix the service)
|
- Repair/migrate: `clawdbot doctor` (offers to install or fix the service)
|
||||||
|
|
||||||
The service target depends on OS:
|
The service target depends on OS:
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ clawdbot onboard --install-daemon
|
|||||||
Or:
|
Or:
|
||||||
|
|
||||||
```
|
```
|
||||||
clawdbot daemon install
|
clawdbot gateway install
|
||||||
```
|
```
|
||||||
|
|
||||||
Or:
|
Or:
|
||||||
@@ -50,7 +50,7 @@ Or:
|
|||||||
clawdbot configure
|
clawdbot configure
|
||||||
```
|
```
|
||||||
|
|
||||||
Select **Gateway daemon** when prompted.
|
Select **Gateway service** when prompted.
|
||||||
|
|
||||||
Repair/migrate:
|
Repair/migrate:
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Plist location (per‑user):
|
|||||||
|
|
||||||
Manager:
|
Manager:
|
||||||
- The macOS app owns LaunchAgent install/update in Local mode.
|
- The macOS app owns LaunchAgent install/update in Local mode.
|
||||||
- The CLI can also install it: `clawdbot daemon install`.
|
- The CLI can also install it: `clawdbot gateway install`.
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
- “Clawdbot Active” enables/disables the LaunchAgent.
|
- “Clawdbot Active” enables/disables the LaunchAgent.
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ If the app crashes when you try to allow **Speech Recognition** or **Microphone*
|
|||||||
If the gateway status stays on "Starting...", check if a zombie process is holding the port:
|
If the gateway status stays on "Starting...", check if a zombie process is holding the port:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot daemon status
|
clawdbot gateway status
|
||||||
clawdbot daemon stop
|
clawdbot gateway stop
|
||||||
|
|
||||||
# If you’re not using a LaunchAgent (dev mode / manual runs), find the listener:
|
# If you’re not using a LaunchAgent (dev mode / manual runs), find the listener:
|
||||||
lsof -nP -iTCP:18789 -sTCP:LISTEN
|
lsof -nP -iTCP:18789 -sTCP:LISTEN
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ capabilities to the agent as a node.
|
|||||||
## Local vs remote mode
|
## Local vs remote mode
|
||||||
|
|
||||||
- **Local** (default): the app attaches to a running local Gateway if present;
|
- **Local** (default): the app attaches to a running local Gateway if present;
|
||||||
otherwise it enables the launchd service via `clawdbot daemon`.
|
otherwise it enables the launchd service via `clawdbot gateway install`.
|
||||||
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
||||||
a local process.
|
a local process.
|
||||||
The app starts the local **node host service** so the remote Gateway can reach this Mac.
|
The app starts the local **node host service** so the remote Gateway can reach this Mac.
|
||||||
@@ -43,7 +43,7 @@ launchctl bootout gui/$UID/com.clawdbot.gateway
|
|||||||
Replace the label with `com.clawdbot.<profile>` when running a named profile.
|
Replace the label with `com.clawdbot.<profile>` when running a named profile.
|
||||||
|
|
||||||
If the LaunchAgent isn’t installed, enable it from the app or run
|
If the LaunchAgent isn’t installed, enable it from the app or run
|
||||||
`clawdbot daemon install`.
|
`clawdbot gateway install`.
|
||||||
|
|
||||||
## Node capabilities (mac)
|
## Node capabilities (mac)
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ clawdbot onboard --install-daemon
|
|||||||
Or:
|
Or:
|
||||||
|
|
||||||
```
|
```
|
||||||
clawdbot daemon install
|
clawdbot gateway install
|
||||||
```
|
```
|
||||||
|
|
||||||
Or:
|
Or:
|
||||||
@@ -41,7 +41,7 @@ Or:
|
|||||||
clawdbot configure
|
clawdbot configure
|
||||||
```
|
```
|
||||||
|
|
||||||
Select **Gateway daemon** when prompted.
|
Select **Gateway service** when prompted.
|
||||||
|
|
||||||
Repair/migrate:
|
Repair/migrate:
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ wsl --install -d Ubuntu-24.04
|
|||||||
|
|
||||||
Reboot if Windows asks.
|
Reboot if Windows asks.
|
||||||
|
|
||||||
### 2) Enable systemd (required for daemon install)
|
### 2) Enable systemd (required for gateway install)
|
||||||
|
|
||||||
In your WSL terminal:
|
In your WSL terminal:
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ clawdbot onboard --anthropic-api-key "$ANTHROPIC_API_KEY"
|
|||||||
|
|
||||||
## Prompt caching (Anthropic API)
|
## Prompt caching (Anthropic API)
|
||||||
|
|
||||||
Clawdbot enables **1-hour prompt caching by default** for Anthropic API keys.
|
Clawdbot does **not** override Anthropic’s default cache TTL unless you set it.
|
||||||
This is **API-only**; Claude Code CLI OAuth ignores TTL settings.
|
This is **API-only**; Claude Code CLI OAuth ignores TTL settings.
|
||||||
|
|
||||||
To override the TTL per model, set `cacheControlTtl` in the model `params`:
|
To set the TTL per model, use `cacheControlTtl` in the model `params`:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ Allowed (diagnostic-only):
|
|||||||
- `clawdbot health`
|
- `clawdbot health`
|
||||||
- `clawdbot help`
|
- `clawdbot help`
|
||||||
- `clawdbot status`
|
- `clawdbot status`
|
||||||
- `clawdbot daemon`
|
- `clawdbot gateway status`
|
||||||
|
|
||||||
Everything else must hard-fail with: “Config invalid. Run `clawdbot doctor --fix`.”
|
Everything else must hard-fail with: “Config invalid. Run `clawdbot doctor --fix`.”
|
||||||
|
|
||||||
|
|||||||
+39
-30
@@ -59,20 +59,21 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [How do I use Brave for browser control?](#how-do-i-use-brave-for-browser-control)
|
- [How do I use Brave for browser control?](#how-do-i-use-brave-for-browser-control)
|
||||||
- [Remote gateways + nodes](#remote-gateways-nodes)
|
- [Remote gateways + nodes](#remote-gateways-nodes)
|
||||||
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
||||||
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
|
- [Do nodes run a gateway service?](#do-nodes-run-a-gateway-service)
|
||||||
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
|
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
|
||||||
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
|
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
|
||||||
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
|
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
|
||||||
- [How do I connect a Mac node to a remote Gateway (Tailscale Serve)?](#how-do-i-connect-a-mac-node-to-a-remote-gateway-tailscale-serve)
|
- [How do I connect a Mac node to a remote Gateway (Tailscale Serve)?](#how-do-i-connect-a-mac-node-to-a-remote-gateway-tailscale-serve)
|
||||||
- [Env vars and .env loading](#env-vars-and-env-loading)
|
- [Env vars and .env loading](#env-vars-and-env-loading)
|
||||||
- [How does Clawdbot load environment variables?](#how-does-clawdbot-load-environment-variables)
|
- [How does Clawdbot load environment variables?](#how-does-clawdbot-load-environment-variables)
|
||||||
- [“I started the Gateway via a daemon and my env vars disappeared.” What now?](#i-started-the-gateway-via-a-daemon-and-my-env-vars-disappeared-what-now)
|
- [“I started the Gateway via the service and my env vars disappeared.” What now?](#i-started-the-gateway-via-the-service-and-my-env-vars-disappeared-what-now)
|
||||||
- [I set `COPILOT_GITHUB_TOKEN`, but models status shows “Shell env: off.” Why?](#i-set-copilot_github_token-but-models-status-shows-shell-env-off-why)
|
- [I set `COPILOT_GITHUB_TOKEN`, but models status shows “Shell env: off.” Why?](#i-set-copilot_github_token-but-models-status-shows-shell-env-off-why)
|
||||||
- [Sessions & multiple chats](#sessions-multiple-chats)
|
- [Sessions & multiple chats](#sessions-multiple-chats)
|
||||||
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
|
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
|
||||||
- [Do sessions reset automatically if I never send `/new`?](#do-sessions-reset-automatically-if-i-never-send-new)
|
- [Do sessions reset automatically if I never send `/new`?](#do-sessions-reset-automatically-if-i-never-send-new)
|
||||||
- [How do I completely reset Clawdbot but keep it installed?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
|
- [How do I completely reset Clawdbot but keep it installed?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
|
||||||
- [I’m getting “context too large” errors — how do I reset or compact?](#im-getting-context-too-large-errors-how-do-i-reset-or-compact)
|
- [I’m getting “context too large” errors — how do I reset or compact?](#im-getting-context-too-large-errors-how-do-i-reset-or-compact)
|
||||||
|
- [Why am I seeing “LLM request rejected: messages.N.content.X.tool_use.input: Field required”?](#why-am-i-seeing-llm-request-rejected-messagesncontentxtool_useinput-field-required)
|
||||||
- [Why am I getting heartbeat messages every 30 minutes?](#why-am-i-getting-heartbeat-messages-every-30-minutes)
|
- [Why am I getting heartbeat messages every 30 minutes?](#why-am-i-getting-heartbeat-messages-every-30-minutes)
|
||||||
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
|
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
|
||||||
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
|
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
|
||||||
@@ -99,8 +100,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [OAuth vs API key: what’s the difference?](#oauth-vs-api-key-whats-the-difference)
|
- [OAuth vs API key: what’s the difference?](#oauth-vs-api-key-whats-the-difference)
|
||||||
- [Gateway: ports, “already running”, and remote mode](#gateway-ports-already-running-and-remote-mode)
|
- [Gateway: ports, “already running”, and remote mode](#gateway-ports-already-running-and-remote-mode)
|
||||||
- [What port does the Gateway use?](#what-port-does-the-gateway-use)
|
- [What port does the Gateway use?](#what-port-does-the-gateway-use)
|
||||||
- [Why does `clawdbot daemon status` say `Runtime: running` but `RPC probe: failed`?](#why-does-clawdbot-daemon-status-say-runtime-running-but-rpc-probe-failed)
|
- [Why does `clawdbot gateway status` say `Runtime: running` but `RPC probe: failed`?](#why-does-clawdbot-gateway-status-say-runtime-running-but-rpc-probe-failed)
|
||||||
- [Why does `clawdbot daemon status` show `Config (cli)` and `Config (daemon)` different?](#why-does-clawdbot-daemon-status-show-config-cli-and-config-daemon-different)
|
- [Why does `clawdbot gateway status` show `Config (cli)` and `Config (service)` different?](#why-does-clawdbot-gateway-status-show-config-cli-and-config-service-different)
|
||||||
- [What does “another gateway instance is already listening” mean?](#what-does-another-gateway-instance-is-already-listening-mean)
|
- [What does “another gateway instance is already listening” mean?](#what-does-another-gateway-instance-is-already-listening-mean)
|
||||||
- [How do I run Clawdbot in remote mode (client connects to a Gateway elsewhere)?](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere)
|
- [How do I run Clawdbot in remote mode (client connects to a Gateway elsewhere)?](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere)
|
||||||
- [The Control UI says “unauthorized” (or keeps reconnecting). What now?](#the-control-ui-says-unauthorized-or-keeps-reconnecting-what-now)
|
- [The Control UI says “unauthorized” (or keeps reconnecting). What now?](#the-control-ui-says-unauthorized-or-keeps-reconnecting-what-now)
|
||||||
@@ -109,8 +110,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [What does “invalid handshake” / code 1008 mean?](#what-does-invalid-handshake--code-1008-mean)
|
- [What does “invalid handshake” / code 1008 mean?](#what-does-invalid-handshake--code-1008-mean)
|
||||||
- [Logging and debugging](#logging-and-debugging)
|
- [Logging and debugging](#logging-and-debugging)
|
||||||
- [Where are logs?](#where-are-logs)
|
- [Where are logs?](#where-are-logs)
|
||||||
- [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon)
|
- [How do I start/stop/restart the Gateway service?](#how-do-i-startstoprestart-the-gateway-service)
|
||||||
- [ELI5: `clawdbot daemon restart` vs `clawdbot gateway`](#eli5-clawdbot-daemon-restart-vs-clawdbot-gateway)
|
- [ELI5: `clawdbot gateway restart` vs `clawdbot gateway`](#eli5-clawdbot-gateway-restart-vs-clawdbot-gateway)
|
||||||
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
|
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
|
||||||
- [Media & attachments](#media-attachments)
|
- [Media & attachments](#media-attachments)
|
||||||
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
|
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
|
||||||
@@ -128,7 +129,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
```bash
|
```bash
|
||||||
clawdbot status
|
clawdbot status
|
||||||
```
|
```
|
||||||
Fast local summary: OS + update, gateway/daemon reachability, agents/sessions, provider config + runtime issues (when gateway is reachable).
|
Fast local summary: OS + update, gateway/service reachability, agents/sessions, provider config + runtime issues (when gateway is reachable).
|
||||||
|
|
||||||
2) **Pasteable report (safe to share)**
|
2) **Pasteable report (safe to share)**
|
||||||
```bash
|
```bash
|
||||||
@@ -138,9 +139,9 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
|
|
||||||
3) **Daemon + port state**
|
3) **Daemon + port state**
|
||||||
```bash
|
```bash
|
||||||
clawdbot daemon status
|
clawdbot gateway status
|
||||||
```
|
```
|
||||||
Shows supervisor runtime vs RPC reachability, the probe target URL, and which config the daemon likely used.
|
Shows supervisor runtime vs RPC reachability, the probe target URL, and which config the service likely used.
|
||||||
|
|
||||||
4) **Deep probes**
|
4) **Deep probes**
|
||||||
```bash
|
```bash
|
||||||
@@ -334,7 +335,7 @@ cd clawdbot
|
|||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
clawdbot doctor
|
clawdbot doctor
|
||||||
clawdbot daemon restart
|
clawdbot gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
From git → npm:
|
From git → npm:
|
||||||
@@ -342,7 +343,7 @@ From git → npm:
|
|||||||
```bash
|
```bash
|
||||||
npm install -g clawdbot@latest
|
npm install -g clawdbot@latest
|
||||||
clawdbot doctor
|
clawdbot doctor
|
||||||
clawdbot daemon restart
|
clawdbot gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
Doctor detects a gateway service entrypoint mismatch and offers to rewrite the service config to match the current install (use `--repair` in automation).
|
Doctor detects a gateway service entrypoint mismatch and offers to rewrite the service config to match the current install (use `--repair` in automation).
|
||||||
@@ -747,7 +748,7 @@ pair devices you trust, and review [Security](/gateway/security).
|
|||||||
|
|
||||||
Docs: [Nodes](/nodes), [Bridge protocol](/gateway/bridge-protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security).
|
Docs: [Nodes](/nodes), [Bridge protocol](/gateway/bridge-protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security).
|
||||||
|
|
||||||
### Do nodes run a gateway daemon?
|
### Do nodes run a gateway service?
|
||||||
|
|
||||||
No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect
|
No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect
|
||||||
to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
|
to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
|
||||||
@@ -839,11 +840,11 @@ You can also define inline env vars in config (applied only if missing from the
|
|||||||
|
|
||||||
See [/environment](/environment) for full precedence and sources.
|
See [/environment](/environment) for full precedence and sources.
|
||||||
|
|
||||||
### “I started the Gateway via a daemon and my env vars disappeared.” What now?
|
### “I started the Gateway via a service and my env vars disappeared.” What now?
|
||||||
|
|
||||||
Two common fixes:
|
Two common fixes:
|
||||||
|
|
||||||
1) Put the missing keys in `~/.clawdbot/.env` so they’re picked up even when the daemon doesn’t inherit your shell env.
|
1) Put the missing keys in `~/.clawdbot/.env` so they’re picked up even when the service doesn’t inherit your shell env.
|
||||||
2) Enable shell import (opt‑in convenience):
|
2) Enable shell import (opt‑in convenience):
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@@ -866,7 +867,7 @@ This runs your login shell and imports only missing expected keys (never overrid
|
|||||||
does **not** mean your env vars are missing — it just means Clawdbot won’t load
|
does **not** mean your env vars are missing — it just means Clawdbot won’t load
|
||||||
your login shell automatically.
|
your login shell automatically.
|
||||||
|
|
||||||
If the Gateway runs as a daemon (launchd/systemd), it won’t inherit your shell
|
If the Gateway runs as a service (launchd/systemd), it won’t inherit your shell
|
||||||
environment. Fix by doing one of these:
|
environment. Fix by doing one of these:
|
||||||
|
|
||||||
1) Put the token in `~/.clawdbot/.env`:
|
1) Put the token in `~/.clawdbot/.env`:
|
||||||
@@ -951,6 +952,14 @@ If it keeps happening:
|
|||||||
|
|
||||||
Docs: [Compaction](/concepts/compaction), [Session pruning](/concepts/session-pruning), [Session management](/concepts/session).
|
Docs: [Compaction](/concepts/compaction), [Session pruning](/concepts/session-pruning), [Session management](/concepts/session).
|
||||||
|
|
||||||
|
### Why am I seeing “LLM request rejected: messages.N.content.X.tool_use.input: Field required”?
|
||||||
|
|
||||||
|
This is a provider validation error: the model emitted a `tool_use` block without the required
|
||||||
|
`input`. It usually means the session history is stale or corrupted (often after long threads
|
||||||
|
or a tool/schema change).
|
||||||
|
|
||||||
|
Fix: start a fresh session with `/new` (standalone message).
|
||||||
|
|
||||||
### Why am I getting heartbeat messages every 30 minutes?
|
### Why am I getting heartbeat messages every 30 minutes?
|
||||||
|
|
||||||
Heartbeats run every **30m** by default. Tune or disable them:
|
Heartbeats run every **30m** by default. Tune or disable them:
|
||||||
@@ -1336,24 +1345,24 @@ Precedence:
|
|||||||
--port > CLAWDBOT_GATEWAY_PORT > gateway.port > default 18789
|
--port > CLAWDBOT_GATEWAY_PORT > gateway.port > default 18789
|
||||||
```
|
```
|
||||||
|
|
||||||
### Why does `clawdbot daemon status` say `Runtime: running` but `RPC probe: failed`?
|
### Why does `clawdbot gateway status` say `Runtime: running` but `RPC probe: failed`?
|
||||||
|
|
||||||
Because “running” is the **supervisor’s** view (launchd/systemd/schtasks). The RPC probe is the CLI actually connecting to the gateway WebSocket and calling `status`.
|
Because “running” is the **supervisor’s** view (launchd/systemd/schtasks). The RPC probe is the CLI actually connecting to the gateway WebSocket and calling `status`.
|
||||||
|
|
||||||
Use `clawdbot daemon status` and trust these lines:
|
Use `clawdbot gateway status` and trust these lines:
|
||||||
- `Probe target:` (the URL the probe actually used)
|
- `Probe target:` (the URL the probe actually used)
|
||||||
- `Listening:` (what’s actually bound on the port)
|
- `Listening:` (what’s actually bound on the port)
|
||||||
- `Last gateway error:` (common root cause when the process is alive but the port isn’t listening)
|
- `Last gateway error:` (common root cause when the process is alive but the port isn’t listening)
|
||||||
|
|
||||||
### Why does `clawdbot daemon status` show `Config (cli)` and `Config (daemon)` different?
|
### Why does `clawdbot gateway status` show `Config (cli)` and `Config (service)` different?
|
||||||
|
|
||||||
You’re editing one config file while the daemon is running another (often a `--profile` / `CLAWDBOT_STATE_DIR` mismatch).
|
You’re editing one config file while the service is running another (often a `--profile` / `CLAWDBOT_STATE_DIR` mismatch).
|
||||||
|
|
||||||
Fix:
|
Fix:
|
||||||
```bash
|
```bash
|
||||||
clawdbot daemon install --force
|
clawdbot gateway install --force
|
||||||
```
|
```
|
||||||
Run that from the same `--profile` / environment you want the daemon to use.
|
Run that from the same `--profile` / environment you want the service to use.
|
||||||
|
|
||||||
### What does “another gateway instance is already listening” mean?
|
### What does “another gateway instance is already listening” mean?
|
||||||
|
|
||||||
@@ -1406,7 +1415,7 @@ Fix:
|
|||||||
- Start Tailscale on that host (so it has a 100.x address), or
|
- Start Tailscale on that host (so it has a 100.x address), or
|
||||||
- Switch to `gateway.bind: "loopback"` / `"lan"`.
|
- Switch to `gateway.bind: "loopback"` / `"lan"`.
|
||||||
|
|
||||||
Note: `tailnet` is legacy and is migrated to `auto` by Doctor. Prefer `gateway.bind: "auto"` when using Tailscale.
|
Note: `tailnet` is explicit. `auto` prefers loopback; use `gateway.bind: "tailnet"` when you want a tailnet-only bind.
|
||||||
|
|
||||||
### Can I run multiple Gateways on the same host?
|
### Can I run multiple Gateways on the same host?
|
||||||
|
|
||||||
@@ -1422,7 +1431,7 @@ Yes, but you must isolate:
|
|||||||
Quick setup (recommended):
|
Quick setup (recommended):
|
||||||
- Use `clawdbot --profile <name> …` per instance (auto-creates `~/.clawdbot-<name>`).
|
- Use `clawdbot --profile <name> …` per instance (auto-creates `~/.clawdbot-<name>`).
|
||||||
- Set a unique `gateway.port` in each profile config (or pass `--port` for manual runs).
|
- Set a unique `gateway.port` in each profile config (or pass `--port` for manual runs).
|
||||||
- Install a per-profile daemon: `clawdbot --profile <name> daemon install`.
|
- Install a per-profile service: `clawdbot --profile <name> gateway install`.
|
||||||
|
|
||||||
Profiles also suffix service names (`com.clawdbot.<profile>`, `clawdbot-gateway-<profile>.service`, `Clawdbot Gateway (<profile>)`).
|
Profiles also suffix service names (`com.clawdbot.<profile>`, `clawdbot-gateway-<profile>.service`, `Clawdbot Gateway (<profile>)`).
|
||||||
Full guide: [Multiple gateways](/gateway/multiple-gateways).
|
Full guide: [Multiple gateways](/gateway/multiple-gateways).
|
||||||
@@ -1475,23 +1484,23 @@ Service/supervisor logs (when the gateway runs via launchd/systemd):
|
|||||||
|
|
||||||
See [Troubleshooting](/gateway/troubleshooting#log-locations) for more.
|
See [Troubleshooting](/gateway/troubleshooting#log-locations) for more.
|
||||||
|
|
||||||
### How do I start/stop/restart the Gateway daemon?
|
### How do I start/stop/restart the Gateway service?
|
||||||
|
|
||||||
Use the daemon helpers:
|
Use the gateway helpers:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot daemon status
|
clawdbot gateway status
|
||||||
clawdbot daemon restart
|
clawdbot gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway).
|
If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway).
|
||||||
|
|
||||||
### ELI5: `clawdbot daemon restart` vs `clawdbot gateway`
|
### ELI5: `clawdbot gateway restart` vs `clawdbot gateway`
|
||||||
|
|
||||||
- `clawdbot daemon restart`: restarts the **background service** (launchd/systemd).
|
- `clawdbot gateway restart`: restarts the **background service** (launchd/systemd).
|
||||||
- `clawdbot gateway`: runs the gateway **in the foreground** for this terminal session.
|
- `clawdbot gateway`: runs the gateway **in the foreground** for this terminal session.
|
||||||
|
|
||||||
If you installed the daemon, use the daemon commands. Use `clawdbot gateway` when
|
If you installed the service, use the gateway commands. Use `clawdbot gateway` when
|
||||||
you want a one-off, foreground run.
|
you want a one-off, foreground run.
|
||||||
|
|
||||||
### What’s the fastest way to get more details when something fails?
|
### What’s the fastest way to get more details when something fails?
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It set
|
|||||||
- channels (WhatsApp/Telegram/Discord/…)
|
- channels (WhatsApp/Telegram/Discord/…)
|
||||||
- pairing defaults (secure DMs)
|
- pairing defaults (secure DMs)
|
||||||
- workspace bootstrap + skills
|
- workspace bootstrap + skills
|
||||||
- optional background daemon
|
- optional background service
|
||||||
|
|
||||||
If you want the deeper reference pages, jump to: [Wizard](/start/wizard), [Setup](/start/setup), [Pairing](/start/pairing), [Security](/gateway/security).
|
If you want the deeper reference pages, jump to: [Wizard](/start/wizard), [Setup](/start/setup), [Pairing](/start/pairing), [Security](/gateway/security).
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ npm install -g clawdbot@latest
|
|||||||
pnpm add -g clawdbot@latest
|
pnpm add -g clawdbot@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2) Run the onboarding wizard (and install the daemon)
|
## 2) Run the onboarding wizard (and install the service)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot onboard --install-daemon
|
clawdbot onboard --install-daemon
|
||||||
@@ -89,7 +89,7 @@ Wizard doc: [Wizard](/start/wizard)
|
|||||||
|
|
||||||
### Auth: where it lives (important)
|
### Auth: where it lives (important)
|
||||||
|
|
||||||
- **Recommended Anthropic path:** set an API key (wizard can store it for daemon use). `claude setup-token` is also supported if you want to reuse Claude Code credentials.
|
- **Recommended Anthropic path:** set an API key (wizard can store it for service use). `claude setup-token` is also supported if you want to reuse Claude Code credentials.
|
||||||
|
|
||||||
- OAuth credentials (legacy import): `~/.clawdbot/credentials/oauth.json`
|
- OAuth credentials (legacy import): `~/.clawdbot/credentials/oauth.json`
|
||||||
- Auth profiles (OAuth + API keys): `~/.clawdbot/agents/<agentId>/agent/auth-profiles.json`
|
- Auth profiles (OAuth + API keys): `~/.clawdbot/agents/<agentId>/agent/auth-profiles.json`
|
||||||
@@ -98,10 +98,10 @@ Headless/server tip: do OAuth on a normal machine first, then copy `oauth.json`
|
|||||||
|
|
||||||
## 3) Start the Gateway
|
## 3) Start the Gateway
|
||||||
|
|
||||||
If you installed the daemon during onboarding, the Gateway should already be running:
|
If you installed the service during onboarding, the Gateway should already be running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot daemon status
|
clawdbot gateway status
|
||||||
```
|
```
|
||||||
|
|
||||||
Manual run (foreground):
|
Manual run (foreground):
|
||||||
|
|||||||
@@ -60,6 +60,28 @@ Full setup walkthrough (28m) by VelvetShark.
|
|||||||
|
|
||||||
[Watch on YouTube](https://www.youtube.com/watch?v=mMSKQvlmFuQ)
|
[Watch on YouTube](https://www.youtube.com/watch?v=mMSKQvlmFuQ)
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
paddingBottom: "56.25%",
|
||||||
|
height: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
src="https://www.youtube-nocookie.com/embed/5kkIJNUGFho"
|
||||||
|
title="Clawdbot community showcase"
|
||||||
|
style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%" }}
|
||||||
|
frameBorder="0"
|
||||||
|
loading="lazy"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
[Watch on YouTube](https://www.youtube.com/watch?v=5kkIJNUGFho)
|
||||||
|
|
||||||
## 🆕 Fresh from Discord
|
## 🆕 Fresh from Discord
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
<CardGroup cols={2}>
|
||||||
|
|||||||
@@ -65,6 +65,41 @@ These are **USD per 1M tokens** for `input`, `output`, `cacheRead`, and
|
|||||||
`cacheWrite`. If pricing is missing, Clawdbot shows tokens only. OAuth tokens
|
`cacheWrite`. If pricing is missing, Clawdbot shows tokens only. OAuth tokens
|
||||||
never show dollar cost.
|
never show dollar cost.
|
||||||
|
|
||||||
|
## Cache TTL and pruning impact
|
||||||
|
|
||||||
|
Provider prompt caching only applies within the cache TTL window. Clawdbot can
|
||||||
|
optionally run **cache-ttl pruning**: it prunes the session once the cache TTL
|
||||||
|
has expired, then resets the cache window so subsequent requests can re-use the
|
||||||
|
freshly cached context instead of re-caching the full history. This keeps cache
|
||||||
|
write costs lower when a session goes idle past the TTL.
|
||||||
|
|
||||||
|
Configure it in [Gateway configuration](/gateway/configuration) and see the
|
||||||
|
behavior details in [Session pruning](/concepts/session-pruning).
|
||||||
|
|
||||||
|
Heartbeat can keep the cache **warm** across idle gaps. If your model cache TTL
|
||||||
|
is `1h`, setting the heartbeat interval just under that (e.g., `55m`) can avoid
|
||||||
|
re-caching the full prompt, reducing cache write costs.
|
||||||
|
|
||||||
|
For Anthropic API pricing, cache reads are significantly cheaper than input
|
||||||
|
tokens, while cache writes are billed at a higher multiplier. See Anthropic’s
|
||||||
|
prompt caching pricing for the latest rates and TTL multipliers:
|
||||||
|
https://docs.anthropic.com/docs/build-with-claude/prompt-caching
|
||||||
|
|
||||||
|
### Example: keep 1h cache warm with heartbeat
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
agents:
|
||||||
|
defaults:
|
||||||
|
model:
|
||||||
|
primary: "anthropic/claude-opus-4-5"
|
||||||
|
models:
|
||||||
|
"anthropic/claude-opus-4-5":
|
||||||
|
params:
|
||||||
|
cacheControlTtl: "1h"
|
||||||
|
heartbeat:
|
||||||
|
every: "55m"
|
||||||
|
```
|
||||||
|
|
||||||
## Tips for reducing token pressure
|
## Tips for reducing token pressure
|
||||||
|
|
||||||
- Use `/compact` to summarize long sessions.
|
- Use `/compact` to summarize long sessions.
|
||||||
|
|||||||
+2
-2
@@ -83,7 +83,7 @@ current limits and pricing.
|
|||||||
`~/.clawdbot/clawdbot.json` under `tools.web.search.apiKey`.
|
`~/.clawdbot/clawdbot.json` under `tools.web.search.apiKey`.
|
||||||
|
|
||||||
**Environment alternative:** set `BRAVE_API_KEY` in the Gateway process
|
**Environment alternative:** set `BRAVE_API_KEY` in the Gateway process
|
||||||
environment. For a daemon install, put it in `~/.clawdbot/.env` (or your
|
environment. For a gateway install, put it in `~/.clawdbot/.env` (or your
|
||||||
service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables).
|
service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables).
|
||||||
|
|
||||||
## Using Perplexity (direct or via OpenRouter)
|
## Using Perplexity (direct or via OpenRouter)
|
||||||
@@ -122,7 +122,7 @@ crypto/prepaid).
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Environment alternative:** set `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` in the Gateway
|
**Environment alternative:** set `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` in the Gateway
|
||||||
environment. For a daemon install, put it in `~/.clawdbot/.env`.
|
environment. For a gateway install, put it in `~/.clawdbot/.env`.
|
||||||
|
|
||||||
If no base URL is set, Clawdbot chooses a default based on the API key source:
|
If no base URL is set, Clawdbot chooses a default based on the API key source:
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
resolveDefaultSlackAccountId,
|
resolveDefaultSlackAccountId,
|
||||||
resolveSlackAccount,
|
resolveSlackAccount,
|
||||||
resolveSlackGroupRequireMention,
|
resolveSlackGroupRequireMention,
|
||||||
|
buildSlackThreadingToolContext,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
slackOnboardingAdapter,
|
slackOnboardingAdapter,
|
||||||
SlackConfigSchema,
|
SlackConfigSchema,
|
||||||
@@ -164,18 +165,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
|||||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
||||||
allowTagsWhenOff: true,
|
allowTagsWhenOff: true,
|
||||||
buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => {
|
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||||
const configuredReplyToMode = resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off";
|
|
||||||
const effectiveReplyToMode = context.ThreadLabel ? "all" : configuredReplyToMode;
|
|
||||||
return {
|
|
||||||
currentChannelId: context.To?.startsWith("channel:")
|
|
||||||
? context.To.slice("channel:".length)
|
|
||||||
: undefined,
|
|
||||||
currentThreadTs: context.ReplyToId,
|
|
||||||
replyToMode: effectiveReplyToMode,
|
|
||||||
hasRepliedRef,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
messaging: {
|
messaging: {
|
||||||
normalizeTarget: normalizeSlackMessagingTarget,
|
normalizeTarget: normalizeSlackMessagingTarget,
|
||||||
|
|||||||
+1
-4
@@ -147,7 +147,7 @@
|
|||||||
"packageManager": "pnpm@10.23.0",
|
"packageManager": "pnpm@10.23.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "0.13.0",
|
"@agentclientprotocol/sdk": "0.13.0",
|
||||||
"@buape/carbon": "0.0.0-beta-20260110172854",
|
"@buape/carbon": "0.14.0",
|
||||||
"@clack/prompts": "^0.11.0",
|
"@clack/prompts": "^0.11.0",
|
||||||
"@grammyjs/runner": "^2.0.3",
|
"@grammyjs/runner": "^2.0.3",
|
||||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||||
@@ -236,9 +236,6 @@
|
|||||||
"@sinclair/typebox": "0.34.47",
|
"@sinclair/typebox": "0.34.47",
|
||||||
"hono": "4.11.4",
|
"hono": "4.11.4",
|
||||||
"tar": "7.5.4"
|
"tar": "7.5.4"
|
||||||
},
|
|
||||||
"patchedDependencies": {
|
|
||||||
"@mariozechner/pi-ai@0.49.2": "patches/@mariozechner__pi-ai@0.49.2.patch"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vitest": {
|
"vitest": {
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
diff --git a/dist/providers/anthropic.js b/dist/providers/anthropic.js
|
|
||||||
index 1cba2f1365812fd2f88993009c9cc06e9c348279..664dd6d8b400ec523fb735480741b9ad64f9a68c 100644
|
|
||||||
--- a/dist/providers/anthropic.js
|
|
||||||
+++ b/dist/providers/anthropic.js
|
|
||||||
@@ -298,10 +298,11 @@ function createClient(model, apiKey, interleavedThinking) {
|
|
||||||
});
|
|
||||||
return { client, isOAuthToken: true };
|
|
||||||
}
|
|
||||||
+ const apiBetaFeatures = ["extended-cache-ttl-2025-04-11", ...betaFeatures];
|
|
||||||
const defaultHeaders = {
|
|
||||||
accept: "application/json",
|
|
||||||
"anthropic-dangerous-direct-browser-access": "true",
|
|
||||||
- "anthropic-beta": betaFeatures.join(","),
|
|
||||||
+ "anthropic-beta": apiBetaFeatures.join(","),
|
|
||||||
...(model.headers || {}),
|
|
||||||
};
|
|
||||||
const client = new Anthropic({
|
|
||||||
@@ -313,9 +314,11 @@ function createClient(model, apiKey, interleavedThinking) {
|
|
||||||
return { client, isOAuthToken: false };
|
|
||||||
}
|
|
||||||
function buildParams(model, context, isOAuthToken, options) {
|
|
||||||
+ const cacheControlTtl = !isOAuthToken ? (options?.cacheControlTtl ?? "1h") : undefined;
|
|
||||||
+ const cacheControl = cacheControlTtl ? { type: "ephemeral", ttl: cacheControlTtl } : { type: "ephemeral" };
|
|
||||||
const params = {
|
|
||||||
model: model.id,
|
|
||||||
- messages: convertMessages(context.messages, model, isOAuthToken),
|
|
||||||
+ messages: convertMessages(context.messages, model, isOAuthToken, cacheControl),
|
|
||||||
max_tokens: options?.maxTokens || (model.maxTokens / 3) | 0,
|
|
||||||
stream: true,
|
|
||||||
};
|
|
||||||
@@ -325,18 +328,14 @@ function buildParams(model, context, isOAuthToken, options) {
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "You are Claude Code, Anthropic's official CLI for Claude.",
|
|
||||||
- cache_control: {
|
|
||||||
- type: "ephemeral",
|
|
||||||
- },
|
|
||||||
+ cache_control: cacheControl,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
if (context.systemPrompt) {
|
|
||||||
params.system.push({
|
|
||||||
type: "text",
|
|
||||||
text: sanitizeSurrogates(context.systemPrompt),
|
|
||||||
- cache_control: {
|
|
||||||
- type: "ephemeral",
|
|
||||||
- },
|
|
||||||
+ cache_control: cacheControl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -346,9 +345,7 @@ function buildParams(model, context, isOAuthToken, options) {
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: sanitizeSurrogates(context.systemPrompt),
|
|
||||||
- cache_control: {
|
|
||||||
- type: "ephemeral",
|
|
||||||
- },
|
|
||||||
+ cache_control: cacheControl,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -378,7 +375,7 @@ function buildParams(model, context, isOAuthToken, options) {
|
|
||||||
function normalizeToolCallId(id) {
|
|
||||||
return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
|
|
||||||
}
|
|
||||||
-function convertMessages(messages, model, isOAuthToken) {
|
|
||||||
+function convertMessages(messages, model, isOAuthToken, cacheControl) {
|
|
||||||
const params = [];
|
|
||||||
// Transform messages for cross-provider compatibility
|
|
||||||
const transformedMessages = transformMessages(messages, model, normalizeToolCallId);
|
|
||||||
@@ -514,7 +511,7 @@ function convertMessages(messages, model, isOAuthToken) {
|
|
||||||
const lastBlock = lastMessage.content[lastMessage.content.length - 1];
|
|
||||||
if (lastBlock &&
|
|
||||||
(lastBlock.type === "text" || lastBlock.type === "image" || lastBlock.type === "tool_result")) {
|
|
||||||
- lastBlock.cache_control = { type: "ephemeral" };
|
|
||||||
+ lastBlock.cache_control = cacheControl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
diff --git a/dist/providers/openai-completions.js b/dist/providers/openai-completions.js
|
|
||||||
index ee5c88d8e280ceeff45ed075f2c7357d40005578..89daad7b0e53753e094028291226d32da9446440 100644
|
|
||||||
--- a/dist/providers/openai-completions.js
|
|
||||||
+++ b/dist/providers/openai-completions.js
|
|
||||||
@@ -305,7 +305,7 @@ function createClient(model, context, apiKey) {
|
|
||||||
function buildParams(model, context, options) {
|
|
||||||
const compat = getCompat(model);
|
|
||||||
const messages = convertMessages(model, context, compat);
|
|
||||||
- maybeAddOpenRouterAnthropicCacheControl(model, messages);
|
|
||||||
+ maybeAddOpenRouterAnthropicCacheControl(model, messages, options?.cacheControlTtl);
|
|
||||||
const params = {
|
|
||||||
model: model.id,
|
|
||||||
messages,
|
|
||||||
@@ -349,9 +349,10 @@ function buildParams(model, context, options) {
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
-function maybeAddOpenRouterAnthropicCacheControl(model, messages) {
|
|
||||||
+function maybeAddOpenRouterAnthropicCacheControl(model, messages, cacheControlTtl) {
|
|
||||||
if (model.provider !== "openrouter" || !model.id.startsWith("anthropic/"))
|
|
||||||
return;
|
|
||||||
+ const cacheControl = cacheControlTtl ? { type: "ephemeral", ttl: cacheControlTtl } : { type: "ephemeral" };
|
|
||||||
// Anthropic-style caching requires cache_control on a text part. Add a breakpoint
|
|
||||||
// on the last user/assistant message (walking backwards until we find text content).
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
|
||||||
@@ -361,7 +362,7 @@ function maybeAddOpenRouterAnthropicCacheControl(model, messages) {
|
|
||||||
const content = msg.content;
|
|
||||||
if (typeof content === "string") {
|
|
||||||
msg.content = [
|
|
||||||
- Object.assign({ type: "text", text: content }, { cache_control: { type: "ephemeral" } }),
|
|
||||||
+ Object.assign({ type: "text", text: content }, { cache_control: cacheControl }),
|
|
||||||
];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
@@ -371,7 +372,7 @@ function maybeAddOpenRouterAnthropicCacheControl(model, messages) {
|
|
||||||
for (let j = content.length - 1; j >= 0; j--) {
|
|
||||||
const part = content[j];
|
|
||||||
if (part?.type === "text") {
|
|
||||||
- Object.assign(part, { cache_control: { type: "ephemeral" } });
|
|
||||||
+ Object.assign(part, { cache_control: cacheControl });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
diff --git a/dist/stream.js b/dist/stream.js
|
|
||||||
index d23fdd9f226a949fac4f2c7160af76f7f5fe71d1..3500f074bd88b85f4c7dd9bf42279f80fdf264d1 100644
|
|
||||||
--- a/dist/stream.js
|
|
||||||
+++ b/dist/stream.js
|
|
||||||
@@ -146,6 +146,7 @@ function mapOptionsForApi(model, options, apiKey) {
|
|
||||||
signal: options?.signal,
|
|
||||||
apiKey: apiKey || options?.apiKey,
|
|
||||||
sessionId: options?.sessionId,
|
|
||||||
+ cacheControlTtl: options?.cacheControlTtl,
|
|
||||||
};
|
|
||||||
// Helper to clamp xhigh to high for providers that don't support it
|
|
||||||
const clampReasoning = (effort) => (effort === "xhigh" ? "high" : effort);
|
|
||||||
Generated
+28
-60
@@ -9,11 +9,6 @@ overrides:
|
|||||||
hono: 4.11.4
|
hono: 4.11.4
|
||||||
tar: 7.5.4
|
tar: 7.5.4
|
||||||
|
|
||||||
patchedDependencies:
|
|
||||||
'@mariozechner/pi-ai@0.49.2':
|
|
||||||
hash: 4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e
|
|
||||||
path: patches/@mariozechner__pi-ai@0.49.2.patch
|
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -22,8 +17,8 @@ importers:
|
|||||||
specifier: 0.13.0
|
specifier: 0.13.0
|
||||||
version: 0.13.0(zod@4.3.5)
|
version: 0.13.0(zod@4.3.5)
|
||||||
'@buape/carbon':
|
'@buape/carbon':
|
||||||
specifier: 0.0.0-beta-20260110172854
|
specifier: 0.14.0
|
||||||
version: 0.0.0-beta-20260110172854(hono@4.11.4)
|
version: 0.14.0(hono@4.11.4)
|
||||||
'@clack/prompts':
|
'@clack/prompts':
|
||||||
specifier: ^0.11.0
|
specifier: ^0.11.0
|
||||||
version: 0.11.0
|
version: 0.11.0
|
||||||
@@ -44,7 +39,7 @@ importers:
|
|||||||
version: 0.49.2(ws@8.19.0)(zod@4.3.5)
|
version: 0.49.2(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-ai':
|
'@mariozechner/pi-ai':
|
||||||
specifier: 0.49.2
|
specifier: 0.49.2
|
||||||
version: 0.49.2(patch_hash=4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e)(ws@8.19.0)(zod@4.3.5)
|
version: 0.49.2(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-coding-agent':
|
'@mariozechner/pi-coding-agent':
|
||||||
specifier: 0.49.2
|
specifier: 0.49.2
|
||||||
version: 0.49.2(ws@8.19.0)(zod@4.3.5)
|
version: 0.49.2(ws@8.19.0)(zod@4.3.5)
|
||||||
@@ -647,8 +642,8 @@ packages:
|
|||||||
'@borewit/text-codec@0.2.1':
|
'@borewit/text-codec@0.2.1':
|
||||||
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
|
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
|
||||||
|
|
||||||
'@buape/carbon@0.0.0-beta-20260110172854':
|
'@buape/carbon@0.14.0':
|
||||||
resolution: {integrity: sha512-hYUr5Glz+oDEQbl58r6AExqM4yObZCjB8JwXit7chn0LIkb5WDFP4Rj1taP3OP+PgwB7GHOVSbnCnBPBzUxpRw==}
|
resolution: {integrity: sha512-mavllPK2iVpRNRtC4C8JOUdJ1hdV0+LDelFW+pjpJaM31MBLMfIJ+f/LlYTIK5QrEcQsXOC+6lU2e0gmgjWhIQ==}
|
||||||
|
|
||||||
'@cacheable/memory@2.0.7':
|
'@cacheable/memory@2.0.7':
|
||||||
resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==}
|
resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==}
|
||||||
@@ -666,8 +661,8 @@ packages:
|
|||||||
'@clack/prompts@0.11.0':
|
'@clack/prompts@0.11.0':
|
||||||
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
|
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
|
||||||
|
|
||||||
'@cloudflare/workers-types@4.20251205.0':
|
'@cloudflare/workers-types@4.20260120.0':
|
||||||
resolution: {integrity: sha512-7pup7fYkuQW5XD8RUS/vkxF9SXlrGyCXuZ4ro3uVQvca/GTeSa+8bZ8T4wbq1Aea5lmLIGSlKbhl2msME7bRBA==}
|
resolution: {integrity: sha512-B8pueG+a5S+mdK3z8oKu1ShcxloZ7qWb68IEyLLaepvdryIbNC7JVPcY0bWsjS56UQVKc5fnyRge3yZIwc9bxw==}
|
||||||
|
|
||||||
'@discordjs/voice@0.19.0':
|
'@discordjs/voice@0.19.0':
|
||||||
resolution: {integrity: sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==}
|
resolution: {integrity: sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==}
|
||||||
@@ -887,8 +882,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-qK6ZgGx0wwOubq/MY6eTbhApQHBUQCvCOsTYpQE01uLvfA2/Prm6egySHlZouKaina1RPuDwfLhCmsRCxwHj3Q==}
|
resolution: {integrity: sha512-qK6ZgGx0wwOubq/MY6eTbhApQHBUQCvCOsTYpQE01uLvfA2/Prm6egySHlZouKaina1RPuDwfLhCmsRCxwHj3Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@hono/node-server@1.19.6':
|
'@hono/node-server@1.19.9':
|
||||||
resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==}
|
resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==}
|
||||||
engines: {node: '>=18.14.1'}
|
engines: {node: '>=18.14.1'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
hono: 4.11.4
|
hono: 4.11.4
|
||||||
@@ -2425,8 +2420,8 @@ packages:
|
|||||||
'@types/body-parser@1.19.6':
|
'@types/body-parser@1.19.6':
|
||||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||||
|
|
||||||
'@types/bun@1.3.3':
|
'@types/bun@1.3.6':
|
||||||
resolution: {integrity: sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==}
|
resolution: {integrity: sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==}
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
@@ -2491,9 +2486,6 @@ packages:
|
|||||||
'@types/node@20.19.30':
|
'@types/node@20.19.30':
|
||||||
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
||||||
|
|
||||||
'@types/node@24.10.9':
|
|
||||||
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
|
|
||||||
|
|
||||||
'@types/node@25.0.9':
|
'@types/node@25.0.9':
|
||||||
resolution: {integrity: sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==}
|
resolution: {integrity: sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==}
|
||||||
|
|
||||||
@@ -2878,8 +2870,8 @@ packages:
|
|||||||
buffer@6.0.3:
|
buffer@6.0.3:
|
||||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||||
|
|
||||||
bun-types@1.3.3:
|
bun-types@1.3.6:
|
||||||
resolution: {integrity: sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==}
|
resolution: {integrity: sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ==}
|
||||||
|
|
||||||
bytes@3.1.2:
|
bytes@3.1.2:
|
||||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||||
@@ -3130,9 +3122,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
|
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
|
||||||
engines: {node: '>=0.3.1'}
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
discord-api-types@0.38.36:
|
|
||||||
resolution: {integrity: sha512-qrbUbjjwtyeBg5HsAlm1C859epfOyiLjPqAOzkdWlCNsZCWJrertnETF/NwM8H+waMFU58xGSc5eXUfXah+WTQ==}
|
|
||||||
|
|
||||||
discord-api-types@0.38.37:
|
discord-api-types@0.38.37:
|
||||||
resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==}
|
resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==}
|
||||||
|
|
||||||
@@ -5229,18 +5218,6 @@ packages:
|
|||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
ws@8.18.3:
|
|
||||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
|
||||||
engines: {node: '>=10.0.0'}
|
|
||||||
peerDependencies:
|
|
||||||
bufferutil: ^4.0.1
|
|
||||||
utf-8-validate: '>=5.0.2'
|
|
||||||
peerDependenciesMeta:
|
|
||||||
bufferutil:
|
|
||||||
optional: true
|
|
||||||
utf-8-validate:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
ws@8.19.0:
|
ws@8.19.0:
|
||||||
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
|
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
@@ -5777,17 +5754,17 @@ snapshots:
|
|||||||
|
|
||||||
'@borewit/text-codec@0.2.1': {}
|
'@borewit/text-codec@0.2.1': {}
|
||||||
|
|
||||||
'@buape/carbon@0.0.0-beta-20260110172854(hono@4.11.4)':
|
'@buape/carbon@0.14.0(hono@4.11.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.10.9
|
'@types/node': 25.0.9
|
||||||
discord-api-types: 0.38.36
|
discord-api-types: 0.38.37
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@cloudflare/workers-types': 4.20251205.0
|
'@cloudflare/workers-types': 4.20260120.0
|
||||||
'@discordjs/voice': 0.19.0
|
'@discordjs/voice': 0.19.0
|
||||||
'@hono/node-server': 1.19.6(hono@4.11.4)
|
'@hono/node-server': 1.19.9(hono@4.11.4)
|
||||||
'@types/bun': 1.3.3
|
'@types/bun': 1.3.6
|
||||||
'@types/ws': 8.18.1
|
'@types/ws': 8.18.1
|
||||||
ws: 8.18.3
|
ws: 8.19.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@discordjs/opus'
|
- '@discordjs/opus'
|
||||||
- bufferutil
|
- bufferutil
|
||||||
@@ -5826,7 +5803,7 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
sisteransi: 1.0.5
|
sisteransi: 1.0.5
|
||||||
|
|
||||||
'@cloudflare/workers-types@4.20251205.0':
|
'@cloudflare/workers-types@4.20260120.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@discordjs/voice@0.19.0':
|
'@discordjs/voice@0.19.0':
|
||||||
@@ -5992,7 +5969,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@hono/node-server@1.19.6(hono@4.11.4)':
|
'@hono/node-server@1.19.9(hono@4.11.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
hono: 4.11.4
|
hono: 4.11.4
|
||||||
optional: true
|
optional: true
|
||||||
@@ -6283,7 +6260,7 @@ snapshots:
|
|||||||
|
|
||||||
'@mariozechner/pi-agent-core@0.49.2(ws@8.19.0)(zod@4.3.5)':
|
'@mariozechner/pi-agent-core@0.49.2(ws@8.19.0)(zod@4.3.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mariozechner/pi-ai': 0.49.2(patch_hash=4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e)(ws@8.19.0)(zod@4.3.5)
|
'@mariozechner/pi-ai': 0.49.2(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-tui': 0.49.2
|
'@mariozechner/pi-tui': 0.49.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@modelcontextprotocol/sdk'
|
- '@modelcontextprotocol/sdk'
|
||||||
@@ -6294,7 +6271,7 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
'@mariozechner/pi-ai@0.49.2(patch_hash=4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e)(ws@8.19.0)(zod@4.3.5)':
|
'@mariozechner/pi-ai@0.49.2(ws@8.19.0)(zod@4.3.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
|
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
|
||||||
'@aws-sdk/client-bedrock-runtime': 3.971.0
|
'@aws-sdk/client-bedrock-runtime': 3.971.0
|
||||||
@@ -6321,7 +6298,7 @@ snapshots:
|
|||||||
'@mariozechner/clipboard': 0.3.0
|
'@mariozechner/clipboard': 0.3.0
|
||||||
'@mariozechner/jiti': 2.6.5
|
'@mariozechner/jiti': 2.6.5
|
||||||
'@mariozechner/pi-agent-core': 0.49.2(ws@8.19.0)(zod@4.3.5)
|
'@mariozechner/pi-agent-core': 0.49.2(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-ai': 0.49.2(patch_hash=4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e)(ws@8.19.0)(zod@4.3.5)
|
'@mariozechner/pi-ai': 0.49.2(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-tui': 0.49.2
|
'@mariozechner/pi-tui': 0.49.2
|
||||||
'@silvia-odwyer/photon-node': 0.3.4
|
'@silvia-odwyer/photon-node': 0.3.4
|
||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
@@ -7613,9 +7590,9 @@ snapshots:
|
|||||||
'@types/connect': 3.4.38
|
'@types/connect': 3.4.38
|
||||||
'@types/node': 25.0.9
|
'@types/node': 25.0.9
|
||||||
|
|
||||||
'@types/bun@1.3.3':
|
'@types/bun@1.3.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
bun-types: 1.3.3
|
bun-types: 1.3.6
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
@@ -7692,10 +7669,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
'@types/node@24.10.9':
|
|
||||||
dependencies:
|
|
||||||
undici-types: 7.16.0
|
|
||||||
|
|
||||||
'@types/node@25.0.9':
|
'@types/node@25.0.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
@@ -8151,7 +8124,7 @@ snapshots:
|
|||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
ieee754: 1.2.1
|
ieee754: 1.2.1
|
||||||
|
|
||||||
bun-types@1.3.3:
|
bun-types@1.3.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 25.0.9
|
'@types/node': 25.0.9
|
||||||
optional: true
|
optional: true
|
||||||
@@ -8398,8 +8371,6 @@ snapshots:
|
|||||||
|
|
||||||
diff@8.0.3: {}
|
diff@8.0.3: {}
|
||||||
|
|
||||||
discord-api-types@0.38.36: {}
|
|
||||||
|
|
||||||
discord-api-types@0.38.37: {}
|
discord-api-types@0.38.37: {}
|
||||||
|
|
||||||
docx-preview@0.3.7:
|
docx-preview@0.3.7:
|
||||||
@@ -10787,9 +10758,6 @@ snapshots:
|
|||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
ws@8.18.3:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
ws@8.19.0: {}
|
ws@8.19.0: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|||||||
@@ -207,6 +207,15 @@ echo "📦 Copying device model resources"
|
|||||||
rm -rf "$APP_ROOT/Contents/Resources/DeviceModels"
|
rm -rf "$APP_ROOT/Contents/Resources/DeviceModels"
|
||||||
cp -R "$ROOT_DIR/apps/macos/Sources/Clawdbot/Resources/DeviceModels" "$APP_ROOT/Contents/Resources/DeviceModels"
|
cp -R "$ROOT_DIR/apps/macos/Sources/Clawdbot/Resources/DeviceModels" "$APP_ROOT/Contents/Resources/DeviceModels"
|
||||||
|
|
||||||
|
echo "📦 Copying model catalog"
|
||||||
|
MODEL_CATALOG_SRC="$ROOT_DIR/node_modules/@mariozechner/pi-ai/dist/models.generated.js"
|
||||||
|
MODEL_CATALOG_DEST="$APP_ROOT/Contents/Resources/models.generated.js"
|
||||||
|
if [ -f "$MODEL_CATALOG_SRC" ]; then
|
||||||
|
cp "$MODEL_CATALOG_SRC" "$MODEL_CATALOG_DEST"
|
||||||
|
else
|
||||||
|
echo "WARN: model catalog missing at $MODEL_CATALOG_SRC (continuing)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
echo "📦 Copying ClawdbotKit resources"
|
echo "📦 Copying ClawdbotKit resources"
|
||||||
CLAWDBOTKIT_BUNDLE="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG/ClawdbotKit_ClawdbotKit.bundle"
|
CLAWDBOTKIT_BUNDLE="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG/ClawdbotKit_ClawdbotKit.bundle"
|
||||||
if [ -d "$CLAWDBOTKIT_BUNDLE" ]; then
|
if [ -d "$CLAWDBOTKIT_BUNDLE" ]; then
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { downgradeGeminiHistory } from "./pi-embedded-helpers.js";
|
|
||||||
|
|
||||||
describe("downgradeGeminiHistory", () => {
|
|
||||||
it("drops unsigned tool calls and matching tool results", () => {
|
|
||||||
const input = [
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: "hello" },
|
|
||||||
{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "/tmp" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "toolResult",
|
|
||||||
toolCallId: "call_1",
|
|
||||||
content: [{ type: "text", text: "ok" }],
|
|
||||||
},
|
|
||||||
{ role: "user", content: "next" },
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(downgradeGeminiHistory(input)).toEqual([
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text: "hello" }],
|
|
||||||
},
|
|
||||||
{ role: "user", content: "next" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps signed tool calls and results", () => {
|
|
||||||
const input = [
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "toolCall",
|
|
||||||
id: "call_2",
|
|
||||||
name: "read",
|
|
||||||
arguments: { path: "/tmp" },
|
|
||||||
thought_signature: "sig_123",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "toolResult",
|
|
||||||
toolCallId: "call_2",
|
|
||||||
content: [{ type: "text", text: "ok" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(downgradeGeminiHistory(input)).toEqual(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops assistant messages that only contain unsigned tool calls", () => {
|
|
||||||
const input = [
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "toolCall", id: "call_3", name: "read", arguments: {} }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "toolResult",
|
|
||||||
toolCallId: "call_3",
|
|
||||||
content: [{ type: "text", text: "ok" }],
|
|
||||||
},
|
|
||||||
{ role: "user", content: "after" },
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(downgradeGeminiHistory(input)).toEqual([{ role: "user", content: "after" }]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -30,12 +30,7 @@ export {
|
|||||||
isTimeoutErrorMessage,
|
isTimeoutErrorMessage,
|
||||||
parseImageDimensionError,
|
parseImageDimensionError,
|
||||||
} from "./pi-embedded-helpers/errors.js";
|
} from "./pi-embedded-helpers/errors.js";
|
||||||
export {
|
export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js";
|
||||||
downgradeGeminiHistory,
|
|
||||||
downgradeGeminiThinkingBlocks,
|
|
||||||
isGoogleModelApi,
|
|
||||||
sanitizeGoogleTurnOrdering,
|
|
||||||
} from "./pi-embedded-helpers/google.js";
|
|
||||||
export {
|
export {
|
||||||
isEmptyAssistantMessageContent,
|
isEmptyAssistantMessageContent,
|
||||||
sanitizeSessionMessagesImages,
|
sanitizeSessionMessagesImages,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
||||||
|
|
||||||
import { sanitizeGoogleTurnOrdering } from "./bootstrap.js";
|
import { sanitizeGoogleTurnOrdering } from "./bootstrap.js";
|
||||||
|
|
||||||
export function isGoogleModelApi(api?: string | null): boolean {
|
export function isGoogleModelApi(api?: string | null): boolean {
|
||||||
@@ -14,145 +12,3 @@ export function isAntigravityClaude(api?: string | null, modelId?: string): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { sanitizeGoogleTurnOrdering };
|
export { sanitizeGoogleTurnOrdering };
|
||||||
|
|
||||||
/**
|
|
||||||
* Drops tool calls that are missing `thought_signature` (required by Gemini)
|
|
||||||
* to prevent 400 INVALID_ARGUMENT errors. Matching tool results are dropped
|
|
||||||
* so they don't become orphaned in the transcript.
|
|
||||||
*/
|
|
||||||
type GeminiToolCallBlock = {
|
|
||||||
type?: unknown;
|
|
||||||
thought_signature?: unknown;
|
|
||||||
thoughtSignature?: unknown;
|
|
||||||
id?: unknown;
|
|
||||||
toolCallId?: unknown;
|
|
||||||
name?: unknown;
|
|
||||||
toolName?: unknown;
|
|
||||||
arguments?: unknown;
|
|
||||||
input?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GeminiThinkingBlock = {
|
|
||||||
type?: unknown;
|
|
||||||
thinking?: unknown;
|
|
||||||
thinkingSignature?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function downgradeGeminiThinkingBlocks(messages: AgentMessage[]): AgentMessage[] {
|
|
||||||
const out: AgentMessage[] = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (!msg || typeof msg !== "object") {
|
|
||||||
out.push(msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const role = (msg as { role?: unknown }).role;
|
|
||||||
if (role !== "assistant") {
|
|
||||||
out.push(msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
|
|
||||||
if (!Array.isArray(assistantMsg.content)) {
|
|
||||||
out.push(msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini rejects thinking blocks that lack a signature; downgrade to text for safety.
|
|
||||||
let hasDowngraded = false;
|
|
||||||
type AssistantContentBlock = (typeof assistantMsg.content)[number];
|
|
||||||
const nextContent = assistantMsg.content.flatMap((block): AssistantContentBlock[] => {
|
|
||||||
if (!block || typeof block !== "object") return [block as AssistantContentBlock];
|
|
||||||
const record = block as GeminiThinkingBlock;
|
|
||||||
if (record.type !== "thinking") return [block];
|
|
||||||
const thinkingSig =
|
|
||||||
typeof record.thinkingSignature === "string" ? record.thinkingSignature.trim() : "";
|
|
||||||
if (thinkingSig.length > 0) return [block];
|
|
||||||
const thinking = typeof record.thinking === "string" ? record.thinking : "";
|
|
||||||
const trimmed = thinking.trim();
|
|
||||||
hasDowngraded = true;
|
|
||||||
if (!trimmed) return [];
|
|
||||||
return [{ type: "text" as const, text: thinking }];
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hasDowngraded) {
|
|
||||||
out.push(msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (nextContent.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.push({ ...assistantMsg, content: nextContent } as AgentMessage);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[] {
|
|
||||||
const droppedToolCallIds = new Set<string>();
|
|
||||||
const out: AgentMessage[] = [];
|
|
||||||
|
|
||||||
const resolveToolResultId = (
|
|
||||||
msg: Extract<AgentMessage, { role: "toolResult" }>,
|
|
||||||
): string | undefined => {
|
|
||||||
const toolCallId = (msg as { toolCallId?: unknown }).toolCallId;
|
|
||||||
if (typeof toolCallId === "string" && toolCallId) return toolCallId;
|
|
||||||
const toolUseId = (msg as { toolUseId?: unknown }).toolUseId;
|
|
||||||
if (typeof toolUseId === "string" && toolUseId) return toolUseId;
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (!msg || typeof msg !== "object") {
|
|
||||||
out.push(msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = (msg as { role?: unknown }).role;
|
|
||||||
if (role === "assistant") {
|
|
||||||
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
|
|
||||||
if (!Array.isArray(assistantMsg.content)) {
|
|
||||||
out.push(msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dropped = false;
|
|
||||||
const nextContent = assistantMsg.content.filter((block) => {
|
|
||||||
if (!block || typeof block !== "object") return true;
|
|
||||||
const blockRecord = block as GeminiToolCallBlock;
|
|
||||||
const type = blockRecord.type;
|
|
||||||
if (type === "toolCall" || type === "functionCall" || type === "toolUse") {
|
|
||||||
const signature = blockRecord.thought_signature ?? blockRecord.thoughtSignature;
|
|
||||||
const hasSignature = Boolean(signature);
|
|
||||||
if (!hasSignature) {
|
|
||||||
const id =
|
|
||||||
typeof blockRecord.id === "string"
|
|
||||||
? blockRecord.id
|
|
||||||
: typeof blockRecord.toolCallId === "string"
|
|
||||||
? blockRecord.toolCallId
|
|
||||||
: undefined;
|
|
||||||
if (id) droppedToolCallIds.add(id);
|
|
||||||
dropped = true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (dropped && nextContent.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
out.push(dropped ? ({ ...assistantMsg, content: nextContent } as AgentMessage) : msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role === "toolResult") {
|
|
||||||
const toolMsg = msg as Extract<AgentMessage, { role: "toolResult" }>;
|
|
||||||
const toolResultId = resolveToolResultId(toolMsg);
|
|
||||||
if (toolResultId && droppedToolCallIds.has(toolResultId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out.push(msg);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
|
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
|
||||||
|
|
||||||
describe("sanitizeSessionHistory (google thinking)", () => {
|
describe("sanitizeSessionHistory (google thinking)", () => {
|
||||||
it("downgrades thinking blocks without signatures for Google models", async () => {
|
it("keeps thinking blocks without signatures for Google models", async () => {
|
||||||
const sessionManager = SessionManager.inMemory();
|
const sessionManager = SessionManager.inMemory();
|
||||||
const input = [
|
const input = [
|
||||||
{
|
{
|
||||||
@@ -25,10 +25,10 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||||
content?: Array<{ type?: string; text?: string }>;
|
content?: Array<{ type?: string; thinking?: string }>;
|
||||||
};
|
};
|
||||||
expect(assistant.content?.map((block) => block.type)).toEqual(["text"]);
|
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
|
||||||
expect(assistant.content?.[0]?.text).toBe("reasoning");
|
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps thinking blocks with signatures for Google models", async () => {
|
it("keeps thinking blocks with signatures for Google models", async () => {
|
||||||
@@ -59,7 +59,7 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
|||||||
expect(assistant.content?.[0]?.thinkingSignature).toBe("sig");
|
expect(assistant.content?.[0]?.thinkingSignature).toBe("sig");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("downgrades thinking blocks with Anthropic-style signatures for Google models", async () => {
|
it("keeps thinking blocks with Anthropic-style signatures for Google models", async () => {
|
||||||
const sessionManager = SessionManager.inMemory();
|
const sessionManager = SessionManager.inMemory();
|
||||||
const input = [
|
const input = [
|
||||||
{
|
{
|
||||||
@@ -80,10 +80,10 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||||
content?: Array<{ type?: string; text?: string }>;
|
content?: Array<{ type?: string; thinking?: string }>;
|
||||||
};
|
};
|
||||||
expect(assistant.content?.map((block) => block.type)).toEqual(["text"]);
|
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
|
||||||
expect(assistant.content?.[0]?.text).toBe("reasoning");
|
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps unsigned thinking blocks for Antigravity Claude", async () => {
|
it("keeps unsigned thinking blocks for Antigravity Claude", async () => {
|
||||||
@@ -114,7 +114,7 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
|||||||
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
|
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves order when downgrading mixed assistant content", async () => {
|
it("preserves order for mixed assistant content", async () => {
|
||||||
const sessionManager = SessionManager.inMemory();
|
const sessionManager = SessionManager.inMemory();
|
||||||
const input = [
|
const input = [
|
||||||
{
|
{
|
||||||
@@ -139,10 +139,10 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||||
content?: Array<{ type?: string; text?: string }>;
|
content?: Array<{ type?: string; text?: string; thinking?: string }>;
|
||||||
};
|
};
|
||||||
expect(assistant.content?.map((block) => block.type)).toEqual(["text", "text", "text"]);
|
expect(assistant.content?.map((block) => block.type)).toEqual(["text", "thinking", "text"]);
|
||||||
expect(assistant.content?.[1]?.text).toBe("internal note");
|
expect(assistant.content?.[1]?.thinking).toBe("internal note");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips non-base64 thought signatures for OpenRouter Gemini", async () => {
|
it("strips non-base64 thought signatures for OpenRouter Gemini", async () => {
|
||||||
@@ -185,11 +185,22 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||||
content?: Array<{ type?: string; thought_signature?: string; thoughtSignature?: string }>;
|
content?: Array<{
|
||||||
|
type?: string;
|
||||||
|
thought_signature?: string;
|
||||||
|
thoughtSignature?: string;
|
||||||
|
thinking?: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
expect(assistant.content).toEqual([
|
expect(assistant.content).toEqual([
|
||||||
{ type: "text", text: "hello" },
|
{ type: "text", text: "hello" },
|
||||||
{ type: "text", text: "ok" },
|
{ type: "thinking", thinking: "ok", thought_signature: "c2ln" },
|
||||||
|
{
|
||||||
|
type: "toolCall",
|
||||||
|
id: "call_1",
|
||||||
|
name: "read",
|
||||||
|
arguments: { path: "/tmp/foo" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "toolCall",
|
type: "toolCall",
|
||||||
id: "call_2",
|
id: "call_2",
|
||||||
@@ -200,7 +211,7 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("downgrades only unsigned thinking blocks when mixed with signed ones", async () => {
|
it("keeps mixed signed/unsigned thinking blocks for Google models", async () => {
|
||||||
const sessionManager = SessionManager.inMemory();
|
const sessionManager = SessionManager.inMemory();
|
||||||
const input = [
|
const input = [
|
||||||
{
|
{
|
||||||
@@ -224,14 +235,14 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||||
content?: Array<{ type?: string; thinking?: string; text?: string }>;
|
content?: Array<{ type?: string; thinking?: string }>;
|
||||||
};
|
};
|
||||||
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking", "text"]);
|
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking", "thinking"]);
|
||||||
expect(assistant.content?.[0]?.thinking).toBe("signed");
|
expect(assistant.content?.[0]?.thinking).toBe("signed");
|
||||||
expect(assistant.content?.[1]?.text).toBe("unsigned");
|
expect(assistant.content?.[1]?.thinking).toBe("unsigned");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops empty unsigned thinking blocks for Google models", async () => {
|
it("keeps empty thinking blocks for Google models", async () => {
|
||||||
const sessionManager = SessionManager.inMemory();
|
const sessionManager = SessionManager.inMemory();
|
||||||
const input = [
|
const input = [
|
||||||
{
|
{
|
||||||
@@ -251,8 +262,10 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
|||||||
sessionId: "session:google-empty",
|
sessionId: "session:google-empty",
|
||||||
});
|
});
|
||||||
|
|
||||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant");
|
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||||
expect(assistant).toBeUndefined();
|
content?: Array<{ type?: string; thinking?: string }>;
|
||||||
|
};
|
||||||
|
expect(assistant?.content?.map((block) => block.type)).toEqual(["thinking"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps thinking blocks for non-Google models", async () => {
|
it("keeps thinking blocks for non-Google models", async () => {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ vi.mock("./pi-embedded-helpers.js", async () => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
isGoogleModelApi: vi.fn(),
|
isGoogleModelApi: vi.fn(),
|
||||||
downgradeGeminiHistory: vi.fn(),
|
|
||||||
sanitizeSessionMessagesImages: vi.fn().mockImplementation(async (msgs) => msgs),
|
sanitizeSessionMessagesImages: vi.fn().mockImplementation(async (msgs) => msgs),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -32,19 +31,14 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs);
|
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs);
|
||||||
// Default mock implementation
|
|
||||||
vi.mocked(helpers.downgradeGeminiHistory).mockImplementation((msgs) => {
|
|
||||||
if (!msgs) return [];
|
|
||||||
return [...msgs, { role: "system", content: "downgraded" }];
|
|
||||||
});
|
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
({ sanitizeSessionHistory } = await import("./pi-embedded-runner/google.js"));
|
({ sanitizeSessionHistory } = await import("./pi-embedded-runner/google.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should downgrade history for Google models if provider is not google-antigravity", async () => {
|
it("sanitizes tool call ids for Google model APIs", async () => {
|
||||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
|
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
|
||||||
|
|
||||||
const result = await sanitizeSessionHistory({
|
await sanitizeSessionHistory({
|
||||||
messages: mockMessages,
|
messages: mockMessages,
|
||||||
modelApi: "google-gemini",
|
modelApi: "google-gemini",
|
||||||
provider: "google-vertex",
|
provider: "google-vertex",
|
||||||
@@ -53,35 +47,17 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
|
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
|
||||||
expect(helpers.downgradeGeminiHistory).toHaveBeenCalled();
|
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
||||||
// Check if the result contains the downgraded message
|
mockMessages,
|
||||||
expect(result).toContainEqual({ role: "system", content: "downgraded" });
|
"session:history",
|
||||||
|
expect.objectContaining({ sanitizeToolCallIds: true }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should NOT downgrade history for google-antigravity provider", async () => {
|
it("does not sanitize tool call ids for non-Google, non-OpenAI APIs", async () => {
|
||||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
|
|
||||||
|
|
||||||
const result = await sanitizeSessionHistory({
|
|
||||||
messages: mockMessages,
|
|
||||||
modelApi: "google-gemini",
|
|
||||||
provider: "google-antigravity",
|
|
||||||
sessionManager: mockSessionManager,
|
|
||||||
sessionId: "test-session",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
|
|
||||||
expect(helpers.downgradeGeminiHistory).not.toHaveBeenCalled();
|
|
||||||
// Result should not contain the downgraded message
|
|
||||||
expect(result).not.toContainEqual({
|
|
||||||
role: "system",
|
|
||||||
content: "downgraded",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should NOT downgrade history for non-Google models", async () => {
|
|
||||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||||
|
|
||||||
const _result = await sanitizeSessionHistory({
|
await sanitizeSessionHistory({
|
||||||
messages: mockMessages,
|
messages: mockMessages,
|
||||||
modelApi: "anthropic-messages",
|
modelApi: "anthropic-messages",
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
@@ -90,25 +66,14 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("anthropic-messages");
|
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("anthropic-messages");
|
||||||
expect(helpers.downgradeGeminiHistory).not.toHaveBeenCalled();
|
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
||||||
|
mockMessages,
|
||||||
|
"session:history",
|
||||||
|
expect.objectContaining({ sanitizeToolCallIds: false }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should downgrade history if provider is undefined but model is Google", async () => {
|
it("keeps reasoning-only assistant messages for openai-responses", async () => {
|
||||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
|
|
||||||
|
|
||||||
const _result = await sanitizeSessionHistory({
|
|
||||||
messages: mockMessages,
|
|
||||||
modelApi: "google-gemini",
|
|
||||||
provider: undefined,
|
|
||||||
sessionManager: mockSessionManager,
|
|
||||||
sessionId: "test-session",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
|
|
||||||
expect(helpers.downgradeGeminiHistory).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops reasoning-only assistant messages for openai-responses", async () => {
|
|
||||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||||
|
|
||||||
const messages: AgentMessage[] = [
|
const messages: AgentMessage[] = [
|
||||||
@@ -135,7 +100,7 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("openai-responses");
|
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("openai-responses");
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(2);
|
||||||
expect(result[0]?.role).toBe("user");
|
expect(result[1]?.role).toBe("assistant");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
type CustomEntryLike = { type?: unknown; customType?: unknown; data?: unknown };
|
||||||
|
|
||||||
|
export const CACHE_TTL_CUSTOM_TYPE = "clawdbot.cache-ttl";
|
||||||
|
|
||||||
|
export type CacheTtlEntryData = {
|
||||||
|
timestamp: number;
|
||||||
|
provider?: string;
|
||||||
|
modelId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isCacheTtlEligibleProvider(provider: string, modelId: string): boolean {
|
||||||
|
const normalizedProvider = provider.toLowerCase();
|
||||||
|
const normalizedModelId = modelId.toLowerCase();
|
||||||
|
if (normalizedProvider === "anthropic") return true;
|
||||||
|
if (normalizedProvider === "openrouter" && normalizedModelId.startsWith("anthropic/"))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLastCacheTtlTimestamp(sessionManager: unknown): number | null {
|
||||||
|
const sm = sessionManager as { getEntries?: () => CustomEntryLike[] };
|
||||||
|
if (!sm?.getEntries) return null;
|
||||||
|
try {
|
||||||
|
const entries = sm.getEntries();
|
||||||
|
let last: number | null = null;
|
||||||
|
for (let i = entries.length - 1; i >= 0; i--) {
|
||||||
|
const entry = entries[i];
|
||||||
|
if (entry?.type !== "custom" || entry?.customType !== CACHE_TTL_CUSTOM_TYPE) continue;
|
||||||
|
const data = entry?.data as Partial<CacheTtlEntryData> | undefined;
|
||||||
|
const ts = typeof data?.timestamp === "number" ? data.timestamp : null;
|
||||||
|
if (ts && Number.isFinite(ts)) {
|
||||||
|
last = ts;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendCacheTtlTimestamp(sessionManager: unknown, data: CacheTtlEntryData): void {
|
||||||
|
const sm = sessionManager as {
|
||||||
|
appendCustomEntry?: (customType: string, data: unknown) => void;
|
||||||
|
};
|
||||||
|
if (!sm?.appendCustomEntry) return;
|
||||||
|
try {
|
||||||
|
sm.appendCustomEntry(CACHE_TTL_CUSTOM_TYPE, data);
|
||||||
|
} catch {
|
||||||
|
// ignore persistence failures
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { setContextPruningRuntime } from "../pi-extensions/context-pruning/runti
|
|||||||
import { computeEffectiveSettings } from "../pi-extensions/context-pruning/settings.js";
|
import { computeEffectiveSettings } from "../pi-extensions/context-pruning/settings.js";
|
||||||
import { makeToolPrunablePredicate } from "../pi-extensions/context-pruning/tools.js";
|
import { makeToolPrunablePredicate } from "../pi-extensions/context-pruning/tools.js";
|
||||||
import { ensurePiCompactionReserveTokens } from "../pi-settings.js";
|
import { ensurePiCompactionReserveTokens } from "../pi-settings.js";
|
||||||
|
import { isCacheTtlEligibleProvider, readLastCacheTtlTimestamp } from "./cache-ttl.js";
|
||||||
|
|
||||||
function resolvePiExtensionPath(id: string): string {
|
function resolvePiExtensionPath(id: string): string {
|
||||||
const self = fileURLToPath(import.meta.url);
|
const self = fileURLToPath(import.meta.url);
|
||||||
@@ -43,7 +44,8 @@ function buildContextPruningExtension(params: {
|
|||||||
model: Model<Api> | undefined;
|
model: Model<Api> | undefined;
|
||||||
}): { additionalExtensionPaths?: string[] } {
|
}): { additionalExtensionPaths?: string[] } {
|
||||||
const raw = params.cfg?.agents?.defaults?.contextPruning;
|
const raw = params.cfg?.agents?.defaults?.contextPruning;
|
||||||
if (raw?.mode !== "adaptive" && raw?.mode !== "aggressive") return {};
|
if (raw?.mode !== "cache-ttl") return {};
|
||||||
|
if (!isCacheTtlEligibleProvider(params.provider, params.modelId)) return {};
|
||||||
|
|
||||||
const settings = computeEffectiveSettings(raw);
|
const settings = computeEffectiveSettings(raw);
|
||||||
if (!settings) return {};
|
if (!settings) return {};
|
||||||
@@ -52,6 +54,7 @@ function buildContextPruningExtension(params: {
|
|||||||
settings,
|
settings,
|
||||||
contextWindowTokens: resolveContextWindowTokens(params),
|
contextWindowTokens: resolveContextWindowTokens(params),
|
||||||
isToolPrunable: makeToolPrunablePredicate(settings.tools),
|
isToolPrunable: makeToolPrunablePredicate(settings.tools),
|
||||||
|
lastCacheTouchAt: readLastCacheTtlTimestamp(params.sessionManager),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
|||||||
|
|
||||||
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
||||||
import {
|
import {
|
||||||
downgradeGeminiThinkingBlocks,
|
|
||||||
downgradeGeminiHistory,
|
|
||||||
isCompactionFailureError,
|
isCompactionFailureError,
|
||||||
isGoogleModelApi,
|
isGoogleModelApi,
|
||||||
sanitizeGoogleTurnOrdering,
|
sanitizeGoogleTurnOrdering,
|
||||||
@@ -52,42 +50,6 @@ function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
|
|||||||
return isGoogleModelApi(modelApi) || OPENAI_TOOL_CALL_ID_APIS.has(modelApi);
|
return isGoogleModelApi(modelApi) || OPENAI_TOOL_CALL_ID_APIS.has(modelApi);
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterOpenAIReasoningOnlyMessages(
|
|
||||||
messages: AgentMessage[],
|
|
||||||
modelApi?: string | null,
|
|
||||||
): AgentMessage[] {
|
|
||||||
if (modelApi !== "openai-responses") return messages;
|
|
||||||
return messages.filter((msg) => {
|
|
||||||
if (!msg || typeof msg !== "object") return true;
|
|
||||||
if ((msg as { role?: unknown }).role !== "assistant") return true;
|
|
||||||
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
|
|
||||||
const content = assistant.content;
|
|
||||||
if (!Array.isArray(content) || content.length === 0) return true;
|
|
||||||
let hasThinking = false;
|
|
||||||
let hasPairedContent = false;
|
|
||||||
for (const block of content) {
|
|
||||||
if (!block || typeof block !== "object") continue;
|
|
||||||
const type = (block as { type?: unknown }).type;
|
|
||||||
if (type === "thinking") {
|
|
||||||
hasThinking = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (type === "toolCall" || type === "toolUse" || type === "functionCall") {
|
|
||||||
hasPairedContent = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (type === "text") {
|
|
||||||
const text = (block as { text?: unknown }).text;
|
|
||||||
if (typeof text === "string" && text.trim().length > 0) {
|
|
||||||
hasPairedContent = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return !(hasThinking && !hasPairedContent);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
|
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
|
||||||
if (!schema || typeof schema !== "object") return [];
|
if (!schema || typeof schema !== "object") return [];
|
||||||
if (Array.isArray(schema)) {
|
if (Array.isArray(schema)) {
|
||||||
@@ -233,7 +195,6 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
const modelId = (params.modelId ?? "").toLowerCase();
|
const modelId = (params.modelId ?? "").toLowerCase();
|
||||||
const isOpenRouterGemini =
|
const isOpenRouterGemini =
|
||||||
(provider === "openrouter" || provider === "opencode") && modelId.includes("gemini");
|
(provider === "openrouter" || provider === "opencode") && modelId.includes("gemini");
|
||||||
const isGeminiLike = isGoogleModelApi(params.modelApi) || isOpenRouterGemini;
|
|
||||||
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
||||||
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
|
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
|
||||||
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
||||||
@@ -242,26 +203,10 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
? { allowBase64Only: true, includeCamelCase: true }
|
? { allowBase64Only: true, includeCamelCase: true }
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
// TODO REMOVE when https://github.com/badlogic/pi-mono/pull/838 is merged.
|
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
||||||
const openaiReasoningFiltered = filterOpenAIReasoningOnlyMessages(
|
|
||||||
sanitizedImages,
|
|
||||||
params.modelApi,
|
|
||||||
);
|
|
||||||
const repairedTools = sanitizeToolUseResultPairing(openaiReasoningFiltered);
|
|
||||||
const isAntigravityProvider =
|
|
||||||
provider === "google-antigravity" || params.modelApi === "google-antigravity";
|
|
||||||
const shouldDowngradeThinking = isGeminiLike && !isAntigravityClaudeModel;
|
|
||||||
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
|
|
||||||
const downgradedThinking = shouldDowngradeThinking
|
|
||||||
? downgradeGeminiThinkingBlocks(repairedTools)
|
|
||||||
: repairedTools;
|
|
||||||
const shouldDowngradeHistory = shouldDowngradeThinking && !isAntigravityProvider;
|
|
||||||
const downgraded = shouldDowngradeHistory
|
|
||||||
? downgradeGeminiHistory(downgradedThinking)
|
|
||||||
: downgradedThinking;
|
|
||||||
|
|
||||||
return applyGoogleTurnOrderingFix({
|
return applyGoogleTurnOrderingFix({
|
||||||
messages: downgraded,
|
messages: repairedTools,
|
||||||
modelApi: params.modelApi,
|
modelApi: params.modelApi,
|
||||||
sessionManager: params.sessionManager,
|
sessionManager: params.sessionManager,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { injectHistoryImagesIntoMessages } from "./attempt.js";
|
||||||
|
|
||||||
|
describe("injectHistoryImagesIntoMessages", () => {
|
||||||
|
const image: ImageContent = { type: "image", data: "abc", mimeType: "image/png" };
|
||||||
|
|
||||||
|
it("injects history images and converts string content", () => {
|
||||||
|
const messages: AgentMessage[] = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: "See /tmp/photo.png",
|
||||||
|
} as AgentMessage,
|
||||||
|
];
|
||||||
|
|
||||||
|
const didMutate = injectHistoryImagesIntoMessages(messages, new Map([[0, [image]]]));
|
||||||
|
|
||||||
|
expect(didMutate).toBe(true);
|
||||||
|
expect(Array.isArray(messages[0]?.content)).toBe(true);
|
||||||
|
const content = messages[0]?.content as Array<{ type: string; text?: string; data?: string }>;
|
||||||
|
expect(content).toHaveLength(2);
|
||||||
|
expect(content[0]?.type).toBe("text");
|
||||||
|
expect(content[1]).toMatchObject({ type: "image", data: "abc" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avoids duplicating existing image content", () => {
|
||||||
|
const messages: AgentMessage[] = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: "See /tmp/photo.png" }, { ...image }],
|
||||||
|
} as AgentMessage,
|
||||||
|
];
|
||||||
|
|
||||||
|
const didMutate = injectHistoryImagesIntoMessages(messages, new Map([[0, [image]]]));
|
||||||
|
|
||||||
|
expect(didMutate).toBe(false);
|
||||||
|
const first = messages[0];
|
||||||
|
if (!first || !Array.isArray(first.content)) {
|
||||||
|
throw new Error("expected array content");
|
||||||
|
}
|
||||||
|
expect(first.content).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-user messages and out-of-range indices", () => {
|
||||||
|
const messages: AgentMessage[] = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: "noop",
|
||||||
|
} as AgentMessage,
|
||||||
|
];
|
||||||
|
|
||||||
|
const didMutate = injectHistoryImagesIntoMessages(messages, new Map([[1, [image]]]));
|
||||||
|
|
||||||
|
expect(didMutate).toBe(false);
|
||||||
|
expect(messages[0]?.content).toBe("noop");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,6 +49,7 @@ import { resolveDefaultModelForAgent } from "../../model-selection.js";
|
|||||||
import { isAbortError } from "../abort.js";
|
import { isAbortError } from "../abort.js";
|
||||||
import { buildEmbeddedExtensionPaths } from "../extensions.js";
|
import { buildEmbeddedExtensionPaths } from "../extensions.js";
|
||||||
import { applyExtraParamsToAgent } from "../extra-params.js";
|
import { applyExtraParamsToAgent } from "../extra-params.js";
|
||||||
|
import { appendCacheTtlTimestamp, isCacheTtlEligibleProvider } from "../cache-ttl.js";
|
||||||
import {
|
import {
|
||||||
logToolSchemasForGoogle,
|
logToolSchemasForGoogle,
|
||||||
sanitizeSessionHistory,
|
sanitizeSessionHistory,
|
||||||
@@ -77,6 +78,50 @@ import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
|
|||||||
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
|
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
|
||||||
import { detectAndLoadPromptImages } from "./images.js";
|
import { detectAndLoadPromptImages } from "./images.js";
|
||||||
|
|
||||||
|
export function injectHistoryImagesIntoMessages(
|
||||||
|
messages: AgentMessage[],
|
||||||
|
historyImagesByIndex: Map<number, ImageContent[]>,
|
||||||
|
): boolean {
|
||||||
|
if (historyImagesByIndex.size === 0) return false;
|
||||||
|
let didMutate = false;
|
||||||
|
|
||||||
|
for (const [msgIndex, images] of historyImagesByIndex) {
|
||||||
|
// Bounds check: ensure index is valid before accessing
|
||||||
|
if (msgIndex < 0 || msgIndex >= messages.length) continue;
|
||||||
|
const msg = messages[msgIndex];
|
||||||
|
if (msg && msg.role === "user") {
|
||||||
|
// Convert string content to array format if needed
|
||||||
|
if (typeof msg.content === "string") {
|
||||||
|
msg.content = [{ type: "text", text: msg.content }];
|
||||||
|
didMutate = true;
|
||||||
|
}
|
||||||
|
if (Array.isArray(msg.content)) {
|
||||||
|
// Check for existing image content to avoid duplicates across turns
|
||||||
|
const existingImageData = new Set(
|
||||||
|
msg.content
|
||||||
|
.filter(
|
||||||
|
(c): c is ImageContent =>
|
||||||
|
c != null &&
|
||||||
|
typeof c === "object" &&
|
||||||
|
c.type === "image" &&
|
||||||
|
typeof c.data === "string",
|
||||||
|
)
|
||||||
|
.map((c) => c.data),
|
||||||
|
);
|
||||||
|
for (const img of images) {
|
||||||
|
// Only add if this image isn't already in the message
|
||||||
|
if (!existingImageData.has(img.data)) {
|
||||||
|
msg.content.push(img);
|
||||||
|
didMutate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return didMutate;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runEmbeddedAttempt(
|
export async function runEmbeddedAttempt(
|
||||||
params: EmbeddedRunAttemptParams,
|
params: EmbeddedRunAttemptParams,
|
||||||
): Promise<EmbeddedRunAttemptResult> {
|
): Promise<EmbeddedRunAttemptResult> {
|
||||||
@@ -626,38 +671,13 @@ export async function runEmbeddedAttempt(
|
|||||||
|
|
||||||
// Inject history images into their original message positions.
|
// Inject history images into their original message positions.
|
||||||
// This ensures the model sees images in context (e.g., "compare to the first image").
|
// This ensures the model sees images in context (e.g., "compare to the first image").
|
||||||
if (imageResult.historyImagesByIndex.size > 0) {
|
const didMutate = injectHistoryImagesIntoMessages(
|
||||||
for (const [msgIndex, images] of imageResult.historyImagesByIndex) {
|
activeSession.messages,
|
||||||
// Bounds check: ensure index is valid before accessing
|
imageResult.historyImagesByIndex,
|
||||||
if (msgIndex < 0 || msgIndex >= activeSession.messages.length) continue;
|
);
|
||||||
const msg = activeSession.messages[msgIndex];
|
if (didMutate) {
|
||||||
if (msg && msg.role === "user") {
|
// Persist message mutations (e.g., injected history images) so we don't re-scan/reload.
|
||||||
// Convert string content to array format if needed
|
activeSession.agent.replaceMessages(activeSession.messages);
|
||||||
if (typeof msg.content === "string") {
|
|
||||||
msg.content = [{ type: "text", text: msg.content }];
|
|
||||||
}
|
|
||||||
if (Array.isArray(msg.content)) {
|
|
||||||
// Check for existing image content to avoid duplicates across turns
|
|
||||||
const existingImageData = new Set(
|
|
||||||
msg.content
|
|
||||||
.filter(
|
|
||||||
(c): c is ImageContent =>
|
|
||||||
c != null &&
|
|
||||||
typeof c === "object" &&
|
|
||||||
c.type === "image" &&
|
|
||||||
typeof c.data === "string",
|
|
||||||
)
|
|
||||||
.map((c) => c.data),
|
|
||||||
);
|
|
||||||
for (const img of images) {
|
|
||||||
// Only add if this image isn't already in the message
|
|
||||||
if (!existingImageData.has(img.data)) {
|
|
||||||
msg.content.push(img);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheTrace?.recordStage("prompt:images", {
|
cacheTrace?.recordStage("prompt:images", {
|
||||||
@@ -666,6 +686,17 @@ export async function runEmbeddedAttempt(
|
|||||||
note: `images: prompt=${imageResult.images.length} history=${imageResult.historyImagesByIndex.size}`,
|
note: `images: prompt=${imageResult.images.length} history=${imageResult.historyImagesByIndex.size}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shouldTrackCacheTtl =
|
||||||
|
params.config?.agents?.defaults?.contextPruning?.mode === "cache-ttl" &&
|
||||||
|
isCacheTtlEligibleProvider(params.provider, params.modelId);
|
||||||
|
if (shouldTrackCacheTtl) {
|
||||||
|
appendCacheTtlTimestamp(sessionManager, {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
provider: params.provider,
|
||||||
|
modelId: params.modelId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Only pass images option if there are actually images to pass
|
// Only pass images option if there are actually images to pass
|
||||||
// This avoids potential issues with models that don't expect the images parameter
|
// This avoids potential issues with models that don't expect the images parameter
|
||||||
if (imageResult.images.length > 0) {
|
if (imageResult.images.length > 0) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|||||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { setContextPruningRuntime } from "./context-pruning/runtime.js";
|
import { getContextPruningRuntime, setContextPruningRuntime } from "./context-pruning/runtime.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
computeEffectiveSettings,
|
computeEffectiveSettings,
|
||||||
@@ -135,12 +135,15 @@ describe("context-pruning", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("never prunes tool results before the first user message", () => {
|
it("never prunes tool results before the first user message", () => {
|
||||||
const settings = computeEffectiveSettings({
|
const settings = {
|
||||||
mode: "aggressive",
|
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
|
||||||
keepLastAssistants: 0,
|
keepLastAssistants: 0,
|
||||||
hardClear: { placeholder: "[cleared]" },
|
softTrimRatio: 0.0,
|
||||||
});
|
hardClearRatio: 0.0,
|
||||||
if (!settings) throw new Error("expected settings");
|
minPrunableToolChars: 0,
|
||||||
|
hardClear: { enabled: true, placeholder: "[cleared]" },
|
||||||
|
softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
|
||||||
|
};
|
||||||
|
|
||||||
const messages: AgentMessage[] = [
|
const messages: AgentMessage[] = [
|
||||||
makeAssistant("bootstrap tool calls"),
|
makeAssistant("bootstrap tool calls"),
|
||||||
@@ -170,7 +173,7 @@ describe("context-pruning", () => {
|
|||||||
expect(toolText(findToolResult(next, "t1"))).toBe("[cleared]");
|
expect(toolText(findToolResult(next, "t1"))).toBe("[cleared]");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mode aggressive clears eligible tool results before cutoff", () => {
|
it("hard-clear removes eligible tool results before cutoff", () => {
|
||||||
const messages: AgentMessage[] = [
|
const messages: AgentMessage[] = [
|
||||||
makeUser("u1"),
|
makeUser("u1"),
|
||||||
makeAssistant("a1"),
|
makeAssistant("a1"),
|
||||||
@@ -195,9 +198,11 @@ describe("context-pruning", () => {
|
|||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
|
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
|
||||||
mode: "aggressive",
|
|
||||||
keepLastAssistants: 1,
|
keepLastAssistants: 1,
|
||||||
hardClear: { enabled: false, placeholder: "[cleared]" },
|
softTrimRatio: 10.0,
|
||||||
|
hardClearRatio: 0.0,
|
||||||
|
minPrunableToolChars: 0,
|
||||||
|
hardClear: { enabled: true, placeholder: "[cleared]" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -258,6 +263,7 @@ describe("context-pruning", () => {
|
|||||||
},
|
},
|
||||||
contextWindowTokens: 1000,
|
contextWindowTokens: 1000,
|
||||||
isToolPrunable: () => true,
|
isToolPrunable: () => true,
|
||||||
|
lastCacheTouchAt: Date.now() - DEFAULT_CONTEXT_PRUNING_SETTINGS.ttlMs - 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const messages: AgentMessage[] = [
|
const messages: AgentMessage[] = [
|
||||||
@@ -300,6 +306,72 @@ describe("context-pruning", () => {
|
|||||||
expect(toolText(findToolResult(result.messages, "t1"))).toBe("[cleared]");
|
expect(toolText(findToolResult(result.messages, "t1"))).toBe("[cleared]");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("cache-ttl prunes once and resets the ttl window", () => {
|
||||||
|
const sessionManager = {};
|
||||||
|
const lastTouch = Date.now() - DEFAULT_CONTEXT_PRUNING_SETTINGS.ttlMs - 1000;
|
||||||
|
|
||||||
|
setContextPruningRuntime(sessionManager, {
|
||||||
|
settings: {
|
||||||
|
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
|
||||||
|
keepLastAssistants: 0,
|
||||||
|
softTrimRatio: 0,
|
||||||
|
hardClearRatio: 0,
|
||||||
|
minPrunableToolChars: 0,
|
||||||
|
hardClear: { enabled: true, placeholder: "[cleared]" },
|
||||||
|
softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
|
||||||
|
},
|
||||||
|
contextWindowTokens: 1000,
|
||||||
|
isToolPrunable: () => true,
|
||||||
|
lastCacheTouchAt: lastTouch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: AgentMessage[] = [
|
||||||
|
makeUser("u1"),
|
||||||
|
makeAssistant("a1"),
|
||||||
|
makeToolResult({
|
||||||
|
toolCallId: "t1",
|
||||||
|
toolName: "exec",
|
||||||
|
text: "x".repeat(20_000),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
let handler:
|
||||||
|
| ((
|
||||||
|
event: { messages: AgentMessage[] },
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
) => { messages: AgentMessage[] } | undefined)
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
on: (name: string, fn: unknown) => {
|
||||||
|
if (name === "context") {
|
||||||
|
handler = fn as typeof handler;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
appendEntry: (_type: string, _data?: unknown) => {},
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
|
||||||
|
contextPruningExtension(api);
|
||||||
|
if (!handler) throw new Error("missing context handler");
|
||||||
|
|
||||||
|
const first = handler({ messages }, {
|
||||||
|
model: undefined,
|
||||||
|
sessionManager,
|
||||||
|
} as unknown as ExtensionContext);
|
||||||
|
if (!first) throw new Error("expected first prune");
|
||||||
|
expect(toolText(findToolResult(first.messages, "t1"))).toBe("[cleared]");
|
||||||
|
|
||||||
|
const runtime = getContextPruningRuntime(sessionManager);
|
||||||
|
if (!runtime?.lastCacheTouchAt) throw new Error("expected lastCacheTouchAt");
|
||||||
|
expect(runtime.lastCacheTouchAt).toBeGreaterThan(lastTouch);
|
||||||
|
|
||||||
|
const second = handler({ messages }, {
|
||||||
|
model: undefined,
|
||||||
|
sessionManager,
|
||||||
|
} as unknown as ExtensionContext);
|
||||||
|
expect(second).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("respects tools allow/deny (deny wins; wildcards supported)", () => {
|
it("respects tools allow/deny (deny wins; wildcards supported)", () => {
|
||||||
const messages: AgentMessage[] = [
|
const messages: AgentMessage[] = [
|
||||||
makeUser("u1"),
|
makeUser("u1"),
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ export default function contextPruningExtension(api: ExtensionAPI): void {
|
|||||||
const runtime = getContextPruningRuntime(ctx.sessionManager);
|
const runtime = getContextPruningRuntime(ctx.sessionManager);
|
||||||
if (!runtime) return undefined;
|
if (!runtime) return undefined;
|
||||||
|
|
||||||
|
if (runtime.settings.mode === "cache-ttl") {
|
||||||
|
const ttlMs = runtime.settings.ttlMs;
|
||||||
|
const lastTouch = runtime.lastCacheTouchAt ?? null;
|
||||||
|
if (!lastTouch || ttlMs <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (ttlMs > 0 && Date.now() - lastTouch < ttlMs) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const next = pruneContextMessages({
|
const next = pruneContextMessages({
|
||||||
messages: event.messages as AgentMessage[],
|
messages: event.messages as AgentMessage[],
|
||||||
settings: runtime.settings,
|
settings: runtime.settings,
|
||||||
@@ -18,6 +29,11 @@ export default function contextPruningExtension(api: ExtensionAPI): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (next === event.messages) return undefined;
|
if (next === event.messages) return undefined;
|
||||||
|
|
||||||
|
if (runtime.settings.mode === "cache-ttl") {
|
||||||
|
runtime.lastCacheTouchAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
return { messages: next };
|
return { messages: next };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -211,34 +211,6 @@ export function pruneContextMessages(params: {
|
|||||||
|
|
||||||
const isToolPrunable = params.isToolPrunable ?? makeToolPrunablePredicate(settings.tools);
|
const isToolPrunable = params.isToolPrunable ?? makeToolPrunablePredicate(settings.tools);
|
||||||
|
|
||||||
if (settings.mode === "aggressive") {
|
|
||||||
let next: AgentMessage[] | null = null;
|
|
||||||
|
|
||||||
for (let i = pruneStartIndex; i < cutoffIndex; i++) {
|
|
||||||
const msg = messages[i];
|
|
||||||
if (!msg || msg.role !== "toolResult") continue;
|
|
||||||
if (!isToolPrunable(msg.toolName)) continue;
|
|
||||||
if (hasImageBlocks(msg.content)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const alreadyCleared =
|
|
||||||
msg.content.length === 1 &&
|
|
||||||
msg.content[0]?.type === "text" &&
|
|
||||||
msg.content[0].text === settings.hardClear.placeholder;
|
|
||||||
if (alreadyCleared) continue;
|
|
||||||
|
|
||||||
const cleared: ToolResultMessage = {
|
|
||||||
...msg,
|
|
||||||
content: [asText(settings.hardClear.placeholder)],
|
|
||||||
};
|
|
||||||
if (!next) next = messages.slice();
|
|
||||||
next[i] = cleared as unknown as AgentMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
return next ?? messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCharsBefore = estimateContextChars(messages);
|
const totalCharsBefore = estimateContextChars(messages);
|
||||||
let totalChars = totalCharsBefore;
|
let totalChars = totalCharsBefore;
|
||||||
let ratio = totalChars / charWindow;
|
let ratio = totalChars / charWindow;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type ContextPruningRuntimeValue = {
|
|||||||
settings: EffectiveContextPruningSettings;
|
settings: EffectiveContextPruningSettings;
|
||||||
contextWindowTokens?: number | null;
|
contextWindowTokens?: number | null;
|
||||||
isToolPrunable: (toolName: string) => boolean;
|
isToolPrunable: (toolName: string) => boolean;
|
||||||
|
lastCacheTouchAt?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Session-scoped runtime registry keyed by object identity.
|
// Session-scoped runtime registry keyed by object identity.
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import { parseDurationMs } from "../../../cli/parse-duration.js";
|
||||||
|
|
||||||
export type ContextPruningToolMatch = {
|
export type ContextPruningToolMatch = {
|
||||||
allow?: string[];
|
allow?: string[];
|
||||||
deny?: string[];
|
deny?: string[];
|
||||||
};
|
};
|
||||||
|
export type ContextPruningMode = "off" | "cache-ttl";
|
||||||
export type ContextPruningMode = "off" | "adaptive" | "aggressive";
|
|
||||||
|
|
||||||
export type ContextPruningConfig = {
|
export type ContextPruningConfig = {
|
||||||
mode?: ContextPruningMode;
|
mode?: ContextPruningMode;
|
||||||
|
/** TTL to consider cache expired (duration string, default unit: minutes). */
|
||||||
|
ttl?: string;
|
||||||
keepLastAssistants?: number;
|
keepLastAssistants?: number;
|
||||||
softTrimRatio?: number;
|
softTrimRatio?: number;
|
||||||
hardClearRatio?: number;
|
hardClearRatio?: number;
|
||||||
@@ -25,6 +28,7 @@ export type ContextPruningConfig = {
|
|||||||
|
|
||||||
export type EffectiveContextPruningSettings = {
|
export type EffectiveContextPruningSettings = {
|
||||||
mode: Exclude<ContextPruningMode, "off">;
|
mode: Exclude<ContextPruningMode, "off">;
|
||||||
|
ttlMs: number;
|
||||||
keepLastAssistants: number;
|
keepLastAssistants: number;
|
||||||
softTrimRatio: number;
|
softTrimRatio: number;
|
||||||
hardClearRatio: number;
|
hardClearRatio: number;
|
||||||
@@ -42,7 +46,8 @@ export type EffectiveContextPruningSettings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_CONTEXT_PRUNING_SETTINGS: EffectiveContextPruningSettings = {
|
export const DEFAULT_CONTEXT_PRUNING_SETTINGS: EffectiveContextPruningSettings = {
|
||||||
mode: "adaptive",
|
mode: "cache-ttl",
|
||||||
|
ttlMs: 5 * 60 * 1000,
|
||||||
keepLastAssistants: 3,
|
keepLastAssistants: 3,
|
||||||
softTrimRatio: 0.3,
|
softTrimRatio: 0.3,
|
||||||
hardClearRatio: 0.5,
|
hardClearRatio: 0.5,
|
||||||
@@ -62,11 +67,19 @@ export const DEFAULT_CONTEXT_PRUNING_SETTINGS: EffectiveContextPruningSettings =
|
|||||||
export function computeEffectiveSettings(raw: unknown): EffectiveContextPruningSettings | null {
|
export function computeEffectiveSettings(raw: unknown): EffectiveContextPruningSettings | null {
|
||||||
if (!raw || typeof raw !== "object") return null;
|
if (!raw || typeof raw !== "object") return null;
|
||||||
const cfg = raw as ContextPruningConfig;
|
const cfg = raw as ContextPruningConfig;
|
||||||
if (cfg.mode !== "adaptive" && cfg.mode !== "aggressive") return null;
|
if (cfg.mode !== "cache-ttl") return null;
|
||||||
|
|
||||||
const s: EffectiveContextPruningSettings = structuredClone(DEFAULT_CONTEXT_PRUNING_SETTINGS);
|
const s: EffectiveContextPruningSettings = structuredClone(DEFAULT_CONTEXT_PRUNING_SETTINGS);
|
||||||
s.mode = cfg.mode;
|
s.mode = cfg.mode;
|
||||||
|
|
||||||
|
if (typeof cfg.ttl === "string") {
|
||||||
|
try {
|
||||||
|
s.ttlMs = parseDurationMs(cfg.ttl, { defaultUnit: "m" });
|
||||||
|
} catch {
|
||||||
|
// keep default ttl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof cfg.keepLastAssistants === "number" && Number.isFinite(cfg.keepLastAssistants)) {
|
if (typeof cfg.keepLastAssistants === "number" && Number.isFinite(cfg.keepLastAssistants)) {
|
||||||
s.keepLastAssistants = Math.max(0, Math.floor(cfg.keepLastAssistants));
|
s.keepLastAssistants = Math.max(0, Math.floor(cfg.keepLastAssistants));
|
||||||
}
|
}
|
||||||
@@ -94,7 +107,7 @@ export function computeEffectiveSettings(raw: unknown): EffectiveContextPruningS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cfg.hardClear) {
|
if (cfg.hardClear) {
|
||||||
if (s.mode === "adaptive" && typeof cfg.hardClear.enabled === "boolean") {
|
if (typeof cfg.hardClear.enabled === "boolean") {
|
||||||
s.hardClear.enabled = cfg.hardClear.enabled;
|
s.hardClear.enabled = cfg.hardClear.enabled;
|
||||||
}
|
}
|
||||||
if (typeof cfg.hardClear.placeholder === "string" && cfg.hardClear.placeholder.trim()) {
|
if (typeof cfg.hardClear.placeholder === "string" && cfg.hardClear.placeholder.trim()) {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(prompt).toContain("## Clawdbot CLI Quick Reference");
|
expect(prompt).toContain("## Clawdbot CLI Quick Reference");
|
||||||
expect(prompt).toContain("clawdbot daemon restart");
|
expect(prompt).toContain("clawdbot gateway restart");
|
||||||
expect(prompt).toContain("Do not invent commands");
|
expect(prompt).toContain("Do not invent commands");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -365,11 +365,11 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
"## Clawdbot CLI Quick Reference",
|
"## Clawdbot CLI Quick Reference",
|
||||||
"Clawdbot is controlled via subcommands. Do not invent commands.",
|
"Clawdbot is controlled via subcommands. Do not invent commands.",
|
||||||
"To manage the Gateway daemon service (start/stop/restart):",
|
"To manage the Gateway daemon service (start/stop/restart):",
|
||||||
"- clawdbot daemon status",
|
"- clawdbot gateway status",
|
||||||
"- clawdbot daemon start",
|
"- clawdbot gateway start",
|
||||||
"- clawdbot daemon stop",
|
"- clawdbot gateway stop",
|
||||||
"- clawdbot daemon restart",
|
"- clawdbot gateway restart",
|
||||||
"If unsure, ask the user to run `clawdbot help` (or `clawdbot daemon --help`) and paste the output.",
|
"If unsure, ask the user to run `clawdbot help` (or `clawdbot gateway --help`) and paste the output.",
|
||||||
"",
|
"",
|
||||||
...skillsSection,
|
...skillsSection,
|
||||||
...memorySection,
|
...memorySection,
|
||||||
|
|||||||
@@ -429,6 +429,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
defineChatCommand({
|
||||||
|
key: "models",
|
||||||
|
nativeName: "models",
|
||||||
|
description: "List model providers or provider models.",
|
||||||
|
textAlias: "/models",
|
||||||
|
argsParsing: "none",
|
||||||
|
acceptsArgs: true,
|
||||||
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "queue",
|
key: "queue",
|
||||||
nativeName: "queue",
|
nativeName: "queue",
|
||||||
@@ -485,7 +493,6 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
registerAlias(commands, "verbose", "/v");
|
registerAlias(commands, "verbose", "/v");
|
||||||
registerAlias(commands, "reasoning", "/reason");
|
registerAlias(commands, "reasoning", "/reason");
|
||||||
registerAlias(commands, "elevated", "/elev");
|
registerAlias(commands, "elevated", "/elev");
|
||||||
registerAlias(commands, "model", "/models");
|
|
||||||
|
|
||||||
assertCommandRegistry(commands);
|
assertCommandRegistry(commands);
|
||||||
return commands;
|
return commands;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ describe("commands registry", () => {
|
|||||||
it("builds command text with args", () => {
|
it("builds command text with args", () => {
|
||||||
expect(buildCommandText("status")).toBe("/status");
|
expect(buildCommandText("status")).toBe("/status");
|
||||||
expect(buildCommandText("model", "gpt-5")).toBe("/model gpt-5");
|
expect(buildCommandText("model", "gpt-5")).toBe("/model gpt-5");
|
||||||
|
expect(buildCommandText("models")).toBe("/models");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes native specs", () => {
|
it("exposes native specs", () => {
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ const resolveChannelOverride = (params: {
|
|||||||
channel: string;
|
channel: string;
|
||||||
}): number | undefined => {
|
}): number | undefined => {
|
||||||
if (!params.byChannel) return undefined;
|
if (!params.byChannel) return undefined;
|
||||||
const channelKey = params.channel as keyof InboundDebounceByProvider;
|
return resolveMs(params.byChannel[params.channel]);
|
||||||
return resolveMs(params.byChannel[channelKey]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveInboundDebounceMs(params: {
|
export function resolveInboundDebounceMs(params: {
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ describe("extractModelDirective", () => {
|
|||||||
expect(result.cleaned).toBe("");
|
expect(result.cleaned).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts /models with argument", () => {
|
it("does not extract /models with argument", () => {
|
||||||
const result = extractModelDirective("/models gpt-5");
|
const result = extractModelDirective("/models gpt-5");
|
||||||
expect(result.hasDirective).toBe(true);
|
expect(result.hasDirective).toBe(false);
|
||||||
expect(result.rawModel).toBe("gpt-5");
|
expect(result.cleaned).toBe("/models gpt-5");
|
||||||
expect(result.cleaned).toBe("");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts /model with provider/model format", () => {
|
it("extracts /model with provider/model format", () => {
|
||||||
@@ -114,10 +113,11 @@ describe("extractModelDirective", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("edge cases", () => {
|
describe("edge cases", () => {
|
||||||
it("preserves spacing when /model is followed by a path segment", () => {
|
it("extracts models with multiple path segments", () => {
|
||||||
const result = extractModelDirective("thats not /model gpt-5/tmp/hello");
|
const result = extractModelDirective("thats not /model gpt-5/tmp/hello");
|
||||||
expect(result.hasDirective).toBe(true);
|
expect(result.hasDirective).toBe(true);
|
||||||
expect(result.cleaned).toBe("thats not /hello");
|
expect(result.rawModel).toBe("gpt-5/tmp/hello");
|
||||||
|
expect(result.cleaned).toBe("thats not");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles alias with special regex characters", () => {
|
it("handles alias with special regex characters", () => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function extractModelDirective(
|
|||||||
if (!body) return { cleaned: "", hasDirective: false };
|
if (!body) return { cleaned: "", hasDirective: false };
|
||||||
|
|
||||||
const modelMatch = body.match(
|
const modelMatch = body.match(
|
||||||
/(?:^|\s)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i,
|
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
|
||||||
);
|
);
|
||||||
|
|
||||||
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
|
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
|
||||||
|
|||||||
+73
-14
@@ -60,13 +60,13 @@ describe("directive behavior", () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists allowlisted models on /model list", async () => {
|
it("lists allowlisted models on /models <provider>", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
@@ -84,9 +84,29 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
|
expect(text).toContain("Models (anthropic)");
|
||||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||||
expect(text).toContain("openai/gpt-4.1-mini");
|
|
||||||
|
const openaiRes = await getReplyFromConfig(
|
||||||
|
{ Body: "/models openai", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: { primary: "anthropic/claude-opus-4-5" },
|
||||||
|
workspace: path.join(home, "clawd"),
|
||||||
|
models: {
|
||||||
|
"anthropic/claude-opus-4-5": {},
|
||||||
|
"openai/gpt-4.1-mini": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: { store: storePath },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const openaiText = Array.isArray(openaiRes) ? openaiRes[0]?.text : openaiRes?.text;
|
||||||
|
expect(openaiText).toContain("Models (openai)");
|
||||||
|
expect(openaiText).toContain("openai/gpt-4.1-mini");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -97,7 +117,7 @@ describe("directive behavior", () => {
|
|||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{ Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
@@ -115,9 +135,29 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
|
expect(text).toContain("Models (anthropic)");
|
||||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||||
expect(text).toContain("openai/gpt-4.1-mini");
|
|
||||||
|
const openaiRes = await getReplyFromConfig(
|
||||||
|
{ Body: "/models openai", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: { primary: "anthropic/claude-opus-4-5" },
|
||||||
|
workspace: path.join(home, "clawd"),
|
||||||
|
models: {
|
||||||
|
"anthropic/claude-opus-4-5": {},
|
||||||
|
"openai/gpt-4.1-mini": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: { store: storePath },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const openaiText = Array.isArray(openaiRes) ? openaiRes[0]?.text : openaiRes?.text;
|
||||||
|
expect(openaiText).toContain("Models (openai)");
|
||||||
|
expect(openaiText).toContain("openai/gpt-4.1-mini");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -132,7 +172,7 @@ describe("directive behavior", () => {
|
|||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/models xai", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
@@ -150,10 +190,29 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
expect(text).toContain("Models (xai)");
|
||||||
expect(text).toContain("openai/gpt-4.1-mini");
|
|
||||||
expect(text).toContain("minimax/MiniMax-M2.1");
|
|
||||||
expect(text).toContain("xai/grok-4");
|
expect(text).toContain("xai/grok-4");
|
||||||
|
|
||||||
|
const minimaxRes = await getReplyFromConfig(
|
||||||
|
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: "anthropic/claude-opus-4-5",
|
||||||
|
fallbacks: ["openai/gpt-4.1-mini"],
|
||||||
|
},
|
||||||
|
imageModel: { primary: "minimax/MiniMax-M2.1" },
|
||||||
|
workspace: path.join(home, "clawd"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: { store: storePath },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const minimaxText = Array.isArray(minimaxRes) ? minimaxRes[0]?.text : minimaxRes?.text;
|
||||||
|
expect(minimaxText).toContain("Models (minimax)");
|
||||||
|
expect(minimaxText).toContain("minimax/MiniMax-M2.1");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -173,7 +232,7 @@ describe("directive behavior", () => {
|
|||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
@@ -202,8 +261,7 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
expect(text).toContain("Models (minimax)");
|
||||||
expect(text).toContain("openai/gpt-4.1-mini");
|
|
||||||
expect(text).toContain("minimax/MiniMax-M2.1");
|
expect(text).toContain("minimax/MiniMax-M2.1");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -231,6 +289,7 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
|
expect(text).toContain("Model listing moved.");
|
||||||
expect(text).not.toContain("missing (missing)");
|
expect(text).not.toContain("missing (missing)");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-2
@@ -62,13 +62,13 @@ describe("directive behavior", () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers alias matches when fuzzy selection is ambiguous", async () => {
|
it("selects exact alias matches even when ambiguous", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
await getReplyFromConfig(
|
await getReplyFromConfig(
|
||||||
{ Body: "/model ki", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/model Kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
+15
-15
@@ -65,7 +65,7 @@ describe("directive behavior", () => {
|
|||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{ Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
@@ -94,10 +94,10 @@ describe("directive behavior", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assertModelSelection(storePath, {
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
model: "kimi-k2-0905-preview",
|
expect(text).toContain("Did you mean:");
|
||||||
provider: "moonshot",
|
expect(text).toContain("moonshot/kimi-k2-0905-preview");
|
||||||
});
|
assertModelSelection(storePath);
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -106,7 +106,7 @@ describe("directive behavior", () => {
|
|||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{
|
{
|
||||||
Body: "/model kimi-k2-0905-preview",
|
Body: "/model kimi-k2-0905-preview",
|
||||||
From: "+1222",
|
From: "+1222",
|
||||||
@@ -140,10 +140,10 @@ describe("directive behavior", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assertModelSelection(storePath, {
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
model: "kimi-k2-0905-preview",
|
expect(text).toContain("Did you mean:");
|
||||||
provider: "moonshot",
|
expect(text).toContain("moonshot/kimi-k2-0905-preview");
|
||||||
});
|
assertModelSelection(storePath);
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -152,7 +152,7 @@ describe("directive behavior", () => {
|
|||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{ Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
@@ -181,10 +181,10 @@ describe("directive behavior", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assertModelSelection(storePath, {
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
model: "kimi-k2-0905-preview",
|
expect(text).toContain("Did you mean:");
|
||||||
provider: "moonshot",
|
expect(text).toContain("moonshot/kimi-k2-0905-preview");
|
||||||
});
|
assertModelSelection(storePath);
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-5
@@ -187,7 +187,7 @@ describe("directive behavior", () => {
|
|||||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("lists allowlisted models on /model", async () => {
|
it("shows a model summary on /model", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
@@ -211,10 +211,9 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
expect(text).toContain("Current: anthropic/claude-opus-4-5");
|
||||||
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
|
expect(text).toContain("Browse: /models");
|
||||||
expect(text).toContain("openai/gpt-4.1-mini");
|
expect(text).toContain("Switch: /model <provider/model>");
|
||||||
expect(text).not.toContain("claude-sonnet-4-1");
|
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+22
-59
@@ -95,7 +95,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("trigger handling", () => {
|
describe("trigger handling", () => {
|
||||||
it("shows a quick /model picker listing provider/model pairs", async () => {
|
it("shows a /model summary with browse hints", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const cfg = makeCfg(home);
|
const cfg = makeCfg(home);
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
@@ -115,23 +115,18 @@ describe("trigger handling", () => {
|
|||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
const normalized = normalizeTestText(text ?? "");
|
const normalized = normalizeTestText(text ?? "");
|
||||||
expect(normalized).toContain("Pick: /model <#> or /model <provider/model>");
|
expect(normalized).toContain("Current: anthropic/claude-opus-4-5");
|
||||||
// Each provider/model combo is listed separately for clear selection
|
expect(normalized).toContain("Browse: /models");
|
||||||
expect(normalized).toContain("anthropic/claude-opus-4-5");
|
expect(normalized).toContain("Switch: /model <provider/model>");
|
||||||
expect(normalized).toContain("openrouter/anthropic/claude-opus-4-5");
|
|
||||||
expect(normalized).toContain("openai/gpt-5.2");
|
|
||||||
expect(normalized).toContain("openai-codex/gpt-5.2");
|
|
||||||
expect(normalized).toContain("More: /model status");
|
expect(normalized).toContain("More: /model status");
|
||||||
expect(normalized).not.toContain("reasoning");
|
|
||||||
expect(normalized).not.toContain("image");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("orders provider/model pairs by provider preference", async () => {
|
it("lists providers on /models", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const cfg = makeCfg(home);
|
const cfg = makeCfg(home);
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{
|
{
|
||||||
Body: "/model",
|
Body: "/models",
|
||||||
From: "telegram:111",
|
From: "telegram:111",
|
||||||
To: "telegram:111",
|
To: "telegram:111",
|
||||||
ChatType: "direct",
|
ChatType: "direct",
|
||||||
@@ -146,54 +141,22 @@ describe("trigger handling", () => {
|
|||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
const normalized = normalizeTestText(text ?? "");
|
const normalized = normalizeTestText(text ?? "");
|
||||||
const anthropicIndex = normalized.indexOf("anthropic/claude-opus-4-5");
|
expect(normalized).toContain("Providers:");
|
||||||
const openrouterIndex = normalized.indexOf("openrouter/anthropic/claude-opus-4-5");
|
expect(normalized).toContain("anthropic");
|
||||||
const openaiIndex = normalized.indexOf("openai/gpt-4.1-mini");
|
expect(normalized).toContain("openrouter");
|
||||||
const codexIndex = normalized.indexOf("openai-codex/gpt-5.2");
|
expect(normalized).toContain("openai");
|
||||||
expect(anthropicIndex).toBeGreaterThanOrEqual(0);
|
expect(normalized).toContain("openai-codex");
|
||||||
expect(openrouterIndex).toBeGreaterThanOrEqual(0);
|
expect(normalized).toContain("minimax");
|
||||||
expect(openaiIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(codexIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(anthropicIndex).toBeLessThan(openrouterIndex);
|
|
||||||
expect(openaiIndex).toBeLessThan(codexIndex);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("selects the exact provider/model pair for openrouter by index", async () => {
|
it("selects the exact provider/model pair for openrouter", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const cfg = makeCfg(home);
|
const cfg = makeCfg(home);
|
||||||
const sessionKey = "telegram:slash:111";
|
const sessionKey = "telegram:slash:111";
|
||||||
const list = await getReplyFromConfig(
|
|
||||||
{
|
|
||||||
Body: "/model",
|
|
||||||
From: "telegram:111",
|
|
||||||
To: "telegram:111",
|
|
||||||
ChatType: "direct",
|
|
||||||
Provider: "telegram",
|
|
||||||
Surface: "telegram",
|
|
||||||
SessionKey: sessionKey,
|
|
||||||
CommandAuthorized: true,
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
cfg,
|
|
||||||
);
|
|
||||||
|
|
||||||
const listText = Array.isArray(list) ? list[0]?.text : list?.text;
|
|
||||||
const lines = normalizeTestText(listText ?? "")
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const targetLine = lines.find((line) =>
|
|
||||||
line.includes("openrouter/anthropic/claude-opus-4-5"),
|
|
||||||
);
|
|
||||||
expect(targetLine).toBeDefined();
|
|
||||||
const match = targetLine?.match(/^(\d+)\)/);
|
|
||||||
expect(match?.[1]).toBeDefined();
|
|
||||||
const index = Number.parseInt(match?.[1] ?? "", 10);
|
|
||||||
expect(Number.isFinite(index)).toBe(true);
|
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{
|
{
|
||||||
Body: `/model ${index}`,
|
Body: "/model openrouter/anthropic/claude-opus-4-5",
|
||||||
From: "telegram:111",
|
From: "telegram:111",
|
||||||
To: "telegram:111",
|
To: "telegram:111",
|
||||||
ChatType: "direct",
|
ChatType: "direct",
|
||||||
@@ -238,7 +201,7 @@ describe("trigger handling", () => {
|
|||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(normalizeTestText(text ?? "")).toContain(
|
expect(normalizeTestText(text ?? "")).toContain(
|
||||||
'Invalid model selection "99". Use /model to list.',
|
"Numeric model selection is not supported in chat.",
|
||||||
);
|
);
|
||||||
|
|
||||||
const store = loadSessionStore(cfg.session.store);
|
const store = loadSessionStore(cfg.session.store);
|
||||||
@@ -246,15 +209,14 @@ describe("trigger handling", () => {
|
|||||||
expect(store[sessionKey]?.modelOverride).toBeUndefined();
|
expect(store[sessionKey]?.modelOverride).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("selects exact provider/model combo by index via /model <#>", async () => {
|
it("selects exact provider/model combo via /model <provider/model>", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const cfg = makeCfg(home);
|
const cfg = makeCfg(home);
|
||||||
const sessionKey = "telegram:slash:111";
|
const sessionKey = "telegram:slash:111";
|
||||||
|
|
||||||
// /model 1 should select the first item (anthropic/claude-opus-4-5)
|
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{
|
{
|
||||||
Body: "/model 1",
|
Body: "/model anthropic/claude-opus-4-5",
|
||||||
From: "telegram:111",
|
From: "telegram:111",
|
||||||
To: "telegram:111",
|
To: "telegram:111",
|
||||||
ChatType: "direct",
|
ChatType: "direct",
|
||||||
@@ -268,8 +230,9 @@ describe("trigger handling", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
// Selecting the default model shows "reset to default" instead of "set to"
|
expect(normalizeTestText(text ?? "")).toContain(
|
||||||
expect(normalizeTestText(text ?? "")).toContain("anthropic/claude-opus-4-5");
|
"Model reset to default (anthropic/claude-opus-4-5).",
|
||||||
|
);
|
||||||
|
|
||||||
const store = loadSessionStore(cfg.session.store);
|
const store = loadSessionStore(cfg.session.store);
|
||||||
// When selecting the default, overrides are cleared
|
// When selecting the default, overrides are cleared
|
||||||
@@ -277,14 +240,14 @@ describe("trigger handling", () => {
|
|||||||
expect(store[sessionKey]?.modelOverride).toBeUndefined();
|
expect(store[sessionKey]?.modelOverride).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("selects a model by index via /model <#>", async () => {
|
it("selects a model via /model <provider/model>", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const cfg = makeCfg(home);
|
const cfg = makeCfg(home);
|
||||||
const sessionKey = "telegram:slash:111";
|
const sessionKey = "telegram:slash:111";
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{
|
{
|
||||||
Body: "/model 3",
|
Body: "/model openai/gpt-5.2",
|
||||||
From: "telegram:111",
|
From: "telegram:111",
|
||||||
To: "telegram:111",
|
To: "telegram:111",
|
||||||
ChatType: "direct",
|
ChatType: "direct",
|
||||||
|
|||||||
@@ -87,4 +87,21 @@ describe("buildThreadingToolContext", () => {
|
|||||||
|
|
||||||
expect(result.currentChannelId).toBe("chat_id:7");
|
expect(result.currentChannelId).toBe("chat_id:7");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers MessageThreadId for Slack tool threading", () => {
|
||||||
|
const sessionCtx = {
|
||||||
|
Provider: "slack",
|
||||||
|
To: "channel:C1",
|
||||||
|
MessageThreadId: "123.456",
|
||||||
|
} as TemplateContext;
|
||||||
|
|
||||||
|
const result = buildThreadingToolContext({
|
||||||
|
sessionCtx,
|
||||||
|
config: { channels: { slack: { replyToMode: "all" } } } as ClawdbotConfig,
|
||||||
|
hasRepliedRef: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.currentChannelId).toBe("C1");
|
||||||
|
expect(result.currentThreadTs).toBe("123.456");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -24,18 +24,11 @@ export function buildThreadingToolContext(params: {
|
|||||||
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
|
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
|
||||||
if (!rawProvider) return {};
|
if (!rawProvider) return {};
|
||||||
const provider = normalizeChannelId(rawProvider);
|
const provider = normalizeChannelId(rawProvider);
|
||||||
// WhatsApp context isolation keys off conversation id, not the bot's own number.
|
|
||||||
const threadingTo =
|
|
||||||
rawProvider === "whatsapp"
|
|
||||||
? (sessionCtx.From ?? sessionCtx.To)
|
|
||||||
: rawProvider === "imessage" && sessionCtx.ChatType === "direct"
|
|
||||||
? (sessionCtx.From ?? sessionCtx.To)
|
|
||||||
: sessionCtx.To;
|
|
||||||
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
|
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
|
||||||
const dock = provider ? getChannelDock(provider) : undefined;
|
const dock = provider ? getChannelDock(provider) : undefined;
|
||||||
if (!dock?.threading?.buildToolContext) {
|
if (!dock?.threading?.buildToolContext) {
|
||||||
return {
|
return {
|
||||||
currentChannelId: threadingTo?.trim() || undefined,
|
currentChannelId: sessionCtx.To?.trim() || undefined,
|
||||||
currentChannelProvider: provider ?? (rawProvider as ChannelId),
|
currentChannelProvider: provider ?? (rawProvider as ChannelId),
|
||||||
hasRepliedRef,
|
hasRepliedRef,
|
||||||
};
|
};
|
||||||
@@ -46,7 +39,9 @@ export function buildThreadingToolContext(params: {
|
|||||||
accountId: sessionCtx.AccountId,
|
accountId: sessionCtx.AccountId,
|
||||||
context: {
|
context: {
|
||||||
Channel: sessionCtx.Provider,
|
Channel: sessionCtx.Provider,
|
||||||
To: threadingTo,
|
From: sessionCtx.From,
|
||||||
|
To: sessionCtx.To,
|
||||||
|
ChatType: sessionCtx.ChatType,
|
||||||
ReplyToId: sessionCtx.ReplyToId,
|
ReplyToId: sessionCtx.ReplyToId,
|
||||||
ThreadLabel: sessionCtx.ThreadLabel,
|
ThreadLabel: sessionCtx.ThreadLabel,
|
||||||
MessageThreadId: sessionCtx.MessageThreadId,
|
MessageThreadId: sessionCtx.MessageThreadId,
|
||||||
|
|||||||
+1
@@ -172,6 +172,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
|||||||
|
|
||||||
const { run, typing } = createMinimalRun({
|
const { run, typing } = createMinimalRun({
|
||||||
opts: { isHeartbeat: false, onPartialReply },
|
opts: { isHeartbeat: false, onPartialReply },
|
||||||
|
typingMode: "message",
|
||||||
});
|
});
|
||||||
await run();
|
await run();
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ export async function runReplyAgent(params: {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await typingSignals.signalRunStart();
|
||||||
|
|
||||||
activeSessionEntry = await runMemoryFlushIfNeeded({
|
activeSessionEntry = await runMemoryFlushIfNeeded({
|
||||||
cfg,
|
cfg,
|
||||||
followupRun,
|
followupRun,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "./commands-info.js";
|
} from "./commands-info.js";
|
||||||
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
||||||
import { handleSubagentsCommand } from "./commands-subagents.js";
|
import { handleSubagentsCommand } from "./commands-subagents.js";
|
||||||
|
import { handleModelsCommand } from "./commands-models.js";
|
||||||
import {
|
import {
|
||||||
handleAbortTrigger,
|
handleAbortTrigger,
|
||||||
handleActivationCommand,
|
handleActivationCommand,
|
||||||
@@ -44,6 +45,7 @@ const HANDLERS: CommandHandler[] = [
|
|||||||
handleSubagentsCommand,
|
handleSubagentsCommand,
|
||||||
handleConfigCommand,
|
handleConfigCommand,
|
||||||
handleDebugCommand,
|
handleDebugCommand,
|
||||||
|
handleModelsCommand,
|
||||||
handleStopCommand,
|
handleStopCommand,
|
||||||
handleCompactCommand,
|
handleCompactCommand,
|
||||||
handleAbortTrigger,
|
handleAbortTrigger,
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { MsgContext } from "../templating.js";
|
||||||
|
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||||
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
|
|
||||||
|
vi.mock("../../agents/model-catalog.js", () => ({
|
||||||
|
loadModelCatalog: vi.fn(async () => [
|
||||||
|
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
|
||||||
|
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet" },
|
||||||
|
{ provider: "openai", id: "gpt-4.1", name: "GPT-4.1" },
|
||||||
|
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 Mini" },
|
||||||
|
{ provider: "google", id: "gemini-2.0-flash", name: "Gemini Flash" },
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||||
|
const ctx = {
|
||||||
|
Body: commandBody,
|
||||||
|
CommandBody: commandBody,
|
||||||
|
CommandSource: "text",
|
||||||
|
CommandAuthorized: true,
|
||||||
|
Provider: "telegram",
|
||||||
|
Surface: "telegram",
|
||||||
|
...ctxOverrides,
|
||||||
|
} as MsgContext;
|
||||||
|
|
||||||
|
const command = buildCommandContext({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
isGroup: false,
|
||||||
|
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
command,
|
||||||
|
directives: parseInlineDirectives(commandBody),
|
||||||
|
elevated: { enabled: true, allowed: true, failures: [] },
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
workspaceDir: "/tmp",
|
||||||
|
defaultGroupActivation: () => "mention",
|
||||||
|
resolvedVerboseLevel: "off" as const,
|
||||||
|
resolvedReasoningLevel: "off" as const,
|
||||||
|
resolveDefaultThinkingLevel: async () => undefined,
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-opus-4-5",
|
||||||
|
contextTokens: 16000,
|
||||||
|
isGroup: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("/models command", () => {
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
// allowlist is empty => allowAny, but still okay for listing
|
||||||
|
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||||
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
|
it.each(["telegram", "discord", "whatsapp"])("lists providers on %s", async (surface) => {
|
||||||
|
const params = buildParams("/models", cfg, { Provider: surface, Surface: surface });
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Providers:");
|
||||||
|
expect(result.reply?.text).toContain("anthropic");
|
||||||
|
expect(result.reply?.text).toContain("Use: /models <provider>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists provider models with pagination hints", async () => {
|
||||||
|
const params = buildParams("/models anthropic", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Models (anthropic)");
|
||||||
|
expect(result.reply?.text).toContain("page 1/");
|
||||||
|
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
||||||
|
expect(result.reply?.text).toContain("Switch: /model <provider/model>");
|
||||||
|
expect(result.reply?.text).toContain("All: /models anthropic all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes configured providers and defaults", async () => {
|
||||||
|
const configuredCfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: "synthetic/synth-1",
|
||||||
|
fallbacks: ["synthetic/synth-2"],
|
||||||
|
},
|
||||||
|
imageModel: {
|
||||||
|
primary: "synthetic/synth-image",
|
||||||
|
fallbacks: ["synthetic/synth-image-2"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
synthetic: {
|
||||||
|
baseUrl: "https://example.com",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "synth-3",
|
||||||
|
name: "Synth 3",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
|
const providersResult = await handleCommands(buildParams("/models", configuredCfg));
|
||||||
|
expect(providersResult.shouldContinue).toBe(false);
|
||||||
|
expect(providersResult.reply?.text).toContain("synthetic");
|
||||||
|
|
||||||
|
const modelsResult = await handleCommands(buildParams("/models synthetic", configuredCfg));
|
||||||
|
expect(modelsResult.shouldContinue).toBe(false);
|
||||||
|
expect(modelsResult.reply?.text).toContain("synthetic/synth-1");
|
||||||
|
expect(modelsResult.reply?.text).toContain("synthetic/synth-2");
|
||||||
|
expect(modelsResult.reply?.text).toContain("synthetic/synth-3");
|
||||||
|
expect(modelsResult.reply?.text).toContain("synthetic/synth-image");
|
||||||
|
expect(modelsResult.reply?.text).toContain("synthetic/synth-image-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on out-of-range pages", async () => {
|
||||||
|
const params = buildParams("/models anthropic 4", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Page out of range");
|
||||||
|
expect(result.reply?.text).toContain("valid: 1-");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles unknown providers", async () => {
|
||||||
|
const params = buildParams("/models not-a-provider", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Unknown provider");
|
||||||
|
expect(result.reply?.text).toContain("Available providers");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { loadModelCatalog } from "../../agents/model-catalog.js";
|
||||||
|
import {
|
||||||
|
buildAllowedModelSet,
|
||||||
|
buildModelAliasIndex,
|
||||||
|
normalizeProviderId,
|
||||||
|
resolveConfiguredModelRef,
|
||||||
|
resolveModelRefFromString,
|
||||||
|
} from "../../agents/model-selection.js";
|
||||||
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
|
||||||
|
import type { ReplyPayload } from "../types.js";
|
||||||
|
import type { CommandHandler } from "./commands-types.js";
|
||||||
|
|
||||||
|
const PAGE_SIZE_DEFAULT = 20;
|
||||||
|
const PAGE_SIZE_MAX = 100;
|
||||||
|
|
||||||
|
function formatProviderLine(params: { provider: string; count: number }): string {
|
||||||
|
return `- ${params.provider} (${params.count})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addModelRef(map: Map<string, Set<string>>, provider: string, model: string): void {
|
||||||
|
const normalizedProvider = normalizeProviderId(provider);
|
||||||
|
const normalizedModel = String(model ?? "").trim();
|
||||||
|
if (!normalizedProvider || !normalizedModel) return;
|
||||||
|
const set = map.get(normalizedProvider) ?? new Set<string>();
|
||||||
|
set.add(normalizedModel);
|
||||||
|
map.set(normalizedProvider, set);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseModelsArgs(raw: string): {
|
||||||
|
provider?: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
all: boolean;
|
||||||
|
} {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { page: 1, pageSize: PAGE_SIZE_DEFAULT, all: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = trimmed.split(/\s+/g).filter(Boolean);
|
||||||
|
const provider = tokens[0]?.trim();
|
||||||
|
|
||||||
|
let page = 1;
|
||||||
|
let all = false;
|
||||||
|
for (const token of tokens.slice(1)) {
|
||||||
|
const lower = token.toLowerCase();
|
||||||
|
if (lower === "all" || lower === "--all") {
|
||||||
|
all = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower.startsWith("page=")) {
|
||||||
|
const value = Number.parseInt(lower.slice("page=".length), 10);
|
||||||
|
if (Number.isFinite(value) && value > 0) page = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^[0-9]+$/.test(lower)) {
|
||||||
|
const value = Number.parseInt(lower, 10);
|
||||||
|
if (Number.isFinite(value) && value > 0) page = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pageSize = PAGE_SIZE_DEFAULT;
|
||||||
|
for (const token of tokens) {
|
||||||
|
const lower = token.toLowerCase();
|
||||||
|
if (lower.startsWith("limit=") || lower.startsWith("size=")) {
|
||||||
|
const rawValue = lower.slice(lower.indexOf("=") + 1);
|
||||||
|
const value = Number.parseInt(rawValue, 10);
|
||||||
|
if (Number.isFinite(value) && value > 0) pageSize = Math.min(PAGE_SIZE_MAX, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: provider ? normalizeProviderId(provider) : undefined,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
all,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||||
|
if (!allowTextCommands) return null;
|
||||||
|
|
||||||
|
const body = params.command.commandBodyNormalized.trim();
|
||||||
|
if (!body.startsWith("/models")) return null;
|
||||||
|
|
||||||
|
const argText = body.replace(/^\/models\b/i, "").trim();
|
||||||
|
const { provider, page, pageSize, all } = parseModelsArgs(argText);
|
||||||
|
|
||||||
|
const resolvedDefault = resolveConfiguredModelRef({
|
||||||
|
cfg: params.cfg,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
defaultModel: DEFAULT_MODEL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const catalog = await loadModelCatalog({ config: params.cfg });
|
||||||
|
const allowed = buildAllowedModelSet({
|
||||||
|
cfg: params.cfg,
|
||||||
|
catalog,
|
||||||
|
defaultProvider: resolvedDefault.provider,
|
||||||
|
defaultModel: resolvedDefault.model,
|
||||||
|
});
|
||||||
|
|
||||||
|
const byProvider = new Map<string, Set<string>>();
|
||||||
|
const aliasIndex = buildModelAliasIndex({
|
||||||
|
cfg: params.cfg,
|
||||||
|
defaultProvider: resolvedDefault.provider,
|
||||||
|
});
|
||||||
|
const addRaw = (raw?: string) => {
|
||||||
|
const value = String(raw ?? "").trim();
|
||||||
|
if (!value) return;
|
||||||
|
const resolved = resolveModelRefFromString({
|
||||||
|
raw: value,
|
||||||
|
defaultProvider: resolvedDefault.provider,
|
||||||
|
aliasIndex,
|
||||||
|
});
|
||||||
|
if (!resolved) return;
|
||||||
|
addModelRef(byProvider, resolved.ref.provider, resolved.ref.model);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entry of allowed.allowedCatalog) {
|
||||||
|
addModelRef(byProvider, entry.provider, entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
addModelRef(byProvider, resolvedDefault.provider, resolvedDefault.model);
|
||||||
|
|
||||||
|
const modelConfig = params.cfg.agents?.defaults?.model;
|
||||||
|
const modelFallbacks =
|
||||||
|
modelConfig && typeof modelConfig === "object" ? (modelConfig.fallbacks ?? []) : [];
|
||||||
|
for (const fallback of modelFallbacks) {
|
||||||
|
addRaw(String(fallback ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageConfig = params.cfg.agents?.defaults?.imageModel;
|
||||||
|
if (imageConfig) {
|
||||||
|
if (typeof imageConfig === "string") {
|
||||||
|
addRaw(imageConfig);
|
||||||
|
} else {
|
||||||
|
addRaw(imageConfig.primary);
|
||||||
|
for (const fallback of imageConfig.fallbacks ?? []) {
|
||||||
|
addRaw(String(fallback ?? ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
|
||||||
|
addRaw(String(raw ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [provider, providerConfig] of Object.entries(params.cfg.models?.providers ?? {})) {
|
||||||
|
for (const modelDef of providerConfig?.models ?? []) {
|
||||||
|
addModelRef(byProvider, provider, modelDef?.id ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = [...byProvider.keys()].sort();
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
const lines: string[] = [
|
||||||
|
"Providers:",
|
||||||
|
...providers.map((p) =>
|
||||||
|
formatProviderLine({ provider: p, count: byProvider.get(p)?.size ?? 0 }),
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
"Use: /models <provider>",
|
||||||
|
"Switch: /model <provider/model>",
|
||||||
|
];
|
||||||
|
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!byProvider.has(provider)) {
|
||||||
|
const lines: string[] = [
|
||||||
|
`Unknown provider: ${provider}`,
|
||||||
|
"",
|
||||||
|
"Available providers:",
|
||||||
|
...providers.map((p) => `- ${p}`),
|
||||||
|
"",
|
||||||
|
"Use: /models <provider>",
|
||||||
|
];
|
||||||
|
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = [...(byProvider.get(provider) ?? new Set<string>())].sort();
|
||||||
|
const total = models.length;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
const lines: string[] = [
|
||||||
|
`Models (${provider}) — none`,
|
||||||
|
"",
|
||||||
|
"Browse: /models",
|
||||||
|
"Switch: /model <provider/model>",
|
||||||
|
];
|
||||||
|
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectivePageSize = all ? total : pageSize;
|
||||||
|
const pageCount = effectivePageSize > 0 ? Math.ceil(total / effectivePageSize) : 1;
|
||||||
|
const safePage = all ? 1 : Math.max(1, Math.min(page, pageCount));
|
||||||
|
|
||||||
|
if (!all && page !== safePage) {
|
||||||
|
const lines: string[] = [
|
||||||
|
`Page out of range: ${page} (valid: 1-${pageCount})`,
|
||||||
|
"",
|
||||||
|
`Try: /models ${provider} ${safePage}`,
|
||||||
|
`All: /models ${provider} all`,
|
||||||
|
];
|
||||||
|
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = (safePage - 1) * effectivePageSize;
|
||||||
|
const endIndexExclusive = Math.min(total, startIndex + effectivePageSize);
|
||||||
|
const pageModels = models.slice(startIndex, endIndexExclusive);
|
||||||
|
|
||||||
|
const header = `Models (${provider}) — showing ${startIndex + 1}-${endIndexExclusive} of ${total} (page ${safePage}/${pageCount})`;
|
||||||
|
|
||||||
|
const lines: string[] = [header];
|
||||||
|
for (const id of pageModels) {
|
||||||
|
lines.push(`- ${provider}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("", "Switch: /model <provider/model>");
|
||||||
|
if (!all && safePage < pageCount) {
|
||||||
|
lines.push(`More: /models ${provider} ${safePage + 1}`);
|
||||||
|
}
|
||||||
|
if (!all) {
|
||||||
|
lines.push(`All: /models ${provider} all`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: ReplyPayload = { text: lines.join("\n") };
|
||||||
|
return { reply: payload, shouldContinue: false };
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ModelAliasIndex } from "../../agents/model-selection.js";
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
|
import {
|
||||||
|
maybeHandleModelDirectiveInfo,
|
||||||
|
resolveModelSelectionFromDirective,
|
||||||
|
} from "./directive-handling.model.js";
|
||||||
|
|
||||||
|
function baseAliasIndex(): ModelAliasIndex {
|
||||||
|
return { byAlias: new Map(), byKey: new Map() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("/model chat UX", () => {
|
||||||
|
it("shows summary for /model with no args", async () => {
|
||||||
|
const directives = parseInlineDirectives("/model");
|
||||||
|
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
|
const reply = await maybeHandleModelDirectiveInfo({
|
||||||
|
directives,
|
||||||
|
cfg,
|
||||||
|
agentDir: "/tmp/agent",
|
||||||
|
activeAgentId: "main",
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-opus-4-5",
|
||||||
|
defaultProvider: "anthropic",
|
||||||
|
defaultModel: "claude-opus-4-5",
|
||||||
|
aliasIndex: baseAliasIndex(),
|
||||||
|
allowedModelCatalog: [],
|
||||||
|
resetModelOverride: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reply?.text).toContain("Current:");
|
||||||
|
expect(reply?.text).toContain("Browse: /models");
|
||||||
|
expect(reply?.text).toContain("Switch: /model <provider/model>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suggests closest match for typos without switching", () => {
|
||||||
|
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
|
||||||
|
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
|
const resolved = resolveModelSelectionFromDirective({
|
||||||
|
directives,
|
||||||
|
cfg,
|
||||||
|
agentDir: "/tmp/agent",
|
||||||
|
defaultProvider: "anthropic",
|
||||||
|
defaultModel: "claude-opus-4-5",
|
||||||
|
aliasIndex: baseAliasIndex(),
|
||||||
|
allowedModelKeys: new Set(["anthropic/claude-opus-4-5"]),
|
||||||
|
allowedModelCatalog: [{ provider: "anthropic", id: "claude-opus-4-5" }],
|
||||||
|
provider: "anthropic",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved.modelSelection).toBeUndefined();
|
||||||
|
expect(resolved.errorText).toContain("Did you mean:");
|
||||||
|
expect(resolved.errorText).toContain("anthropic/claude-opus-4-5");
|
||||||
|
expect(resolved.errorText).toContain("Try: /model anthropic/claude-opus-4-5");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
resolveProfileOverride,
|
resolveProfileOverride,
|
||||||
} from "./directive-handling.auth.js";
|
} from "./directive-handling.auth.js";
|
||||||
import {
|
import {
|
||||||
buildModelPickerItems,
|
|
||||||
type ModelPickerCatalogEntry,
|
type ModelPickerCatalogEntry,
|
||||||
resolveProviderEndpointLabel,
|
resolveProviderEndpointLabel,
|
||||||
} from "./directive-handling.model-picker.js";
|
} from "./directive-handling.model-picker.js";
|
||||||
@@ -169,8 +168,9 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
|||||||
const rawDirective = params.directives.rawModelDirective?.trim();
|
const rawDirective = params.directives.rawModelDirective?.trim();
|
||||||
const directive = rawDirective?.toLowerCase();
|
const directive = rawDirective?.toLowerCase();
|
||||||
const wantsStatus = directive === "status";
|
const wantsStatus = directive === "status";
|
||||||
const wantsList = !rawDirective || directive === "list";
|
const wantsSummary = !rawDirective;
|
||||||
if (!wantsList && !wantsStatus) return undefined;
|
const wantsLegacyList = directive === "list";
|
||||||
|
if (!wantsSummary && !wantsStatus && !wantsLegacyList) return undefined;
|
||||||
|
|
||||||
if (params.directives.rawModelProfile) {
|
if (params.directives.rawModelProfile) {
|
||||||
return { text: "Auth profile override requires a model selection." };
|
return { text: "Auth profile override requires a model selection." };
|
||||||
@@ -184,16 +184,28 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
|||||||
allowedModelCatalog: params.allowedModelCatalog,
|
allowedModelCatalog: params.allowedModelCatalog,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (wantsList) {
|
if (wantsLegacyList) {
|
||||||
const items = buildModelPickerItems(pickerCatalog);
|
return {
|
||||||
if (items.length === 0) return { text: "No models available." };
|
text: [
|
||||||
|
"Model listing moved.",
|
||||||
|
"",
|
||||||
|
"Use: /models (providers) or /models <provider> (models)",
|
||||||
|
"Switch: /model <provider/model>",
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsSummary) {
|
||||||
const current = `${params.provider}/${params.model}`;
|
const current = `${params.provider}/${params.model}`;
|
||||||
const lines: string[] = [`Current: ${current}`, "Pick: /model <#> or /model <provider/model>"];
|
return {
|
||||||
for (const [idx, item] of items.entries()) {
|
text: [
|
||||||
lines.push(`${idx + 1}) ${item.provider}/${item.model}`);
|
`Current: ${current}`,
|
||||||
}
|
"",
|
||||||
lines.push("", "More: /model status");
|
"Switch: /model <provider/model>",
|
||||||
return { text: lines.join("\n") };
|
"Browse: /models (providers) or /models <provider> (models)",
|
||||||
|
"More: /model status",
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelsPath = `${params.agentDir}/models.json`;
|
const modelsPath = `${params.agentDir}/models.json`;
|
||||||
@@ -285,31 +297,36 @@ export function resolveModelSelectionFromDirective(params: {
|
|||||||
let modelSelection: ModelDirectiveSelection | undefined;
|
let modelSelection: ModelDirectiveSelection | undefined;
|
||||||
|
|
||||||
if (/^[0-9]+$/.test(raw)) {
|
if (/^[0-9]+$/.test(raw)) {
|
||||||
const pickerCatalog = buildModelPickerCatalog({
|
return {
|
||||||
cfg: params.cfg,
|
errorText: [
|
||||||
defaultProvider: params.defaultProvider,
|
"Numeric model selection is not supported in chat.",
|
||||||
defaultModel: params.defaultModel,
|
"",
|
||||||
aliasIndex: params.aliasIndex,
|
"Browse: /models or /models <provider>",
|
||||||
allowedModelCatalog: params.allowedModelCatalog,
|
"Switch: /model <provider/model>",
|
||||||
});
|
].join("\n"),
|
||||||
const items = buildModelPickerItems(pickerCatalog);
|
};
|
||||||
const index = Number.parseInt(raw, 10) - 1;
|
}
|
||||||
const item = Number.isFinite(index) ? items[index] : undefined;
|
|
||||||
if (!item) {
|
const explicit = resolveModelRefFromString({
|
||||||
return {
|
raw,
|
||||||
errorText: `Invalid model selection "${raw}". Use /model to list.`,
|
defaultProvider: params.defaultProvider,
|
||||||
|
aliasIndex: params.aliasIndex,
|
||||||
|
});
|
||||||
|
if (explicit) {
|
||||||
|
const explicitKey = modelKey(explicit.ref.provider, explicit.ref.model);
|
||||||
|
if (params.allowedModelKeys.size === 0 || params.allowedModelKeys.has(explicitKey)) {
|
||||||
|
modelSelection = {
|
||||||
|
provider: explicit.ref.provider,
|
||||||
|
model: explicit.ref.model,
|
||||||
|
isDefault:
|
||||||
|
explicit.ref.provider === params.defaultProvider &&
|
||||||
|
explicit.ref.model === params.defaultModel,
|
||||||
|
...(explicit.alias ? { alias: explicit.alias } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const key = `${item.provider}/${item.model}`;
|
}
|
||||||
const aliases = params.aliasIndex.byKey.get(key);
|
|
||||||
const alias = aliases && aliases.length > 0 ? aliases[0] : undefined;
|
if (!modelSelection) {
|
||||||
modelSelection = {
|
|
||||||
provider: item.provider,
|
|
||||||
model: item.model,
|
|
||||||
isDefault: item.provider === params.defaultProvider && item.model === params.defaultModel,
|
|
||||||
...(alias ? { alias } : {}),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const resolved = resolveModelDirectiveSelection({
|
const resolved = resolveModelDirectiveSelection({
|
||||||
raw,
|
raw,
|
||||||
defaultProvider: params.defaultProvider,
|
defaultProvider: params.defaultProvider,
|
||||||
@@ -317,10 +334,24 @@ export function resolveModelSelectionFromDirective(params: {
|
|||||||
aliasIndex: params.aliasIndex,
|
aliasIndex: params.aliasIndex,
|
||||||
allowedModelKeys: params.allowedModelKeys,
|
allowedModelKeys: params.allowedModelKeys,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resolved.error) {
|
if (resolved.error) {
|
||||||
return { errorText: resolved.error };
|
return { errorText: resolved.error };
|
||||||
}
|
}
|
||||||
modelSelection = resolved.selection;
|
|
||||||
|
if (resolved.selection) {
|
||||||
|
const suggestion = `${resolved.selection.provider}/${resolved.selection.model}`;
|
||||||
|
return {
|
||||||
|
errorText: [
|
||||||
|
`Unrecognized model: ${raw}`,
|
||||||
|
"",
|
||||||
|
`Did you mean: ${suggestion}`,
|
||||||
|
`Try: /model ${suggestion}`,
|
||||||
|
"",
|
||||||
|
"Browse: /models or /models <provider>",
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let profileOverride: string | undefined;
|
let profileOverride: string | undefined;
|
||||||
|
|||||||
@@ -46,6 +46,38 @@ const FUZZY_VARIANT_TOKENS = [
|
|||||||
"nano",
|
"nano",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function boundedLevenshteinDistance(a: string, b: string, maxDistance: number): number | null {
|
||||||
|
if (a === b) return 0;
|
||||||
|
if (!a || !b) return null;
|
||||||
|
const aLen = a.length;
|
||||||
|
const bLen = b.length;
|
||||||
|
if (Math.abs(aLen - bLen) > maxDistance) return null;
|
||||||
|
|
||||||
|
// Standard DP with early exit. O(maxDistance * minLen) in common cases.
|
||||||
|
const prev = Array.from({ length: bLen + 1 }, (_, index) => index);
|
||||||
|
const curr = Array.from({ length: bLen + 1 }, () => 0);
|
||||||
|
|
||||||
|
for (let i = 1; i <= aLen; i++) {
|
||||||
|
curr[0] = i;
|
||||||
|
let rowMin = curr[0];
|
||||||
|
|
||||||
|
const aChar = a.charCodeAt(i - 1);
|
||||||
|
for (let j = 1; j <= bLen; j++) {
|
||||||
|
const cost = aChar === b.charCodeAt(j - 1) ? 0 : 1;
|
||||||
|
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
||||||
|
if (curr[j] < rowMin) rowMin = curr[j];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowMin > maxDistance) return null;
|
||||||
|
|
||||||
|
for (let j = 0; j <= bLen; j++) prev[j] = curr[j] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dist = prev[bLen] ?? null;
|
||||||
|
if (dist == null || dist > maxDistance) return null;
|
||||||
|
return dist;
|
||||||
|
}
|
||||||
|
|
||||||
function scoreFuzzyMatch(params: {
|
function scoreFuzzyMatch(params: {
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
@@ -94,6 +126,13 @@ function scoreFuzzyMatch(params: {
|
|||||||
includes: 80,
|
includes: 80,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Best-effort typo tolerance for common near-misses like "claud" vs "claude".
|
||||||
|
// Bounded to keep this cheap across large model sets.
|
||||||
|
const distModel = boundedLevenshteinDistance(fragment, modelLower, 3);
|
||||||
|
if (distModel != null) {
|
||||||
|
score += (3 - distModel) * 70;
|
||||||
|
}
|
||||||
|
|
||||||
const aliases = params.aliasIndex.byKey.get(key) ?? [];
|
const aliases = params.aliasIndex.byKey.get(key) ?? [];
|
||||||
for (const alias of aliases) {
|
for (const alias of aliases) {
|
||||||
score += scoreFragment(alias.toLowerCase(), {
|
score += scoreFragment(alias.toLowerCase(), {
|
||||||
@@ -293,17 +332,16 @@ export function resolveModelDirectiveSelection(params: {
|
|||||||
const fragment = params.fragment.trim().toLowerCase();
|
const fragment = params.fragment.trim().toLowerCase();
|
||||||
if (!fragment) return {};
|
if (!fragment) return {};
|
||||||
|
|
||||||
|
const providerFilter = params.provider ? normalizeProviderId(params.provider) : undefined;
|
||||||
|
|
||||||
const candidates: Array<{ provider: string; model: string }> = [];
|
const candidates: Array<{ provider: string; model: string }> = [];
|
||||||
for (const key of allowedModelKeys) {
|
for (const key of allowedModelKeys) {
|
||||||
const slash = key.indexOf("/");
|
const slash = key.indexOf("/");
|
||||||
if (slash <= 0) continue;
|
if (slash <= 0) continue;
|
||||||
const provider = normalizeProviderId(key.slice(0, slash));
|
const provider = normalizeProviderId(key.slice(0, slash));
|
||||||
const model = key.slice(slash + 1);
|
const model = key.slice(slash + 1);
|
||||||
if (params.provider && provider !== normalizeProviderId(params.provider)) continue;
|
if (providerFilter && provider !== providerFilter) continue;
|
||||||
const haystack = `${provider}/${model}`.toLowerCase();
|
candidates.push({ provider, model });
|
||||||
if (haystack.includes(fragment) || model.toLowerCase().includes(fragment)) {
|
|
||||||
candidates.push({ provider, model });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also allow partial alias matches when the user didn't specify a provider.
|
// Also allow partial alias matches when the user didn't specify a provider.
|
||||||
@@ -325,11 +363,6 @@ export function resolveModelDirectiveSelection(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidates.length === 1) {
|
|
||||||
const match = candidates[0];
|
|
||||||
if (!match) return {};
|
|
||||||
return { selection: buildSelection(match.provider, match.model) };
|
|
||||||
}
|
|
||||||
if (candidates.length === 0) return {};
|
if (candidates.length === 0) return {};
|
||||||
|
|
||||||
const scored = candidates
|
const scored = candidates
|
||||||
@@ -354,8 +387,13 @@ export function resolveModelDirectiveSelection(params: {
|
|||||||
return a.key.localeCompare(b.key);
|
return a.key.localeCompare(b.key);
|
||||||
});
|
});
|
||||||
|
|
||||||
const best = scored[0]?.candidate;
|
const bestScored = scored[0];
|
||||||
if (!best) return {};
|
const best = bestScored?.candidate;
|
||||||
|
if (!best || !bestScored) return {};
|
||||||
|
|
||||||
|
const minScore = providerFilter ? 90 : 120;
|
||||||
|
if (bestScored.score < minScore) return {};
|
||||||
|
|
||||||
return { selection: buildSelection(best.provider, best.model) };
|
return { selection: buildSelection(best.provider, best.model) };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -369,7 +407,7 @@ export function resolveModelDirectiveSelection(params: {
|
|||||||
const fuzzy = resolveFuzzy({ fragment: rawTrimmed });
|
const fuzzy = resolveFuzzy({ fragment: rawTrimmed });
|
||||||
if (fuzzy.selection || fuzzy.error) return fuzzy;
|
if (fuzzy.selection || fuzzy.error) return fuzzy;
|
||||||
return {
|
return {
|
||||||
error: `Unrecognized model "${rawTrimmed}". Use /model to list available models.`,
|
error: `Unrecognized model "${rawTrimmed}". Use /models to list providers, or /models <provider> to list models.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +438,7 @@ export function resolveModelDirectiveSelection(params: {
|
|||||||
if (fuzzy.selection || fuzzy.error) return fuzzy;
|
if (fuzzy.selection || fuzzy.error) return fuzzy;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /model to list available models.`,
|
error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /models to list providers, or /models <provider> to list models.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getChannelPlugin } from "../../../channels/plugins/index.js";
|
||||||
|
import type { InboundDebounceByProvider } from "../../../config/types.messages.js";
|
||||||
import { normalizeQueueDropPolicy, normalizeQueueMode } from "./normalize.js";
|
import { normalizeQueueDropPolicy, normalizeQueueMode } from "./normalize.js";
|
||||||
import { DEFAULT_QUEUE_CAP, DEFAULT_QUEUE_DEBOUNCE_MS, DEFAULT_QUEUE_DROP } from "./state.js";
|
import { DEFAULT_QUEUE_CAP, DEFAULT_QUEUE_DEBOUNCE_MS, DEFAULT_QUEUE_DROP } from "./state.js";
|
||||||
import type { QueueMode, QueueSettings, ResolveQueueSettingsParams } from "./types.js";
|
import type { QueueMode, QueueSettings, ResolveQueueSettingsParams } from "./types.js";
|
||||||
@@ -6,6 +8,23 @@ function defaultQueueModeForChannel(_channel?: string): QueueMode {
|
|||||||
return "collect";
|
return "collect";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve per-channel debounce override from debounceMsByChannel map. */
|
||||||
|
function resolveChannelDebounce(
|
||||||
|
byChannel: InboundDebounceByProvider | undefined,
|
||||||
|
channelKey: string | undefined,
|
||||||
|
): number | undefined {
|
||||||
|
if (!channelKey || !byChannel) return undefined;
|
||||||
|
const value = byChannel[channelKey];
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePluginDebounce(channelKey: string | undefined): number | undefined {
|
||||||
|
if (!channelKey) return undefined;
|
||||||
|
const plugin = getChannelPlugin(channelKey);
|
||||||
|
const value = plugin?.defaults?.queue?.debounceMs;
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveQueueSettings(params: ResolveQueueSettingsParams): QueueSettings {
|
export function resolveQueueSettings(params: ResolveQueueSettingsParams): QueueSettings {
|
||||||
const channelKey = params.channel?.trim().toLowerCase();
|
const channelKey = params.channel?.trim().toLowerCase();
|
||||||
const queueCfg = params.cfg.messages?.queue;
|
const queueCfg = params.cfg.messages?.queue;
|
||||||
@@ -22,6 +41,8 @@ export function resolveQueueSettings(params: ResolveQueueSettingsParams): QueueS
|
|||||||
const debounceRaw =
|
const debounceRaw =
|
||||||
params.inlineOptions?.debounceMs ??
|
params.inlineOptions?.debounceMs ??
|
||||||
params.sessionEntry?.queueDebounceMs ??
|
params.sessionEntry?.queueDebounceMs ??
|
||||||
|
resolveChannelDebounce(queueCfg?.debounceMsByChannel, channelKey) ??
|
||||||
|
resolvePluginDebounce(channelKey) ??
|
||||||
queueCfg?.debounceMs ??
|
queueCfg?.debounceMs ??
|
||||||
DEFAULT_QUEUE_DEBOUNCE_MS;
|
DEFAULT_QUEUE_DEBOUNCE_MS;
|
||||||
const capRaw =
|
const capRaw =
|
||||||
|
|||||||
@@ -436,3 +436,42 @@ describe("initSessionState reset policy", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("initSessionState channel reset overrides", () => {
|
||||||
|
it("uses channel-specific reset policy when configured", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-channel-idle-"));
|
||||||
|
const storePath = path.join(root, "sessions.json");
|
||||||
|
const sessionKey = "agent:main:discord:dm:123";
|
||||||
|
const sessionId = "session-override";
|
||||||
|
const updatedAt = Date.now() - (10080 - 1) * 60_000;
|
||||||
|
|
||||||
|
await saveSessionStore(storePath, {
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId,
|
||||||
|
updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
session: {
|
||||||
|
store: storePath,
|
||||||
|
idleMinutes: 60,
|
||||||
|
resetByType: { dm: { mode: "idle", idleMinutes: 10 } },
|
||||||
|
resetByChannel: { discord: { mode: "idle", idleMinutes: 10080 } },
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const result = await initSessionState({
|
||||||
|
ctx: {
|
||||||
|
Body: "Hello",
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
Provider: "discord",
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isNewSession).toBe(false);
|
||||||
|
expect(result.sessionEntry.sessionId).toBe(sessionId);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
evaluateSessionFreshness,
|
evaluateSessionFreshness,
|
||||||
type GroupKeyResolution,
|
type GroupKeyResolution,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
resolveChannelResetConfig,
|
||||||
resolveThreadFlag,
|
resolveThreadFlag,
|
||||||
resolveSessionResetPolicy,
|
resolveSessionResetPolicy,
|
||||||
resolveSessionResetType,
|
resolveSessionResetType,
|
||||||
@@ -106,6 +107,7 @@ export async function initSessionState(params: {
|
|||||||
sessionKey: sessionCtxForState.SessionKey,
|
sessionKey: sessionCtxForState.SessionKey,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
});
|
});
|
||||||
|
const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined;
|
||||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||||
? sessionCfg.resetTriggers
|
? sessionCfg.resetTriggers
|
||||||
: DEFAULT_RESET_TRIGGERS;
|
: DEFAULT_RESET_TRIGGERS;
|
||||||
@@ -129,7 +131,6 @@ export async function initSessionState(params: {
|
|||||||
let persistedModelOverride: string | undefined;
|
let persistedModelOverride: string | undefined;
|
||||||
let persistedProviderOverride: string | undefined;
|
let persistedProviderOverride: string | undefined;
|
||||||
|
|
||||||
const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined;
|
|
||||||
const normalizedChatType = normalizeChatType(ctx.ChatType);
|
const normalizedChatType = normalizeChatType(ctx.ChatType);
|
||||||
const isGroup =
|
const isGroup =
|
||||||
normalizedChatType != null && normalizedChatType !== "direct" ? true : Boolean(groupResolution);
|
normalizedChatType != null && normalizedChatType !== "direct" ? true : Boolean(groupResolution);
|
||||||
@@ -195,7 +196,19 @@ export async function initSessionState(params: {
|
|||||||
parentSessionKey: ctx.ParentSessionKey,
|
parentSessionKey: ctx.ParentSessionKey,
|
||||||
});
|
});
|
||||||
const resetType = resolveSessionResetType({ sessionKey, isGroup, isThread });
|
const resetType = resolveSessionResetType({ sessionKey, isGroup, isThread });
|
||||||
const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType });
|
const channelReset = resolveChannelResetConfig({
|
||||||
|
sessionCfg,
|
||||||
|
channel:
|
||||||
|
groupResolution?.channel ??
|
||||||
|
(ctx.OriginatingChannel as string | undefined) ??
|
||||||
|
ctx.Surface ??
|
||||||
|
ctx.Provider,
|
||||||
|
});
|
||||||
|
const resetPolicy = resolveSessionResetPolicy({
|
||||||
|
sessionCfg,
|
||||||
|
resetType,
|
||||||
|
resetOverride: channelReset,
|
||||||
|
});
|
||||||
const freshEntry = entry
|
const freshEntry = entry
|
||||||
? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh
|
? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh
|
||||||
: false;
|
: false;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
d9a36b111dfd93cbbb629fddf075800690ce0ae32a3a2ef201b365f4f6d6f5d5
|
27d5aed982d9f110b44e85254877597e49efae61141de480b4e9f254c04131ce
|
||||||
+15246
-17439
File diff suppressed because it is too large.
Load diff
@@ -16,6 +16,8 @@ export type AllowlistMatch<TSource extends string = AllowlistMatchSource> = {
|
|||||||
matchSource?: TSource;
|
matchSource?: TSource;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatAllowlistMatchMeta(match?: AllowlistMatch | null): string {
|
export function formatAllowlistMatchMeta(
|
||||||
|
match?: { matchKey?: string; matchSource?: string } | null,
|
||||||
|
): string {
|
||||||
return `matchKey=${match?.matchKey ?? "none"} matchSource=${match?.matchSource ?? "none"}`;
|
return `matchKey=${match?.matchKey ?? "none"} matchSource=${match?.matchSource ?? "none"}`;
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
resolveChannelEntryMatch,
|
resolveChannelEntryMatch,
|
||||||
resolveChannelEntryMatchWithFallback,
|
resolveChannelEntryMatchWithFallback,
|
||||||
resolveNestedAllowlistDecision,
|
resolveNestedAllowlistDecision,
|
||||||
|
applyChannelMatchMeta,
|
||||||
|
resolveChannelMatchConfig,
|
||||||
} from "./channel-config.js";
|
} from "./channel-config.js";
|
||||||
|
|
||||||
describe("buildChannelKeyCandidates", () => {
|
describe("buildChannelKeyCandidates", () => {
|
||||||
@@ -90,6 +92,33 @@ describe("resolveChannelEntryMatchWithFallback", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("applyChannelMatchMeta", () => {
|
||||||
|
it("copies match metadata onto resolved configs", () => {
|
||||||
|
const resolved = applyChannelMatchMeta(
|
||||||
|
{ allowed: true },
|
||||||
|
{ matchKey: "general", matchSource: "direct" },
|
||||||
|
);
|
||||||
|
expect(resolved.matchKey).toBe("general");
|
||||||
|
expect(resolved.matchSource).toBe("direct");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveChannelMatchConfig", () => {
|
||||||
|
it("returns null when no entry is matched", () => {
|
||||||
|
const resolved = resolveChannelMatchConfig({ matchKey: "x" }, () => ({ allowed: true }));
|
||||||
|
expect(resolved).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves entry and applies match metadata", () => {
|
||||||
|
const resolved = resolveChannelMatchConfig(
|
||||||
|
{ entry: { allow: true }, matchKey: "*", matchSource: "wildcard" },
|
||||||
|
() => ({ allowed: true }),
|
||||||
|
);
|
||||||
|
expect(resolved?.matchKey).toBe("*");
|
||||||
|
expect(resolved?.matchSource).toBe("wildcard");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveNestedAllowlistDecision", () => {
|
describe("resolveNestedAllowlistDecision", () => {
|
||||||
it("allows when outer allowlist is disabled", () => {
|
it("allows when outer allowlist is disabled", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -11,6 +11,24 @@ export type ChannelEntryMatch<T> = {
|
|||||||
matchSource?: ChannelMatchSource;
|
matchSource?: ChannelMatchSource;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function applyChannelMatchMeta<
|
||||||
|
TResult extends { matchKey?: string; matchSource?: ChannelMatchSource },
|
||||||
|
>(result: TResult, match: ChannelEntryMatch<unknown>): TResult {
|
||||||
|
if (match.matchKey && match.matchSource) {
|
||||||
|
result.matchKey = match.matchKey;
|
||||||
|
result.matchSource = match.matchSource;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveChannelMatchConfig<
|
||||||
|
TEntry,
|
||||||
|
TResult extends { matchKey?: string; matchSource?: ChannelMatchSource },
|
||||||
|
>(match: ChannelEntryMatch<TEntry>, resolveEntry: (entry: TEntry) => TResult): TResult | null {
|
||||||
|
if (!match.entry) return null;
|
||||||
|
return applyChannelMatchMeta(resolveEntry(match.entry), match);
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeChannelSlug(value: string): string {
|
export function normalizeChannelSlug(value: string): string {
|
||||||
return value
|
return value
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
+30
-27
@@ -3,6 +3,7 @@ import { resolveDiscordAccount } from "../discord/accounts.js";
|
|||||||
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
||||||
import { resolveSignalAccount } from "../signal/accounts.js";
|
import { resolveSignalAccount } from "../signal/accounts.js";
|
||||||
import { resolveSlackAccount } from "../slack/accounts.js";
|
import { resolveSlackAccount } from "../slack/accounts.js";
|
||||||
|
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
|
||||||
import { resolveTelegramAccount } from "../telegram/accounts.js";
|
import { resolveTelegramAccount } from "../telegram/accounts.js";
|
||||||
import { normalizeE164 } from "../utils.js";
|
import { normalizeE164 } from "../utils.js";
|
||||||
import { resolveWhatsAppAccount } from "../web/accounts.js";
|
import { resolveWhatsAppAccount } from "../web/accounts.js";
|
||||||
@@ -150,11 +151,14 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||||
currentChannelId: context.To?.trim() || undefined,
|
const channelId = context.From?.trim() || context.To?.trim() || undefined;
|
||||||
currentThreadTs: context.ReplyToId,
|
return {
|
||||||
hasRepliedRef,
|
currentChannelId: channelId,
|
||||||
}),
|
currentThreadTs: context.ReplyToId,
|
||||||
|
hasRepliedRef,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
discord: {
|
discord: {
|
||||||
@@ -221,18 +225,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
||||||
allowTagsWhenOff: true,
|
allowTagsWhenOff: true,
|
||||||
buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => {
|
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||||
const configuredReplyToMode = resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off";
|
|
||||||
const effectiveReplyToMode = context.ThreadLabel ? "all" : configuredReplyToMode;
|
|
||||||
return {
|
|
||||||
currentChannelId: context.To?.startsWith("channel:")
|
|
||||||
? context.To.slice("channel:".length)
|
|
||||||
: undefined,
|
|
||||||
currentThreadTs: context.ReplyToId,
|
|
||||||
replyToMode: effectiveReplyToMode,
|
|
||||||
hasRepliedRef,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
signal: {
|
signal: {
|
||||||
@@ -259,11 +252,16 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||||
currentChannelId: context.To?.trim() || undefined,
|
const isDirect = context.ChatType?.toLowerCase() === "direct";
|
||||||
currentThreadTs: context.ReplyToId,
|
const channelId =
|
||||||
hasRepliedRef,
|
(isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined;
|
||||||
}),
|
return {
|
||||||
|
currentChannelId: channelId,
|
||||||
|
currentThreadTs: context.ReplyToId,
|
||||||
|
hasRepliedRef,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
imessage: {
|
imessage: {
|
||||||
@@ -286,11 +284,16 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
resolveRequireMention: resolveIMessageGroupRequireMention,
|
resolveRequireMention: resolveIMessageGroupRequireMention,
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||||
currentChannelId: context.To?.trim() || undefined,
|
const isDirect = context.ChatType?.toLowerCase() === "direct";
|
||||||
currentThreadTs: context.ReplyToId,
|
const channelId =
|
||||||
hasRepliedRef,
|
(isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined;
|
||||||
}),
|
return {
|
||||||
|
currentChannelId: channelId,
|
||||||
|
currentThreadTs: context.ReplyToId,
|
||||||
|
hasRepliedRef,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
export type { ChannelEntryMatch, ChannelMatchSource } from "../channel-config.js";
|
export type { ChannelEntryMatch, ChannelMatchSource } from "../channel-config.js";
|
||||||
export {
|
export {
|
||||||
|
applyChannelMatchMeta,
|
||||||
buildChannelKeyCandidates,
|
buildChannelKeyCandidates,
|
||||||
normalizeChannelSlug,
|
normalizeChannelSlug,
|
||||||
resolveChannelEntryMatch,
|
resolveChannelEntryMatch,
|
||||||
resolveChannelEntryMatchWithFallback,
|
resolveChannelEntryMatchWithFallback,
|
||||||
|
resolveChannelMatchConfig,
|
||||||
resolveNestedAllowlistDecision,
|
resolveNestedAllowlistDecision,
|
||||||
} from "../channel-config.js";
|
} from "../channel-config.js";
|
||||||
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user