Compare commits

..
Author SHA1 Message Date
Peter Steinberger f4917c2cfa fix: improve /models listings and model directive UX (#1398) (thanks @vignesh07) 2026-01-21 21:52:30 +00:00
Vignesh Natarajan 205a5fd522 fix(models): handle out-of-range pages 2026-01-21 21:03:02 +00:00
Vignesh Natarajan 9ba7c8874b feat(commands): add /models and fix /model listing UX 2026-01-21 21:03:02 +00:00
Peter Steinberger acd40e1780 docs: add showcase video 2026-01-21 20:53:55 +00:00
Peter Steinberger b5fd66c92d fix: add explicit tailnet gateway bind 2026-01-21 20:36:09 +00:00
Peter Steinberger 45c1ccdfcf refactor: unify threading contexts 2026-01-21 20:35:12 +00:00
Peter Steinberger 76600e80ba docs: simplify heartbeat active hours example 2026-01-21 20:33:23 +00:00
Peter Steinberger 483a50f107 fix: correct nodes exec config typing 2026-01-21 20:32:43 +00:00
Peter Steinberger 31943dcecb feat: add heartbeat active hours 2026-01-21 20:30:37 +00:00
Peter Steinberger 717fb9e413 refactor(macos): drop CLI install UI 2026-01-21 20:26:25 +00:00
Peter Steinberger ad7ef27f66 refactor(macos): tidy settings layout 2026-01-21 20:26:25 +00:00
Peter Steinberger 0d3b8f6ac3 feat: make nodes run exec-style 2026-01-21 20:25:12 +00:00
Peter Steinberger 6492e90c1b feat: add auth-aware cache defaults 2026-01-21 20:23:39 +00:00
Peter Steinberger e4b3c8b98d fix(macos): switch connection mode to menu 2026-01-21 20:10:38 +00:00
Peter Steinberger 8b8e078ef8 chore(canvas): update a2ui bundle 2026-01-21 20:10:38 +00:00
Peter Steinberger 44a3539ffa tmp 2026-01-21 20:10:37 +00:00
Peter Steinberger 0daaa5b592 fix: restore 1h cache ttl option 2026-01-21 20:00:32 +00:00
Peter Steinberger 6866cca6d7 docs: clarify cache-ttl pruning window 2026-01-21 20:00:32 +00:00
Peter Steinberger c145a0d116 docs: update changelog 2026-01-21 19:58:20 +00:00
Peter Steinberger 6c0a01dc90 fix: bundle mac model catalog 2026-01-21 19:58:19 +00:00
Peter Steinberger 41c9c214fc fix: drop obsolete pi-mono workarounds 2026-01-21 19:58:19 +00:00
Peter Steinberger 9f999f6554 fix: reset cache-ttl pruning window 2026-01-21 19:53:00 +00:00
Peter Steinberger 9f59ff325b feat: add cache-ttl pruning mode 2026-01-21 19:46:24 +00:00
c415ccaed5 feat(sessions): add channelIdleMinutes config for per-channel session idle durations (#1353)
* feat(sessions): add channelIdleMinutes config for per-channel session idle durations

Add new `channelIdleMinutes` config option to allow different session idle
timeouts per channel. For example, Discord sessions can now be configured
to last 7 days (10080 minutes) while other channels use shorter defaults.

Config example:
  sessions:
    channelIdleMinutes:
      discord: 10080  # 7 days

The channel-specific idle is passed as idleMinutesOverride to the existing
resolveSessionResetPolicy, integrating cleanly with the new reset policy
architecture.

* fix

* feat: add per-channel session reset overrides (#1353) (thanks @cash-echo-bot)

---------

Co-authored-by: Cash Williams <cashwilliams@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-21 19:10:31 +00:00
Peter Steinberger 403904ecd1 fix: harden port listener detection 2026-01-21 18:52:55 +00:00
Peter Steinberger 32550154f9 feat(queue): add per-channel debounce overrides 2026-01-21 18:50:55 +00:00
Peter Steinberger 6996c0f330 test: cover history image injection 2026-01-21 18:45:23 +00:00
Peter Steinberger cf4f1ed03a fix: persist history image injections 2026-01-21 18:45:23 +00:00
Peter Steinberger c913f05fb5 docs(discord): mention wildcard channel defaults 2026-01-21 18:21:24 +00:00
Peter Steinberger 88d76d4be5 refactor(channels): centralize match metadata 2026-01-21 18:21:19 +00:00
Peter Steinberger b52ab96e2c docs(changelog): note discord wildcard fix 2026-01-21 17:56:01 +00:00
Peter Steinberger f0a8b34198 fix(discord): align wildcard channel matching 2026-01-21 17:56:01 +00:00
Wimmie 64d29b0c31 feat(discord): add wildcard channel config support
Add support for '*' wildcard in Discord channel configuration,
matching the existing guild-level wildcard behavior.

This allows applying default channel settings (like autoThread)
to all channels without listing each one explicitly:

  guilds:
    '*':
      channels:
        '*': { autoThread: true }

Specific channel configs still take precedence over the wildcard.
2026-01-21 17:56:01 +00:00
Peter Steinberger 9b47f463b7 chore: rename gateway daemon prompts 2026-01-21 17:46:30 +00:00
Peter Steinberger 9605ad76c5 fix: preserve fetch preconnect in abort wrapper 2026-01-21 17:45:58 +00:00
Peter Steinberger c129f0bbaa docs: align gateway service naming 2026-01-21 17:45:26 +00:00
Peter Steinberger 9e22f019db feat: fold gateway service commands into gateway 2026-01-21 17:45:26 +00:00
Shadow 6f58d508b8 chore: update carbon to v0.14.0 2026-01-21 11:36:56 -06:00
Peter Steinberger 84eadd92a1 Merge pull request #1384 from ameno-/fix/ssh-identity-agent
macOS: allow SSH agents without identity file
2026-01-21 17:32:44 +00:00
Peter Steinberger fd918bf6bf fix: allow SSH agent auth without identity file (#1384) (thanks @ameno-) 2026-01-21 17:32:00 +00:00
Peter Steinberger 4e1806947d fix: normalize abort signals for fetch 2026-01-21 17:29:46 +00:00
Peter Steinberger 8aca606a6f docs: clarify bluebubbles message ids 2026-01-21 17:20:03 +00:00
Ameno Osman 56799a21be macOS: allow SSH agents without identity file 2026-01-21 17:19:51 +00:00
Peter Steinberger d2a0e416ea test: align NO_REPLY typing expectations 2026-01-21 17:12:50 +00:00
Peter Steinberger 43afad9f51 fix: start instant typing at run start 2026-01-21 17:12:50 +00:00
Peter Steinberger 5d73a412c6 Merge pull request #1387 from clawdbot/temp/landpr-1369-followup
BlueBubbles: enforce short id resolution
2026-01-21 17:10:06 +00:00
Peter Steinberger d0e8faea97 docs: update changelog for bluebubbles follow-up (#1387) 2026-01-21 17:09:51 +00:00
Peter Steinberger cd25d69b4d fix: harden bluebubbles short ids and fetch wrapper (#1369) (thanks @tyler6204) 2026-01-21 17:09:15 +00:00
Peter Steinberger c3adc50cb2 Merge pull request #1369 from tyler6204/fix/bluebubbles-gc-guid-resolution
BlueBubbles: short ID mapping, action resolution, and threading/typing fixes
2026-01-21 17:06:09 +00:00
Peter Steinberger cbb9872478 docs: add FAQ entry for tool_use input error 2026-01-21 16:56:26 +00:00
Tyler Yust 9b9bbae501 feat: enhance message context with full ID support for replies and caching
- Updated message processing to include full message IDs alongside short IDs for better context resolution.
- Improved reply handling by caching inbound messages, allowing for accurate sender and body resolution without exposing dropped content.
- Adjusted tests to validate the new full ID properties and their integration into the message handling workflow.
2026-01-21 00:45:01 -08:00
Tyler Yust 7bfc32fe33 feat: enhance message handling with short ID resolution and reply context improvements
- Implemented resolution of short message IDs to full UUIDs in both text and media sending functions.
- Updated reply context formatting to optimize token usage by including only necessary information.
- Introduced truncation for long reply bodies to further reduce token consumption.
- Adjusted tests to reflect changes in reply context handling and message ID resolution.
2026-01-21 00:45:01 -08:00
Tyler Yust b073deee20 feat: implement short ID mapping for BlueBubbles messages and enhance reply context caching
- Added functionality to resolve short message IDs to full UUIDs and vice versa, optimizing token usage.
- Introduced a reply cache to store message context for replies when metadata is omitted in webhook payloads.
- Updated message handling to utilize short IDs for outbound messages and replies, improving efficiency.
- Enhanced error messages to clarify required parameters for actions like react, edit, and unsend.
- Added tests to ensure correct behavior of new features and maintain existing functionality.
2026-01-21 00:45:01 -08:00
194 changed files with 18787 additions and 19411 deletions

No files matched your search

+10 -2
View File
@@ -10,16 +10,24 @@ Docs: https://docs.clawd.bot
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
- 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: 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
- 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.
- 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.
- 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: seed model fallbacks from the allowlist selection when multiple models are chosen.
- 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.
- Infra: preserve fetch helper methods when wrapping abort signals. (#1369)
- Chat: include configured defaults/providers in `/models` output and normalize all-mode paging. (#1398) Thanks @vignesh07.
- 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
@@ -284,13 +284,16 @@ enum CommandResolver {
var args: [String] = [
"-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
args.append(contentsOf: ["-i", settings.identity])
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
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
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) {
Button("Restart app") { DebugActions.restartApp() }
Button("Restart onboarding") { DebugActions.restartOnboarding() }
@@ -15,10 +15,6 @@ struct GeneralSettings: View {
private let gatewayManager = GatewayProcessManager.shared
@State private var gatewayDiscovery = GatewayDiscoveryModel(
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 remoteStatus: RemoteStatus = .idle
@State private var showRemoteAdvanced = false
@@ -29,25 +25,6 @@ struct GeneralSettings: View {
var body: some View {
ScrollView(.vertical) {
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) {
SettingsToggleRow(
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.",
binding: self.$cameraEnabled)
SystemRunSettingsView()
VStack(alignment: .leading, spacing: 6) {
Text("Location Access")
.font(.body)
@@ -130,7 +105,6 @@ struct GeneralSettings: View {
}
.onAppear {
guard !self.isPreview else { return }
self.refreshCLIStatus()
self.refreshGatewayStatus()
self.lastLocationModeRaw = self.locationModeRaw
}
@@ -187,13 +161,14 @@ struct GeneralSettings: View {
.font(.title3.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
Picker("", selection: self.$state.connectionMode) {
Picker("Mode", selection: self.$state.connectionMode) {
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
}
.pickerStyle(.segmented)
.frame(width: 380, alignment: .leading)
.pickerStyle(.menu)
.labelsHidden()
.frame(width: 260, alignment: .leading)
if self.state.connectionMode == .unconfigured {
Text("Pick Local or Remote to start the Gateway.")
@@ -217,7 +192,6 @@ struct GeneralSettings: View {
self.remoteCard
}
self.cliInstaller
}
}
@@ -346,59 +320,6 @@ struct GeneralSettings: View {
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 {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
@@ -454,22 +375,6 @@ struct GeneralSettings: View {
.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() {
Task {
let status = await Task.detached(priority: .utility) {
@@ -763,9 +668,6 @@ extension GeneralSettings {
message: "Gateway ready")
view.remoteStatus = .failed("SSH failed")
view.showRemoteAdvanced = true
view.cliInstalled = true
view.cliInstallLocation = "/usr/local/bin/clawdbot"
view.cliStatus = "Installed"
_ = view.body
state.connectionMode = .unconfigured
@@ -2,14 +2,28 @@ import Foundation
import JavaScriptCore
enum ModelCatalogLoader {
static let defaultPath: String = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
static var defaultPath: String { self.resolveDefaultPath() }
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] {
let expanded = (path as NSString).expandingTildeInPath
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: expanded).lastPathComponent)")
let source = try String(contentsOfFile: expanded, encoding: .utf8)
guard let resolved = self.resolvePath(preferred: expanded) else {
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 ctx = JSContext()
@@ -45,9 +59,82 @@ enum ModelCatalogLoader {
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
}
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
if resolved.shouldCache {
self.cacheCatalog(sourcePath: resolved.path)
}
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 {
guard let exportRange = source.range(of: "export const MODELS"),
let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"),
@@ -8,6 +8,8 @@ struct PermissionsSettings: View {
var body: some View {
VStack(alignment: .leading, spacing: 14) {
SystemRunSettingsView()
Text("Allow these so Clawdbot can notify and capture when needed.")
.padding(.top, 4)
@@ -46,24 +48,6 @@ struct PermissionStatusList: View {
.padding(.top, 2)
.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] = [
"-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes",
"-o", "ExitOnForwardFailure=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
@@ -84,7 +83,12 @@ final class RemotePortTunnel {
]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
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
args.append(userHost)
+13
View File
@@ -149,6 +149,19 @@ Available actions:
- **leaveGroup**: Leave a group chat (`chatGuid`)
- **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
Control whether responses are sent as a single message or streamed in blocks:
```json5
+1
View File
@@ -175,6 +175,7 @@ Notes:
- `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`.
- 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.
- 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`.
+4
View File
@@ -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`)
- 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`):**
- 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.
-23
View File
@@ -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
View File
@@ -25,6 +25,12 @@ Run a local Gateway process:
clawdbot gateway
```
Foreground alias:
```bash
clawdbot gateway run
```
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.
- Binding beyond loopback without auth is blocked (safety guardrail).
@@ -34,7 +40,7 @@ Notes:
### Options
- `--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.
- `--token <token>`: token override (also sets `CLAWDBOT_GATEWAY_TOKEN` 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` 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
- 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.
```bash
clawdbot gateway status
clawdbot gateway status --json
clawdbot gateway probe
clawdbot gateway probe --json
```
#### 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:
```bash
clawdbot gateway status --ssh user@gateway-host
clawdbot gateway probe --ssh user@gateway-host
```
Options:
@@ -114,6 +137,20 @@ clawdbot gateway call status
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)
`gateway discover` scans for Gateway beacons (`_clawdbot-gw._tcp`).
+28 -27
View File
@@ -28,7 +28,6 @@ This page describes the current CLI behavior. If commands change, update this do
- [`health`](/cli/health)
- [`sessions`](/cli/sessions)
- [`gateway`](/cli/gateway)
- [`daemon`](/cli/daemon)
- [`logs`](/cli/logs)
- [`models`](/cli/models)
- [`memory`](/cli/memory)
@@ -137,14 +136,14 @@ clawdbot [--dev] [--profile <name>] <command>
call
health
status
probe
discover
daemon
status
install
uninstall
start
stop
restart
run
logs
models
list
@@ -175,14 +174,13 @@ clawdbot [--dev] [--profile <name>] <command>
nodes
devices
node
run
status
install
uninstall
start
daemon
status
install
uninstall
start
stop
restart
stop
restart
approvals
get
set
@@ -312,7 +310,7 @@ Options:
- `--minimax-api-key <key>`
- `--opencode-zen-api-key <key>`
- `--gateway-port <port>`
- `--gateway-bind <loopback|lan|tailnet|auto>`
- `--gateway-bind <loopback|lan|tailnet|auto|custom>`
- `--gateway-auth <off|token|password>`
- `--gateway-token <token>`
- `--gateway-password <password>`
@@ -598,7 +596,7 @@ Run the WebSocket Gateway.
Options:
- `--port <port>`
- `--bind <loopback|tailnet|lan|auto>`
- `--bind <loopback|tailnet|lan|auto|custom>`
- `--token <token>`
- `--auth <token|password>`
- `--password <password>`
@@ -615,25 +613,25 @@ Options:
- `--raw-stream`
- `--raw-stream-path <path>`
### `daemon`
### `gateway service`
Manage the Gateway service (launchd/systemd/schtasks).
Subcommands:
- `daemon status` (probes the Gateway RPC by default)
- `daemon install` (service install)
- `daemon uninstall`
- `daemon start`
- `daemon stop`
- `daemon restart`
- `gateway status` (probes the Gateway RPC by default)
- `gateway install` (service install)
- `gateway uninstall`
- `gateway start`
- `gateway stop`
- `gateway restart`
Notes:
- `daemon status` probes the Gateway RPC by default using the daemon’s resolved port/config (override with `--url/--token/--password`).
- `daemon 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".
- `daemon status` prints which config path the CLI uses vs which config the daemon 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).
- `daemon install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`.
- `gateway status` probes the Gateway RPC by default using the service’s resolved port/config (override with `--url/--token/--password`).
- `gateway status` supports `--no-probe`, `--deep`, and `--json` for scripting.
- `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".
- `gateway status` prints which config path the CLI uses vs which config the service likely uses (service env), plus the resolved probe target URL.
- `gateway install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
- `gateway install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).
- `gateway install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`.
### `logs`
Tail Gateway file logs via RPC.
@@ -652,13 +650,16 @@ clawdbot logs --no-color
```
### `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:
- `gateway call <method> [--params <json>]`
- `gateway health`
- `gateway status`
- `gateway probe`
- `gateway discover`
- `gateway install|uninstall|start|stop|restart`
- `gateway run`
Common RPCs:
- `config.apply` (validate + write config + restart + wake)
+15
View File
@@ -36,4 +36,19 @@ filter to nodes that connected within a duration (e.g. `24h`, `7d`).
```bash
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
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
View File
@@ -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:
- You want to update a source checkout safely
- You need to understand `--update` shorthand behavior
@@ -26,7 +26,7 @@ clawdbot --update
## 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).
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
- `--json`: print machine-readable `UpdateRunResult` JSON.
+24 -22
View File
@@ -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`).
## 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 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
- Only `toolResult` messages.
@@ -26,14 +40,10 @@ Pruning uses an estimated context window (chars ≈ tokens × 4). The window siz
3) `agents.defaults.contextTokens`.
4) Default `200000` tokens.
## Modes
### adaptive
- If estimated context ratio ≥ `softTrimRatio`: soft-trim oversized tool results.
- If still ≥ `hardClearRatio` **and** prunable tool text ≥ `minPrunableToolChars`: hard-clear oldest eligible tool results.
### aggressive
- Always hard-clears eligible tool results before the cutoff.
- Ignores `hardClear.enabled` (always clears when eligible).
## Mode
### cache-ttl
- Pruning only runs if the last Anthropic call is older than `ttl` (default `5m`).
- When it runs: same soft-trim + hard-clear behavior as before.
## Soft vs hard pruning
- **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).
## Defaults (when enabled)
- `ttl`: `"5m"`
- `keepLastAssistants`: `3`
- `softTrimRatio`: `0.3`
- `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]" }`
## Examples
Default (adaptive):
```json5
{
agent: {
contextPruning: { mode: "adaptive" }
}
}
```
To disable:
Default (off):
```json5
{
agent: {
@@ -78,11 +80,11 @@ To disable:
}
```
Aggressive:
Enable TTL-aware pruning:
```json5
{
agent: {
contextPruning: { mode: "aggressive" }
contextPruning: { mode: "cache-ttl", ttl: "5m" }
}
}
```
@@ -92,7 +94,7 @@ Restrict pruning to specific tools:
{
agent: {
contextPruning: {
mode: "adaptive",
mode: "cache-ttl",
tools: { allow: ["exec", "read"], deny: ["*image*"] }
}
}
+4
View File
@@ -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.
- 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-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.
- 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).
@@ -109,6 +110,9 @@ Send these as standalone messages so they register.
dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 }
},
resetByChannel: {
discord: { mode: "idle", idleMinutes: 10080 }
},
resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
mainKey: "main",
+1 -1
View File
@@ -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:
```bash
clawdbot daemon stop
clawdbot gateway stop
```
## Raw stream logging (Clawdbot)
-2
View File
@@ -829,8 +829,6 @@
"cli/nodes",
"cli/approvals",
"cli/gateway",
"cli/daemon",
"cli/service",
"cli/tui",
"cli/voicecall",
"cli/wake",
+3 -1
View File
@@ -151,7 +151,9 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
atHour: 4,
idleMinutes: 60
},
heartbeatIdleMinutes: 120,
resetByChannel: {
discord: { mode: "idle", idleMinutes: 10080 }
},
resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/default/sessions/sessions.json",
typingIntervalSeconds: 5,
+40 -31
View File
@@ -24,7 +24,7 @@ Unknown keys, malformed types, or invalid values cause the Gateway to **refuse t
When validation fails:
- 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 --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`).
- `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:
@@ -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.
It does **not** modify the session history on disk (`*.jsonl` remains complete).
@@ -1580,11 +1580,9 @@ High level:
- Never touches user/assistant messages.
- 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).
- Modes:
- `adaptive`: soft-trims oversized tool results (keep head/tail) when the estimated context ratio crosses `softTrimRatio`.
Then hard-clears the oldest eligible tool results when the estimated context ratio crosses `hardClearRatio` **and**
there’s enough prunable tool-result bulk (`minPrunableToolChars`).
- `aggressive`: always replaces eligible tool results before the cutoff with the `hardClear.placeholder` (no ratio checks).
- Mode:
- `cache-ttl`: pruning only runs when the last Anthropic call for the session is **older** than `ttl`.
When it runs, it uses the same soft-trim + hard-clear behavior as before.
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.
@@ -1598,44 +1596,41 @@ Notes / current limitations:
- Tool results containing **image blocks are skipped** (never trimmed/cleared) right now.
- 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.
- 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):
```json5
{
agents: { defaults: { contextPruning: { mode: "adaptive" } } }
}
```
To disable:
Default (off, unless Anthropic auth profiles are detected):
```json5
{
agents: { defaults: { contextPruning: { mode: "off" } } }
}
```
Defaults (when `mode` is `"adaptive"` or `"aggressive"`):
- `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):
Enable TTL-aware pruning:
```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
{
agents: {
defaults: {
contextPruning: {
mode: "adaptive",
mode: "cache-ttl",
ttl: "5m",
keepLastAssistants: 3,
softTrimRatio: 0.3,
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.
- `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`.
- `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`.
- `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.
@@ -2453,6 +2452,9 @@ Controls session scoping, reset policy, reset triggers, and where the session st
dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 }
},
resetByChannel: {
discord: { mode: "idle", idleMinutes: 10080 }
},
resetTriggers: ["/new", "/reset"],
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
// 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.
- `resetByType`: per-session overrides for `dm`, `group`, and `thread`.
- If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility.
- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled).
- `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).
- `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.
@@ -2645,6 +2647,13 @@ Defaults:
- bind: `loopback`
- 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
{
gateway: {
@@ -2675,7 +2684,7 @@ Notes:
- 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`.
- 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).
- `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored.
+3 -3
View File
@@ -225,10 +225,10 @@ Notes:
- `clawdbot doctor --yes` accepts the default repair prompts.
- `clawdbot doctor --repair` applies recommended fixes without prompts.
- `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
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
on the gateway port (default `18789`) and reports likely causes (gateway already
running, SSH tunnel).
@@ -236,7 +236,7 @@ running, SSH tunnel).
### 17) Gateway runtime best practices
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,
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
available (Homebrew/apt/choco).
+6 -2
View File
@@ -10,10 +10,11 @@ surface anything that needs attention without spamming you.
## 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).
3. Decide where heartbeat messages should go (`target: "last"` is the default).
4. Optional: enable heartbeat reasoning delivery for transparency.
5. Optional: restrict heartbeats to active hours (local time).
Example config:
@@ -24,6 +25,7 @@ Example config:
heartbeat: {
every: "30m",
target: "last",
// activeHours: { start: "08:00", end: "24:00" },
// includeReasoning: true, // optional: send separate `Reasoning:` message too
}
}
@@ -33,11 +35,13 @@ Example config:
## 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`):
`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
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
+27 -27
View File
@@ -1,9 +1,9 @@
---
summary: "Runbook for the Gateway daemon, lifecycle, and operations"
summary: "Runbook for the Gateway service, lifecycle, and operations"
read_when:
- Running or debugging the gateway process
---
# Gateway (daemon) runbook
# Gateway service runbook
Last updated: 2025-12-09
@@ -101,10 +101,10 @@ Checklist per instance:
- unique `agents.defaults.workspace`
- separate WhatsApp numbers (if using WA)
Daemon install per profile:
Service install per profile:
```bash
clawdbot --profile main daemon install
clawdbot --profile rescue daemon install
clawdbot --profile main gateway install
clawdbot --profile rescue gateway install
```
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.
## Supervision (macOS example)
- Use launchd to keep the daemon alive:
- Use launchd to keep the service alive:
- Program: path to `clawdbot`
- Arguments: `gateway`
- KeepAlive: true
- StandardOut/Err: file paths or `syslog`
- 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).
- `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`).
- `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
clawdbot daemon status
clawdbot daemon install
clawdbot daemon stop
clawdbot daemon restart
clawdbot gateway status
clawdbot gateway install
clawdbot gateway stop
clawdbot gateway restart
clawdbot logs --follow
```
Notes:
- `daemon status` probes the Gateway RPC by default using the daemon’s resolved port/config (override with `--url`).
- `daemon status --deep` adds system-level scans (LaunchDaemons/system units).
- `daemon status --no-probe` skips the RPC probe (useful when networking is down).
- `daemon status --json` is stable for scripts.
- `daemon 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.
- `daemon status` includes the last gateway error line when the service looks running but the port is closed.
- `gateway status` probes the Gateway RPC by default using the service’s resolved port/config (override with `--url`).
- `gateway status --deep` adds system-level scans (LaunchDaemons/system units).
- `gateway status --no-probe` skips the RPC probe (useful when networking is down).
- `gateway status --json` is stable for scripts.
- `gateway status` reports **supervisor runtime** (launchd/systemd running) separately from **RPC reachability** (WS connect + status RPC).
- `gateway status` prints config path + probe target to avoid “localhost vs LAN bind” confusion and profile mismatches.
- `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).
- 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).
- Cleanup: `clawdbot daemon 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).
- Cleanup: `clawdbot gateway uninstall` (current service) and `clawdbot doctor` (legacy migrations).
- `gateway install` is a no-op when already installed; use `clawdbot gateway install --force` to reinstall (profile/env/path changes).
Bundled mac app:
- Clawdbot.app can bundle a Node-based gateway relay and install a per-user LaunchAgent labeled
`com.clawdbot.gateway` (or `com.clawdbot.<profile>`).
- To stop it cleanly, use `clawdbot daemon stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`).
- To restart, use `clawdbot daemon restart` (or `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`).
- `launchctl` only works if the LaunchAgent is installed; otherwise use `clawdbot daemon install` first.
- To stop it cleanly, use `clawdbot gateway stop` (or `launchctl bootout 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 gateway install` first.
- Replace the label with `com.clawdbot.<profile>` when running a named profile.
## 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
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.
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 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 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.
## Migration guidance
+6 -6
View File
@@ -31,10 +31,10 @@ clawdbot --profile rescue setup
clawdbot --profile rescue gateway --port 19001
```
Per-profile daemons:
Per-profile services:
```bash
clawdbot --profile main daemon install
clawdbot --profile rescue daemon install
clawdbot --profile main gateway install
clawdbot --profile rescue gateway install
```
## 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)
# Runs on port 18789 + Chrome CDC/Canvas/... Ports
clawdbot onboard
clawdbot daemon install
clawdbot gateway install
# Rescue bot (isolated profile + ports)
clawdbot --profile rescue onboard
@@ -65,8 +65,8 @@ clawdbot --profile rescue onboard
# better choose completely different base port, like 19789,
# - rest of the onboarding is the same as normal
# To install the daemon (if not happened automatically during onboarding)
clawdbot --profile rescue daemon install
# To install the service (if not happened automatically during onboarding)
clawdbot --profile rescue gateway install
```
## Port mapping (derived)
+3 -3
View File
@@ -50,7 +50,7 @@ Guide: [Tailscale](/gateway/tailscale) and [Web overview](/web).
## 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):
- Telegram message arrives at the **Gateway**.
@@ -59,7 +59,7 @@ Flow example (Telegram → node):
- Node returns the result; Gateway replies back out to Telegram.
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.
## 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.
- **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.tlsFingerprint` pins the remote TLS cert when using `wss://`.
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
+1 -1
View File
@@ -237,7 +237,7 @@ The Gateway multiplexes **WebSocket + HTTP** on a single port:
Bind mode controls where the Gateway listens:
- `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:
- Prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback, and Tailscale handles access).
+21
View File
@@ -46,6 +46,25 @@ force `gateway.auth.mode: "password"`.
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)
```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.
- Set `gateway.tailscale.resetOnExit` if you want Clawdbot to undo `tailscale serve`
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
uses the separate bridge port (default `18790`) and is **not** proxied by Serve.
+33 -33
View File
@@ -17,12 +17,12 @@ Quick triage commands (in order):
| 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 --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 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 |
**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
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.
**Check:**
```bash
clawdbot daemon status
clawdbot gateway status
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:**
- Preferred: `clawdbot logs --follow`
@@ -71,12 +71,12 @@ See [/logging](/logging) for a full overview of formats, config, and access.
### 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`
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
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
gateway).
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**
- `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`.
- 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:**
- `gateway.mode` must be `local` for `clawdbot gateway` and the daemon.
- 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`).
- `clawdbot daemon 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:
- `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 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 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`/`custom`, or `auto` when loopback is unavailable) require auth:
`gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
- `gateway.remote.token` is for remote CLI calls only; it does **not** enable local auth.
- `gateway.token` is ignored; use `gateway.auth.token`.
**If `clawdbot daemon status` shows a config mismatch**
- `Config (cli): ...` and `Config (daemon): ...` should normally match.
- If they don’t, you’re almost certainly editing one config while the daemon is running another.
- Fix: rerun `clawdbot daemon install --force` from the same `--profile` / `CLAWDBOT_STATE_DIR` you want the daemon to use.
**If `clawdbot gateway status` shows a config mismatch**
- `Config (cli): ...` and `Config (service): ...` should normally match.
- If they don’t, you’re almost certainly editing one config while the service is running another.
- 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.
- 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”**
- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`auto`) but left auth off.
- Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the daemon.
- 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 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.
- 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:**
```bash
clawdbot daemon status
clawdbot gateway status
```
It will show the listener(s) and likely causes (gateway already running, SSH tunnel).
@@ -354,7 +354,7 @@ clawdbot doctor --fix
Notes:
- `clawdbot doctor` reports every invalid entry.
- `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?
@@ -407,7 +407,7 @@ git status # ensure you’re in the repo root
pnpm install
pnpm build
clawdbot doctor
clawdbot daemon restart
clawdbot gateway restart
```
Why: pnpm is the configured package manager for this repo.
@@ -432,7 +432,7 @@ Notes:
- After switching, run:
```bash
clawdbot doctor
clawdbot daemon restart
clawdbot gateway restart
```
### 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)**
If the gateway is supervised by launchd, killing the PID will just respawn it. Stop the supervisor first:
```bash
clawdbot daemon status
clawdbot daemon stop
clawdbot gateway status
clawdbot gateway stop
# Or: launchctl bootout gui/$UID/com.clawdbot.gateway (replace with com.clawdbot.<profile> if needed)
```
@@ -558,9 +558,9 @@ clawdbot channels login --verbose
```bash
# Supervisor + probe target + config paths
clawdbot daemon status
clawdbot gateway status
# Include system-level scans (legacy/extra services, port listeners)
clawdbot daemon status --deep
clawdbot gateway status --deep
# Is the gateway reachable?
clawdbot health --json
@@ -581,13 +581,13 @@ tail -20 /tmp/clawdbot/clawdbot-*.log
Nuclear option:
```bash
clawdbot daemon stop
clawdbot gateway stop
# If you installed a service and want a clean install:
# clawdbot daemon uninstall
# clawdbot gateway uninstall
trash "${CLAWDBOT_STATE_DIR:-$HOME/.clawdbot}"
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.
+3 -3
View File
@@ -14,7 +14,7 @@ Run these in order:
```bash
clawdbot status
clawdbot status --all
clawdbot daemon status
clawdbot gateway probe
clawdbot logs --follow
clawdbot doctor
```
@@ -38,10 +38,10 @@ Almost always a Node/npm PATH issue. Start here:
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Gateway authentication](/gateway/authentication)
### Daemon says running, but RPC probe fails
### Service says running, but RPC probe fails
- [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”)
+2 -2
View File
@@ -103,13 +103,13 @@ Runtime requirement: **Node ≥ 22**.
npm install -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
# Pair WhatsApp Web (shows QR)
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
```
+2 -2
View File
@@ -31,13 +31,13 @@ Manual steps (same result):
1) Stop the gateway service:
```bash
clawdbot daemon stop
clawdbot gateway stop
```
2) Uninstall the gateway service (launchd/systemd/schtasks):
```bash
clawdbot daemon uninstall
clawdbot gateway uninstall
```
3) Delete state + config:
+8 -8
View File
@@ -68,12 +68,12 @@ Then:
```bash
clawdbot doctor
clawdbot daemon restart
clawdbot gateway restart
clawdbot health
```
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.
## Update (`clawdbot update`)
@@ -148,9 +148,9 @@ Details: [Doctor](/gateway/doctor)
CLI (works regardless of OS):
```bash
clawdbot daemon status
clawdbot daemon stop
clawdbot daemon restart
clawdbot gateway status
clawdbot gateway stop
clawdbot gateway restart
clawdbot gateway --port 18789
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)
- Linux systemd user service: `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)
@@ -183,7 +183,7 @@ Then restart + re-run doctor:
```bash
clawdbot doctor
clawdbot daemon restart
clawdbot gateway restart
```
### Pin (source) by date
@@ -200,7 +200,7 @@ Then reinstall deps + restart:
```bash
pnpm install
pnpm build
clawdbot daemon restart
clawdbot gateway restart
```
If you want to go back to latest later:
+1 -1
View File
@@ -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).
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.
## Pairing + status
+4 -4
View File
@@ -97,7 +97,7 @@ It can set up:
- `~/.clawdbot/clawdbot.json` config
- model auth profiles
- 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)).
@@ -125,7 +125,7 @@ export CLAWDBOT_GATEWAY_TOKEN="$(openssl rand -hex 32)"
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
{
@@ -159,7 +159,7 @@ Notes:
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:
@@ -180,7 +180,7 @@ More: [Linux](/platforms/linux)
```bash
npm i -g clawdbot@latest
clawdbot doctor
clawdbot daemon restart
clawdbot gateway restart
clawdbot health
```
+3 -3
View File
@@ -31,15 +31,15 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
- Install guide: [Getting Started](/start/getting-started)
- Gateway runbook: [Gateway](/gateway)
- Gateway configuration: [Configuration](/gateway/configuration)
- Service status: `clawdbot daemon status`
- Service status: `clawdbot gateway status`
## Gateway service install (CLI)
Use one of these (all supported):
- Wizard (recommended): `clawdbot onboard --install-daemon`
- Direct: `clawdbot daemon install`
- Configure flow: `clawdbot configure` → select **Gateway daemon**
- Direct: `clawdbot gateway install`
- Configure flow: `clawdbot configure` → select **Gateway service**
- Repair/migrate: `clawdbot doctor` (offers to install or fix the service)
The service target depends on OS:
+2 -2
View File
@@ -41,7 +41,7 @@ clawdbot onboard --install-daemon
Or:
```
clawdbot daemon install
clawdbot gateway install
```
Or:
@@ -50,7 +50,7 @@ Or:
clawdbot configure
```
Select **Gateway daemon** when prompted.
Select **Gateway service** when prompted.
Repair/migrate:
+1 -1
View File
@@ -34,7 +34,7 @@ Plist location (per‑user):
Manager:
- 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:
- “Clawdbot Active” enables/disables the LaunchAgent.
+2 -2
View File
@@ -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:
```bash
clawdbot daemon status
clawdbot daemon stop
clawdbot gateway status
clawdbot gateway stop
# If you’re not using a LaunchAgent (dev mode / manual runs), find the listener:
lsof -nP -iTCP:18789 -sTCP:LISTEN
+2 -2
View File
@@ -24,7 +24,7 @@ capabilities to the agent as a node.
## Local vs remote mode
- **Local** (default): the app attaches to a running local Gateway if present;
otherwise it enables the launchd service via `clawdbot daemon`.
otherwise it enables the launchd service via `clawdbot gateway install`.
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
a local process.
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.
If the LaunchAgent isn’t installed, enable it from the app or run
`clawdbot daemon install`.
`clawdbot gateway install`.
## Node capabilities (mac)
+3 -3
View File
@@ -32,7 +32,7 @@ clawdbot onboard --install-daemon
Or:
```
clawdbot daemon install
clawdbot gateway install
```
Or:
@@ -41,7 +41,7 @@ Or:
clawdbot configure
```
Select **Gateway daemon** when prompted.
Select **Gateway service** when prompted.
Repair/migrate:
@@ -108,7 +108,7 @@ wsl --install -d Ubuntu-24.04
Reboot if Windows asks.
### 2) Enable systemd (required for daemon install)
### 2) Enable systemd (required for gateway install)
In your WSL terminal:
+2 -2
View File
@@ -36,10 +36,10 @@ clawdbot onboard --anthropic-api-key "$ANTHROPIC_API_KEY"
## 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.
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
{
+1 -1
View File
@@ -54,7 +54,7 @@ Allowed (diagnostic-only):
- `clawdbot health`
- `clawdbot help`
- `clawdbot status`
- `clawdbot daemon`
- `clawdbot gateway status`
Everything else must hard-fail with: “Config invalid. Run `clawdbot doctor --fix`.”
+39 -30
View File
@@ -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)
- [Remote gateways + nodes](#remote-gateways-nodes)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
- [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)
- [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 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)
- [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)
- [Sessions & multiple chats](#sessions-multiple-chats)
- [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)
- [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)
- [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)
- [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)
@@ -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)
- [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)
- [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 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` say `Runtime: running` but `RPC probe: failed`?](#why-does-clawdbot-gateway-status-say-runtime-running-but-rpc-probe-failed)
- [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)
- [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)
@@ -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)
- [Logging and debugging](#logging-and-debugging)
- [Where are logs?](#where-are-logs)
- [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon)
- [ELI5: `clawdbot daemon restart` vs `clawdbot gateway`](#eli5-clawdbot-daemon-restart-vs-clawdbot-gateway)
- [How do I start/stop/restart the Gateway service?](#how-do-i-startstoprestart-the-gateway-service)
- [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)
- [Media & attachments](#media-attachments)
- [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
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)**
```bash
@@ -138,9 +139,9 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
3) **Daemon + port state**
```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**
```bash
@@ -334,7 +335,7 @@ cd clawdbot
pnpm install
pnpm build
clawdbot doctor
clawdbot daemon restart
clawdbot gateway restart
```
From git → npm:
@@ -342,7 +343,7 @@ From git → npm:
```bash
npm install -g clawdbot@latest
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).
@@ -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).
### 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
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.
### “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:
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):
```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
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:
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).
### 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?
Heartbeats run every **30m** by default. Tune or disable them:
@@ -1336,24 +1345,24 @@ Precedence:
--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`.
Use `clawdbot daemon status` and trust these lines:
Use `clawdbot gateway status` and trust these lines:
- `Probe target:` (the URL the probe actually used)
- `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)
### 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:
```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?
@@ -1406,7 +1415,7 @@ Fix:
- Start Tailscale on that host (so it has a 100.x address), or
- 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?
@@ -1422,7 +1431,7 @@ Yes, but you must isolate:
Quick setup (recommended):
- 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).
- 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>)`).
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.
### 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
clawdbot daemon status
clawdbot daemon restart
clawdbot gateway status
clawdbot gateway restart
```
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.
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.
### What’s the fastest way to get more details when something fails?
+5 -5
View File
@@ -15,7 +15,7 @@ Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It set
- channels (WhatsApp/Telegram/Discord/…)
- pairing defaults (secure DMs)
- 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).
@@ -71,7 +71,7 @@ npm install -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
clawdbot onboard --install-daemon
@@ -89,7 +89,7 @@ Wizard doc: [Wizard](/start/wizard)
### 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`
- 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
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
clawdbot daemon status
clawdbot gateway status
```
Manual run (foreground):
+22
View File
@@ -60,6 +60,28 @@ Full setup walkthrough (28m) by VelvetShark.
[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
<CardGroup cols={2}>
+35
View File
@@ -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
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
- Use `/compact` to summarize long sessions.
+2 -2
View File
@@ -83,7 +83,7 @@ current limits and pricing.
`~/.clawdbot/clawdbot.json` under `tools.web.search.apiKey`.
**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).
## 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. 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:
+2 -12
View File
@@ -20,6 +20,7 @@ import {
resolveDefaultSlackAccountId,
resolveSlackAccount,
resolveSlackGroupRequireMention,
buildSlackThreadingToolContext,
setAccountEnabledInConfigSection,
slackOnboardingAdapter,
SlackConfigSchema,
@@ -164,18 +165,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
resolveReplyToMode: ({ cfg, accountId }) =>
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
allowTagsWhenOff: true,
buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => {
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,
};
},
buildToolContext: (params) => buildSlackThreadingToolContext(params),
},
messaging: {
normalizeTarget: normalizeSlackMessagingTarget,
+1 -4
View File
@@ -147,7 +147,7 @@
"packageManager": "pnpm@10.23.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.13.0",
"@buape/carbon": "0.0.0-beta-20260110172854",
"@buape/carbon": "0.14.0",
"@clack/prompts": "^0.11.0",
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
@@ -236,9 +236,6 @@
"@sinclair/typebox": "0.34.47",
"hono": "4.11.4",
"tar": "7.5.4"
},
"patchedDependencies": {
"@mariozechner/pi-ai@0.49.2": "patches/@mariozechner__pi-ai@0.49.2.patch"
}
},
"vitest": {
-135
View File
@@ -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);
+28 -60
View File
@@ -9,11 +9,6 @@ overrides:
hono: 4.11.4
tar: 7.5.4
patchedDependencies:
'@mariozechner/pi-ai@0.49.2':
hash: 4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e
path: patches/@mariozechner__pi-ai@0.49.2.patch
importers:
.:
@@ -22,8 +17,8 @@ importers:
specifier: 0.13.0
version: 0.13.0(zod@4.3.5)
'@buape/carbon':
specifier: 0.0.0-beta-20260110172854
version: 0.0.0-beta-20260110172854(hono@4.11.4)
specifier: 0.14.0
version: 0.14.0(hono@4.11.4)
'@clack/prompts':
specifier: ^0.11.0
version: 0.11.0
@@ -44,7 +39,7 @@ importers:
version: 0.49.2(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai':
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':
specifier: 0.49.2
version: 0.49.2(ws@8.19.0)(zod@4.3.5)
@@ -647,8 +642,8 @@ packages:
'@borewit/text-codec@0.2.1':
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
'@buape/carbon@0.0.0-beta-20260110172854':
resolution: {integrity: sha512-hYUr5Glz+oDEQbl58r6AExqM4yObZCjB8JwXit7chn0LIkb5WDFP4Rj1taP3OP+PgwB7GHOVSbnCnBPBzUxpRw==}
'@buape/carbon@0.14.0':
resolution: {integrity: sha512-mavllPK2iVpRNRtC4C8JOUdJ1hdV0+LDelFW+pjpJaM31MBLMfIJ+f/LlYTIK5QrEcQsXOC+6lU2e0gmgjWhIQ==}
'@cacheable/memory@2.0.7':
resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==}
@@ -666,8 +661,8 @@ packages:
'@clack/prompts@0.11.0':
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
'@cloudflare/workers-types@4.20251205.0':
resolution: {integrity: sha512-7pup7fYkuQW5XD8RUS/vkxF9SXlrGyCXuZ4ro3uVQvca/GTeSa+8bZ8T4wbq1Aea5lmLIGSlKbhl2msME7bRBA==}
'@cloudflare/workers-types@4.20260120.0':
resolution: {integrity: sha512-B8pueG+a5S+mdK3z8oKu1ShcxloZ7qWb68IEyLLaepvdryIbNC7JVPcY0bWsjS56UQVKc5fnyRge3yZIwc9bxw==}
'@discordjs/voice@0.19.0':
resolution: {integrity: sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==}
@@ -887,8 +882,8 @@ packages:
resolution: {integrity: sha512-qK6ZgGx0wwOubq/MY6eTbhApQHBUQCvCOsTYpQE01uLvfA2/Prm6egySHlZouKaina1RPuDwfLhCmsRCxwHj3Q==}
hasBin: true
'@hono/node-server@1.19.6':
resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==}
'@hono/node-server@1.19.9':
resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: 4.11.4
@@ -2425,8 +2420,8 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/bun@1.3.3':
resolution: {integrity: sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==}
'@types/bun@1.3.6':
resolution: {integrity: sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -2491,9 +2486,6 @@ packages:
'@types/node@20.19.30':
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
'@types/node@24.10.9':
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
'@types/node@25.0.9':
resolution: {integrity: sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==}
@@ -2878,8 +2870,8 @@ packages:
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bun-types@1.3.3:
resolution: {integrity: sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==}
bun-types@1.3.6:
resolution: {integrity: sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
@@ -3130,9 +3122,6 @@ packages:
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
engines: {node: '>=0.3.1'}
discord-api-types@0.38.36:
resolution: {integrity: sha512-qrbUbjjwtyeBg5HsAlm1C859epfOyiLjPqAOzkdWlCNsZCWJrertnETF/NwM8H+waMFU58xGSc5eXUfXah+WTQ==}
discord-api-types@0.38.37:
resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==}
@@ -5229,18 +5218,6 @@ packages:
wrappy@1.0.2:
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:
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
engines: {node: '>=10.0.0'}
@@ -5777,17 +5754,17 @@ snapshots:
'@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:
'@types/node': 24.10.9
discord-api-types: 0.38.36
'@types/node': 25.0.9
discord-api-types: 0.38.37
optionalDependencies:
'@cloudflare/workers-types': 4.20251205.0
'@cloudflare/workers-types': 4.20260120.0
'@discordjs/voice': 0.19.0
'@hono/node-server': 1.19.6(hono@4.11.4)
'@types/bun': 1.3.3
'@hono/node-server': 1.19.9(hono@4.11.4)
'@types/bun': 1.3.6
'@types/ws': 8.18.1
ws: 8.18.3
ws: 8.19.0
transitivePeerDependencies:
- '@discordjs/opus'
- bufferutil
@@ -5826,7 +5803,7 @@ snapshots:
picocolors: 1.1.1
sisteransi: 1.0.5
'@cloudflare/workers-types@4.20251205.0':
'@cloudflare/workers-types@4.20260120.0':
optional: true
'@discordjs/voice@0.19.0':
@@ -5992,7 +5969,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@hono/node-server@1.19.6(hono@4.11.4)':
'@hono/node-server@1.19.9(hono@4.11.4)':
dependencies:
hono: 4.11.4
optional: true
@@ -6283,7 +6260,7 @@ snapshots:
'@mariozechner/pi-agent-core@0.49.2(ws@8.19.0)(zod@4.3.5)':
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
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@@ -6294,7 +6271,7 @@ snapshots:
- ws
- 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:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
'@aws-sdk/client-bedrock-runtime': 3.971.0
@@ -6321,7 +6298,7 @@ snapshots:
'@mariozechner/clipboard': 0.3.0
'@mariozechner/jiti': 2.6.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
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2
@@ -7613,9 +7590,9 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 25.0.9
'@types/bun@1.3.3':
'@types/bun@1.3.6':
dependencies:
bun-types: 1.3.3
bun-types: 1.3.6
optional: true
'@types/chai@5.2.3':
@@ -7692,10 +7669,6 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@24.10.9':
dependencies:
undici-types: 7.16.0
'@types/node@25.0.9':
dependencies:
undici-types: 7.16.0
@@ -8151,7 +8124,7 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
bun-types@1.3.3:
bun-types@1.3.6:
dependencies:
'@types/node': 25.0.9
optional: true
@@ -8398,8 +8371,6 @@ snapshots:
diff@8.0.3: {}
discord-api-types@0.38.36: {}
discord-api-types@0.38.37: {}
docx-preview@0.3.7:
@@ -10787,9 +10758,6 @@ snapshots:
wrappy@1.0.2: {}
ws@8.18.3:
optional: true
ws@8.19.0: {}
y18n@5.0.8: {}
+9
View File
@@ -207,6 +207,15 @@ echo "📦 Copying device model resources"
rm -rf "$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"
CLAWDBOTKIT_BUNDLE="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG/ClawdbotKit_ClawdbotKit.bundle"
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" }]);
});
});
+1 -6
View File
@@ -30,12 +30,7 @@ export {
isTimeoutErrorMessage,
parseImageDimensionError,
} from "./pi-embedded-helpers/errors.js";
export {
downgradeGeminiHistory,
downgradeGeminiThinkingBlocks,
isGoogleModelApi,
sanitizeGoogleTurnOrdering,
} from "./pi-embedded-helpers/google.js";
export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js";
export {
isEmptyAssistantMessageContent,
sanitizeSessionMessagesImages,
-144
View File
@@ -1,5 +1,3 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { sanitizeGoogleTurnOrdering } from "./bootstrap.js";
export function isGoogleModelApi(api?: string | null): boolean {
@@ -14,145 +12,3 @@ export function isAntigravityClaude(api?: string | null, modelId?: string): bool
}
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";
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 input = [
{
@@ -25,10 +25,10 @@ describe("sanitizeSessionHistory (google thinking)", () => {
});
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?.[0]?.text).toBe("reasoning");
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
});
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");
});
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 input = [
{
@@ -80,10 +80,10 @@ describe("sanitizeSessionHistory (google thinking)", () => {
});
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?.[0]?.text).toBe("reasoning");
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
});
it("keeps unsigned thinking blocks for Antigravity Claude", async () => {
@@ -114,7 +114,7 @@ describe("sanitizeSessionHistory (google thinking)", () => {
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 input = [
{
@@ -139,10 +139,10 @@ describe("sanitizeSessionHistory (google thinking)", () => {
});
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?.[1]?.text).toBe("internal note");
expect(assistant.content?.map((block) => block.type)).toEqual(["text", "thinking", "text"]);
expect(assistant.content?.[1]?.thinking).toBe("internal note");
});
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 {
content?: Array<{ type?: string; thought_signature?: string; thoughtSignature?: string }>;
content?: Array<{
type?: string;
thought_signature?: string;
thoughtSignature?: string;
thinking?: string;
}>;
};
expect(assistant.content).toEqual([
{ 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",
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 input = [
{
@@ -224,14 +235,14 @@ describe("sanitizeSessionHistory (google thinking)", () => {
});
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?.[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 input = [
{
@@ -251,8 +262,10 @@ describe("sanitizeSessionHistory (google thinking)", () => {
sessionId: "session:google-empty",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant");
expect(assistant).toBeUndefined();
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{ type?: string; thinking?: string }>;
};
expect(assistant?.content?.map((block) => block.type)).toEqual(["thinking"]);
});
it("keeps thinking blocks for non-Google models", async () => {
@@ -13,7 +13,6 @@ vi.mock("./pi-embedded-helpers.js", async () => {
return {
...actual,
isGoogleModelApi: vi.fn(),
downgradeGeminiHistory: vi.fn(),
sanitizeSessionMessagesImages: vi.fn().mockImplementation(async (msgs) => msgs),
};
});
@@ -32,19 +31,14 @@ describe("sanitizeSessionHistory", () => {
beforeEach(async () => {
vi.resetAllMocks();
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();
({ 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);
const result = await sanitizeSessionHistory({
await sanitizeSessionHistory({
messages: mockMessages,
modelApi: "google-gemini",
provider: "google-vertex",
@@ -53,35 +47,17 @@ describe("sanitizeSessionHistory", () => {
});
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
expect(helpers.downgradeGeminiHistory).toHaveBeenCalled();
// Check if the result contains the downgraded message
expect(result).toContainEqual({ role: "system", content: "downgraded" });
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
mockMessages,
"session:history",
expect.objectContaining({ sanitizeToolCallIds: true }),
);
});
it("should NOT downgrade history for google-antigravity provider", 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 () => {
it("does not sanitize tool call ids for non-Google, non-OpenAI APIs", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
const _result = await sanitizeSessionHistory({
await sanitizeSessionHistory({
messages: mockMessages,
modelApi: "anthropic-messages",
provider: "anthropic",
@@ -90,25 +66,14 @@ describe("sanitizeSessionHistory", () => {
});
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 () => {
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 () => {
it("keeps reasoning-only assistant messages for openai-responses", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
const messages: AgentMessage[] = [
@@ -135,7 +100,7 @@ describe("sanitizeSessionHistory", () => {
});
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("openai-responses");
expect(result).toHaveLength(1);
expect(result[0]?.role).toBe("user");
expect(result).toHaveLength(2);
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
}
}
+4 -1
View File
@@ -11,6 +11,7 @@ import { setContextPruningRuntime } from "../pi-extensions/context-pruning/runti
import { computeEffectiveSettings } from "../pi-extensions/context-pruning/settings.js";
import { makeToolPrunablePredicate } from "../pi-extensions/context-pruning/tools.js";
import { ensurePiCompactionReserveTokens } from "../pi-settings.js";
import { isCacheTtlEligibleProvider, readLastCacheTtlTimestamp } from "./cache-ttl.js";
function resolvePiExtensionPath(id: string): string {
const self = fileURLToPath(import.meta.url);
@@ -43,7 +44,8 @@ function buildContextPruningExtension(params: {
model: Model<Api> | undefined;
}): { additionalExtensionPaths?: string[] } {
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);
if (!settings) return {};
@@ -52,6 +54,7 @@ function buildContextPruningExtension(params: {
settings,
contextWindowTokens: resolveContextWindowTokens(params),
isToolPrunable: makeToolPrunablePredicate(settings.tools),
lastCacheTouchAt: readLastCacheTtlTimestamp(params.sessionManager),
});
return {
+2 -57
View File
@@ -4,8 +4,6 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
import {
downgradeGeminiThinkingBlocks,
downgradeGeminiHistory,
isCompactionFailureError,
isGoogleModelApi,
sanitizeGoogleTurnOrdering,
@@ -52,42 +50,6 @@ function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
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[] {
if (!schema || typeof schema !== "object") return [];
if (Array.isArray(schema)) {
@@ -233,7 +195,6 @@ export async function sanitizeSessionHistory(params: {
const modelId = (params.modelId ?? "").toLowerCase();
const isOpenRouterGemini =
(provider === "openrouter" || provider === "opencode") && modelId.includes("gemini");
const isGeminiLike = isGoogleModelApi(params.modelApi) || isOpenRouterGemini;
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
enforceToolCallLast: params.modelApi === "anthropic-messages",
@@ -242,26 +203,10 @@ export async function sanitizeSessionHistory(params: {
? { allowBase64Only: true, includeCamelCase: true }
: undefined,
});
// TODO REMOVE when https://github.com/badlogic/pi-mono/pull/838 is merged.
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;
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
return applyGoogleTurnOrderingFix({
messages: downgraded,
messages: repairedTools,
modelApi: params.modelApi,
sessionManager: params.sessionManager,
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");
});
});
+63 -32
View File
@@ -49,6 +49,7 @@ import { resolveDefaultModelForAgent } from "../../model-selection.js";
import { isAbortError } from "../abort.js";
import { buildEmbeddedExtensionPaths } from "../extensions.js";
import { applyExtraParamsToAgent } from "../extra-params.js";
import { appendCacheTtlTimestamp, isCacheTtlEligibleProvider } from "../cache-ttl.js";
import {
logToolSchemasForGoogle,
sanitizeSessionHistory,
@@ -77,6 +78,50 @@ import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.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(
params: EmbeddedRunAttemptParams,
): Promise<EmbeddedRunAttemptResult> {
@@ -626,38 +671,13 @@ export async function runEmbeddedAttempt(
// Inject history images into their original message positions.
// This ensures the model sees images in context (e.g., "compare to the first image").
if (imageResult.historyImagesByIndex.size > 0) {
for (const [msgIndex, images] of imageResult.historyImagesByIndex) {
// Bounds check: ensure index is valid before accessing
if (msgIndex < 0 || msgIndex >= activeSession.messages.length) continue;
const msg = activeSession.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 }];
}
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);
}
}
}
}
}
const didMutate = injectHistoryImagesIntoMessages(
activeSession.messages,
imageResult.historyImagesByIndex,
);
if (didMutate) {
// Persist message mutations (e.g., injected history images) so we don't re-scan/reload.
activeSession.agent.replaceMessages(activeSession.messages);
}
cacheTrace?.recordStage("prompt:images", {
@@ -666,6 +686,17 @@ export async function runEmbeddedAttempt(
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
// This avoids potential issues with models that don't expect the images parameter
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 { describe, expect, it } from "vitest";
import { setContextPruningRuntime } from "./context-pruning/runtime.js";
import { getContextPruningRuntime, setContextPruningRuntime } from "./context-pruning/runtime.js";
import {
computeEffectiveSettings,
@@ -135,12 +135,15 @@ describe("context-pruning", () => {
});
it("never prunes tool results before the first user message", () => {
const settings = computeEffectiveSettings({
mode: "aggressive",
const settings = {
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
keepLastAssistants: 0,
hardClear: { placeholder: "[cleared]" },
});
if (!settings) throw new Error("expected settings");
softTrimRatio: 0.0,
hardClearRatio: 0.0,
minPrunableToolChars: 0,
hardClear: { enabled: true, placeholder: "[cleared]" },
softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
};
const messages: AgentMessage[] = [
makeAssistant("bootstrap tool calls"),
@@ -170,7 +173,7 @@ describe("context-pruning", () => {
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[] = [
makeUser("u1"),
makeAssistant("a1"),
@@ -195,9 +198,11 @@ describe("context-pruning", () => {
const settings = {
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
mode: "aggressive",
keepLastAssistants: 1,
hardClear: { enabled: false, placeholder: "[cleared]" },
softTrimRatio: 10.0,
hardClearRatio: 0.0,
minPrunableToolChars: 0,
hardClear: { enabled: true, placeholder: "[cleared]" },
};
const ctx = {
@@ -258,6 +263,7 @@ describe("context-pruning", () => {
},
contextWindowTokens: 1000,
isToolPrunable: () => true,
lastCacheTouchAt: Date.now() - DEFAULT_CONTEXT_PRUNING_SETTINGS.ttlMs - 1000,
});
const messages: AgentMessage[] = [
@@ -300,6 +306,72 @@ describe("context-pruning", () => {
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)", () => {
const messages: AgentMessage[] = [
makeUser("u1"),
@@ -9,6 +9,17 @@ export default function contextPruningExtension(api: ExtensionAPI): void {
const runtime = getContextPruningRuntime(ctx.sessionManager);
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({
messages: event.messages as AgentMessage[],
settings: runtime.settings,
@@ -18,6 +29,11 @@ export default function contextPruningExtension(api: ExtensionAPI): void {
});
if (next === event.messages) return undefined;
if (runtime.settings.mode === "cache-ttl") {
runtime.lastCacheTouchAt = Date.now();
}
return { messages: next };
});
}
@@ -211,34 +211,6 @@ export function pruneContextMessages(params: {
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);
let totalChars = totalCharsBefore;
let ratio = totalChars / charWindow;
@@ -4,6 +4,7 @@ export type ContextPruningRuntimeValue = {
settings: EffectiveContextPruningSettings;
contextWindowTokens?: number | null;
isToolPrunable: (toolName: string) => boolean;
lastCacheTouchAt?: number | null;
};
// Session-scoped runtime registry keyed by object identity.
@@ -1,12 +1,15 @@
import { parseDurationMs } from "../../../cli/parse-duration.js";
export type ContextPruningToolMatch = {
allow?: string[];
deny?: string[];
};
export type ContextPruningMode = "off" | "adaptive" | "aggressive";
export type ContextPruningMode = "off" | "cache-ttl";
export type ContextPruningConfig = {
mode?: ContextPruningMode;
/** TTL to consider cache expired (duration string, default unit: minutes). */
ttl?: string;
keepLastAssistants?: number;
softTrimRatio?: number;
hardClearRatio?: number;
@@ -25,6 +28,7 @@ export type ContextPruningConfig = {
export type EffectiveContextPruningSettings = {
mode: Exclude<ContextPruningMode, "off">;
ttlMs: number;
keepLastAssistants: number;
softTrimRatio: number;
hardClearRatio: number;
@@ -42,7 +46,8 @@ export type EffectiveContextPruningSettings = {
};
export const DEFAULT_CONTEXT_PRUNING_SETTINGS: EffectiveContextPruningSettings = {
mode: "adaptive",
mode: "cache-ttl",
ttlMs: 5 * 60 * 1000,
keepLastAssistants: 3,
softTrimRatio: 0.3,
hardClearRatio: 0.5,
@@ -62,11 +67,19 @@ export const DEFAULT_CONTEXT_PRUNING_SETTINGS: EffectiveContextPruningSettings =
export function computeEffectiveSettings(raw: unknown): EffectiveContextPruningSettings | null {
if (!raw || typeof raw !== "object") return null;
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);
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)) {
s.keepLastAssistants = Math.max(0, Math.floor(cfg.keepLastAssistants));
}
@@ -94,7 +107,7 @@ export function computeEffectiveSettings(raw: unknown): EffectiveContextPruningS
}
}
if (cfg.hardClear) {
if (s.mode === "adaptive" && typeof cfg.hardClear.enabled === "boolean") {
if (typeof cfg.hardClear.enabled === "boolean") {
s.hardClear.enabled = cfg.hardClear.enabled;
}
if (typeof cfg.hardClear.placeholder === "string" && cfg.hardClear.placeholder.trim()) {
+1 -1
View File
@@ -66,7 +66,7 @@ describe("buildAgentSystemPrompt", () => {
});
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");
});
+5 -5
View File
@@ -365,11 +365,11 @@ export function buildAgentSystemPrompt(params: {
"## Clawdbot CLI Quick Reference",
"Clawdbot is controlled via subcommands. Do not invent commands.",
"To manage the Gateway daemon service (start/stop/restart):",
"- clawdbot daemon status",
"- clawdbot daemon start",
"- clawdbot daemon stop",
"- clawdbot daemon restart",
"If unsure, ask the user to run `clawdbot help` (or `clawdbot daemon --help`) and paste the output.",
"- clawdbot gateway status",
"- clawdbot gateway start",
"- clawdbot gateway stop",
"- clawdbot gateway restart",
"If unsure, ask the user to run `clawdbot help` (or `clawdbot gateway --help`) and paste the output.",
"",
...skillsSection,
...memorySection,
+8 -1
View File
@@ -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({
key: "queue",
nativeName: "queue",
@@ -485,7 +493,6 @@ function buildChatCommands(): ChatCommandDefinition[] {
registerAlias(commands, "verbose", "/v");
registerAlias(commands, "reasoning", "/reason");
registerAlias(commands, "elevated", "/elev");
registerAlias(commands, "model", "/models");
assertCommandRegistry(commands);
return commands;
+1
View File
@@ -25,6 +25,7 @@ describe("commands registry", () => {
it("builds command text with args", () => {
expect(buildCommandText("status")).toBe("/status");
expect(buildCommandText("model", "gpt-5")).toBe("/model gpt-5");
expect(buildCommandText("models")).toBe("/models");
});
it("exposes native specs", () => {
+1 -2
View File
@@ -11,8 +11,7 @@ const resolveChannelOverride = (params: {
channel: string;
}): number | undefined => {
if (!params.byChannel) return undefined;
const channelKey = params.channel as keyof InboundDebounceByProvider;
return resolveMs(params.byChannel[channelKey]);
return resolveMs(params.byChannel[params.channel]);
};
export function resolveInboundDebounceMs(params: {
+6 -6
View File
@@ -10,11 +10,10 @@ describe("extractModelDirective", () => {
expect(result.cleaned).toBe("");
});
it("extracts /models with argument", () => {
it("does not extract /models with argument", () => {
const result = extractModelDirective("/models gpt-5");
expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBe("gpt-5");
expect(result.cleaned).toBe("");
expect(result.hasDirective).toBe(false);
expect(result.cleaned).toBe("/models gpt-5");
});
it("extracts /model with provider/model format", () => {
@@ -114,10 +113,11 @@ describe("extractModelDirective", () => {
});
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");
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", () => {
+1 -1
View File
@@ -14,7 +14,7 @@ export function extractModelDirective(
if (!body) return { cleaned: "", hasDirective: false };
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);
@@ -60,13 +60,13 @@ describe("directive behavior", () => {
vi.restoreAllMocks();
});
it("lists allowlisted models on /model list", async () => {
it("lists allowlisted models on /models <provider>", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -84,9 +84,29 @@ describe("directive behavior", () => {
);
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("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();
});
});
@@ -97,7 +117,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true },
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -115,9 +135,29 @@ describe("directive behavior", () => {
);
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("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();
});
});
@@ -132,7 +172,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{ Body: "/models xai", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -150,10 +190,29 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("minimax/MiniMax-M2.1");
expect(text).toContain("Models (xai)");
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();
});
});
@@ -173,7 +232,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -202,8 +261,7 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("Models (minimax)");
expect(text).toContain("minimax/MiniMax-M2.1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
@@ -231,6 +289,7 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model listing moved.");
expect(text).not.toContain("missing (missing)");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
@@ -62,13 +62,13 @@ describe("directive behavior", () => {
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) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
{ Body: "/model ki", From: "+1222", To: "+1222", CommandAuthorized: true },
{ Body: "/model Kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -65,7 +65,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
const res = await getReplyFromConfig(
{ Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
@@ -94,10 +94,10 @@ describe("directive behavior", () => {
},
);
assertModelSelection(storePath, {
model: "kimi-k2-0905-preview",
provider: "moonshot",
});
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Did you mean:");
expect(text).toContain("moonshot/kimi-k2-0905-preview");
assertModelSelection(storePath);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -106,7 +106,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
const res = await getReplyFromConfig(
{
Body: "/model kimi-k2-0905-preview",
From: "+1222",
@@ -140,10 +140,10 @@ describe("directive behavior", () => {
},
);
assertModelSelection(storePath, {
model: "kimi-k2-0905-preview",
provider: "moonshot",
});
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Did you mean:");
expect(text).toContain("moonshot/kimi-k2-0905-preview");
assertModelSelection(storePath);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -152,7 +152,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
const res = await getReplyFromConfig(
{ Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
@@ -181,10 +181,10 @@ describe("directive behavior", () => {
},
);
assertModelSelection(storePath, {
model: "kimi-k2-0905-preview",
provider: "moonshot",
});
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Did you mean:");
expect(text).toContain("moonshot/kimi-k2-0905-preview");
assertModelSelection(storePath);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -187,7 +187,7 @@ describe("directive behavior", () => {
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
});
it("lists allowlisted models on /model", async () => {
it("shows a model summary on /model", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
@@ -211,10 +211,9 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).not.toContain("claude-sonnet-4-1");
expect(text).toContain("Current: anthropic/claude-opus-4-5");
expect(text).toContain("Browse: /models");
expect(text).toContain("Switch: /model <provider/model>");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -95,7 +95,7 @@ afterEach(() => {
});
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) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
@@ -115,23 +115,18 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Pick: /model <#> or /model <provider/model>");
// Each provider/model combo is listed separately for clear selection
expect(normalized).toContain("anthropic/claude-opus-4-5");
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("Current: anthropic/claude-opus-4-5");
expect(normalized).toContain("Browse: /models");
expect(normalized).toContain("Switch: /model <provider/model>");
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) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
{
Body: "/model",
Body: "/models",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
@@ -146,54 +141,22 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
const anthropicIndex = normalized.indexOf("anthropic/claude-opus-4-5");
const openrouterIndex = normalized.indexOf("openrouter/anthropic/claude-opus-4-5");
const openaiIndex = normalized.indexOf("openai/gpt-4.1-mini");
const codexIndex = normalized.indexOf("openai-codex/gpt-5.2");
expect(anthropicIndex).toBeGreaterThanOrEqual(0);
expect(openrouterIndex).toBeGreaterThanOrEqual(0);
expect(openaiIndex).toBeGreaterThanOrEqual(0);
expect(codexIndex).toBeGreaterThanOrEqual(0);
expect(anthropicIndex).toBeLessThan(openrouterIndex);
expect(openaiIndex).toBeLessThan(codexIndex);
expect(normalized).toContain("Providers:");
expect(normalized).toContain("anthropic");
expect(normalized).toContain("openrouter");
expect(normalized).toContain("openai");
expect(normalized).toContain("openai-codex");
expect(normalized).toContain("minimax");
});
});
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) => {
const cfg = makeCfg(home);
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(
{
Body: `/model ${index}`,
Body: "/model openrouter/anthropic/claude-opus-4-5",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
@@ -238,7 +201,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
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);
@@ -246,15 +209,14 @@ describe("trigger handling", () => {
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) => {
const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111";
// /model 1 should select the first item (anthropic/claude-opus-4-5)
const res = await getReplyFromConfig(
{
Body: "/model 1",
Body: "/model anthropic/claude-opus-4-5",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
@@ -268,8 +230,9 @@ describe("trigger handling", () => {
);
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("anthropic/claude-opus-4-5");
expect(normalizeTestText(text ?? "")).toContain(
"Model reset to default (anthropic/claude-opus-4-5).",
);
const store = loadSessionStore(cfg.session.store);
// When selecting the default, overrides are cleared
@@ -277,14 +240,14 @@ describe("trigger handling", () => {
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) => {
const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111";
const res = await getReplyFromConfig(
{
Body: "/model 3",
Body: "/model openai/gpt-5.2",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
@@ -87,4 +87,21 @@ describe("buildThreadingToolContext", () => {
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");
});
});
+4 -9
View File
@@ -24,18 +24,11 @@ export function buildThreadingToolContext(params: {
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
if (!rawProvider) return {};
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)
const dock = provider ? getChannelDock(provider) : undefined;
if (!dock?.threading?.buildToolContext) {
return {
currentChannelId: threadingTo?.trim() || undefined,
currentChannelId: sessionCtx.To?.trim() || undefined,
currentChannelProvider: provider ?? (rawProvider as ChannelId),
hasRepliedRef,
};
@@ -46,7 +39,9 @@ export function buildThreadingToolContext(params: {
accountId: sessionCtx.AccountId,
context: {
Channel: sessionCtx.Provider,
To: threadingTo,
From: sessionCtx.From,
To: sessionCtx.To,
ChatType: sessionCtx.ChatType,
ReplyToId: sessionCtx.ReplyToId,
ThreadLabel: sessionCtx.ThreadLabel,
MessageThreadId: sessionCtx.MessageThreadId,
@@ -172,6 +172,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
const { run, typing } = createMinimalRun({
opts: { isHeartbeat: false, onPartialReply },
typingMode: "message",
});
await run();
+2
View File
@@ -196,6 +196,8 @@ export async function runReplyAgent(params: {
return undefined;
}
await typingSignals.signalRunStart();
activeSessionEntry = await runMemoryFlushIfNeeded({
cfg,
followupRun,
+2
View File
@@ -15,6 +15,7 @@ import {
} from "./commands-info.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleSubagentsCommand } from "./commands-subagents.js";
import { handleModelsCommand } from "./commands-models.js";
import {
handleAbortTrigger,
handleActivationCommand,
@@ -44,6 +45,7 @@ const HANDLERS: CommandHandler[] = [
handleSubagentsCommand,
handleConfigCommand,
handleDebugCommand,
handleModelsCommand,
handleStopCommand,
handleCompactCommand,
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");
});
});
+230
View File
@@ -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,
} from "./directive-handling.auth.js";
import {
buildModelPickerItems,
type ModelPickerCatalogEntry,
resolveProviderEndpointLabel,
} from "./directive-handling.model-picker.js";
@@ -169,8 +168,9 @@ export async function maybeHandleModelDirectiveInfo(params: {
const rawDirective = params.directives.rawModelDirective?.trim();
const directive = rawDirective?.toLowerCase();
const wantsStatus = directive === "status";
const wantsList = !rawDirective || directive === "list";
if (!wantsList && !wantsStatus) return undefined;
const wantsSummary = !rawDirective;
const wantsLegacyList = directive === "list";
if (!wantsSummary && !wantsStatus && !wantsLegacyList) return undefined;
if (params.directives.rawModelProfile) {
return { text: "Auth profile override requires a model selection." };
@@ -184,16 +184,28 @@ export async function maybeHandleModelDirectiveInfo(params: {
allowedModelCatalog: params.allowedModelCatalog,
});
if (wantsList) {
const items = buildModelPickerItems(pickerCatalog);
if (items.length === 0) return { text: "No models available." };
if (wantsLegacyList) {
return {
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 lines: string[] = [`Current: ${current}`, "Pick: /model <#> or /model <provider/model>"];
for (const [idx, item] of items.entries()) {
lines.push(`${idx + 1}) ${item.provider}/${item.model}`);
}
lines.push("", "More: /model status");
return { text: lines.join("\n") };
return {
text: [
`Current: ${current}`,
"",
"Switch: /model <provider/model>",
"Browse: /models (providers) or /models <provider> (models)",
"More: /model status",
].join("\n"),
};
}
const modelsPath = `${params.agentDir}/models.json`;
@@ -285,31 +297,36 @@ export function resolveModelSelectionFromDirective(params: {
let modelSelection: ModelDirectiveSelection | undefined;
if (/^[0-9]+$/.test(raw)) {
const pickerCatalog = buildModelPickerCatalog({
cfg: params.cfg,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
aliasIndex: params.aliasIndex,
allowedModelCatalog: params.allowedModelCatalog,
});
const items = buildModelPickerItems(pickerCatalog);
const index = Number.parseInt(raw, 10) - 1;
const item = Number.isFinite(index) ? items[index] : undefined;
if (!item) {
return {
errorText: `Invalid model selection "${raw}". Use /model to list.`,
return {
errorText: [
"Numeric model selection is not supported in chat.",
"",
"Browse: /models or /models <provider>",
"Switch: /model <provider/model>",
].join("\n"),
};
}
const explicit = resolveModelRefFromString({
raw,
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;
modelSelection = {
provider: item.provider,
model: item.model,
isDefault: item.provider === params.defaultProvider && item.model === params.defaultModel,
...(alias ? { alias } : {}),
};
} else {
}
if (!modelSelection) {
const resolved = resolveModelDirectiveSelection({
raw,
defaultProvider: params.defaultProvider,
@@ -317,10 +334,24 @@ export function resolveModelSelectionFromDirective(params: {
aliasIndex: params.aliasIndex,
allowedModelKeys: params.allowedModelKeys,
});
if (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;
+52 -14
View File
@@ -46,6 +46,38 @@ const FUZZY_VARIANT_TOKENS = [
"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: {
provider: string;
model: string;
@@ -94,6 +126,13 @@ function scoreFuzzyMatch(params: {
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) ?? [];
for (const alias of aliases) {
score += scoreFragment(alias.toLowerCase(), {
@@ -293,17 +332,16 @@ export function resolveModelDirectiveSelection(params: {
const fragment = params.fragment.trim().toLowerCase();
if (!fragment) return {};
const providerFilter = params.provider ? normalizeProviderId(params.provider) : undefined;
const candidates: Array<{ provider: string; model: string }> = [];
for (const key of allowedModelKeys) {
const slash = key.indexOf("/");
if (slash <= 0) continue;
const provider = normalizeProviderId(key.slice(0, slash));
const model = key.slice(slash + 1);
if (params.provider && provider !== normalizeProviderId(params.provider)) continue;
const haystack = `${provider}/${model}`.toLowerCase();
if (haystack.includes(fragment) || model.toLowerCase().includes(fragment)) {
candidates.push({ provider, model });
}
if (providerFilter && provider !== providerFilter) continue;
candidates.push({ provider, model });
}
// 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 {};
const scored = candidates
@@ -354,8 +387,13 @@ export function resolveModelDirectiveSelection(params: {
return a.key.localeCompare(b.key);
});
const best = scored[0]?.candidate;
if (!best) return {};
const bestScored = scored[0];
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) };
};
@@ -369,7 +407,7 @@ export function resolveModelDirectiveSelection(params: {
const fuzzy = resolveFuzzy({ fragment: rawTrimmed });
if (fuzzy.selection || fuzzy.error) return fuzzy;
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;
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.`,
};
}
+21
View File
@@ -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 { DEFAULT_QUEUE_CAP, DEFAULT_QUEUE_DEBOUNCE_MS, DEFAULT_QUEUE_DROP } from "./state.js";
import type { QueueMode, QueueSettings, ResolveQueueSettingsParams } from "./types.js";
@@ -6,6 +8,23 @@ function defaultQueueModeForChannel(_channel?: string): QueueMode {
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 {
const channelKey = params.channel?.trim().toLowerCase();
const queueCfg = params.cfg.messages?.queue;
@@ -22,6 +41,8 @@ export function resolveQueueSettings(params: ResolveQueueSettingsParams): QueueS
const debounceRaw =
params.inlineOptions?.debounceMs ??
params.sessionEntry?.queueDebounceMs ??
resolveChannelDebounce(queueCfg?.debounceMsByChannel, channelKey) ??
resolvePluginDebounce(channelKey) ??
queueCfg?.debounceMs ??
DEFAULT_QUEUE_DEBOUNCE_MS;
const capRaw =
+39
View File
@@ -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);
});
});
+15 -2
View File
@@ -11,6 +11,7 @@ import {
evaluateSessionFreshness,
type GroupKeyResolution,
loadSessionStore,
resolveChannelResetConfig,
resolveThreadFlag,
resolveSessionResetPolicy,
resolveSessionResetType,
@@ -106,6 +107,7 @@ export async function initSessionState(params: {
sessionKey: sessionCtxForState.SessionKey,
config: cfg,
});
const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined;
const resetTriggers = sessionCfg?.resetTriggers?.length
? sessionCfg.resetTriggers
: DEFAULT_RESET_TRIGGERS;
@@ -129,7 +131,6 @@ export async function initSessionState(params: {
let persistedModelOverride: string | undefined;
let persistedProviderOverride: string | undefined;
const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined;
const normalizedChatType = normalizeChatType(ctx.ChatType);
const isGroup =
normalizedChatType != null && normalizedChatType !== "direct" ? true : Boolean(groupResolution);
@@ -195,7 +196,19 @@ export async function initSessionState(params: {
parentSessionKey: ctx.ParentSessionKey,
});
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
? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh
: false;
+1 -1
View File
@@ -1 +1 @@
d9a36b111dfd93cbbb629fddf075800690ce0ae32a3a2ef201b365f4f6d6f5d5
27d5aed982d9f110b44e85254877597e49efae61141de480b4e9f254c04131ce
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -16,6 +16,8 @@ export type AllowlistMatch<TSource extends string = AllowlistMatchSource> = {
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"}`;
}
+29
View File
@@ -6,6 +6,8 @@ import {
resolveChannelEntryMatch,
resolveChannelEntryMatchWithFallback,
resolveNestedAllowlistDecision,
applyChannelMatchMeta,
resolveChannelMatchConfig,
} from "./channel-config.js";
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", () => {
it("allows when outer allowlist is disabled", () => {
expect(
+18
View File
@@ -11,6 +11,24 @@ export type ChannelEntryMatch<T> = {
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 {
return value
.trim()
+30 -27
View File
@@ -3,6 +3,7 @@ import { resolveDiscordAccount } from "../discord/accounts.js";
import { resolveIMessageAccount } from "../imessage/accounts.js";
import { resolveSignalAccount } from "../signal/accounts.js";
import { resolveSlackAccount } from "../slack/accounts.js";
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
import { resolveTelegramAccount } from "../telegram/accounts.js";
import { normalizeE164 } from "../utils.js";
import { resolveWhatsAppAccount } from "../web/accounts.js";
@@ -150,11 +151,14 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
buildToolContext: ({ context, hasRepliedRef }) => {
const channelId = context.From?.trim() || context.To?.trim() || undefined;
return {
currentChannelId: channelId,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
};
},
},
},
discord: {
@@ -221,18 +225,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
resolveReplyToMode: ({ cfg, accountId }) =>
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
allowTagsWhenOff: true,
buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => {
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,
};
},
buildToolContext: (params) => buildSlackThreadingToolContext(params),
},
},
signal: {
@@ -259,11 +252,16 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
.filter(Boolean),
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
buildToolContext: ({ context, hasRepliedRef }) => {
const isDirect = context.ChatType?.toLowerCase() === "direct";
const channelId =
(isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined;
return {
currentChannelId: channelId,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
};
},
},
},
imessage: {
@@ -286,11 +284,16 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
resolveRequireMention: resolveIMessageGroupRequireMention,
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
buildToolContext: ({ context, hasRepliedRef }) => {
const isDirect = context.ChatType?.toLowerCase() === "direct";
const channelId =
(isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined;
return {
currentChannelId: channelId,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
};
},
},
},
};
+2
View File
@@ -1,8 +1,10 @@
export type { ChannelEntryMatch, ChannelMatchSource } from "../channel-config.js";
export {
applyChannelMatchMeta,
buildChannelKeyCandidates,
normalizeChannelSlug,
resolveChannelEntryMatch,
resolveChannelEntryMatchWithFallback,
resolveChannelMatchConfig,
resolveNestedAllowlistDecision,
} 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