Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4917c2cfa | ||
|
|
205a5fd522 | ||
|
|
9ba7c8874b | ||
|
|
acd40e1780 | ||
|
|
b5fd66c92d | ||
|
|
45c1ccdfcf | ||
|
|
76600e80ba | ||
|
|
483a50f107 | ||
|
|
31943dcecb | ||
|
|
717fb9e413 | ||
|
|
ad7ef27f66 | ||
|
|
0d3b8f6ac3 | ||
|
|
6492e90c1b | ||
|
|
e4b3c8b98d | ||
|
|
8b8e078ef8 | ||
|
|
44a3539ffa | ||
|
|
0daaa5b592 | ||
|
|
6866cca6d7 | ||
|
|
c145a0d116 | ||
|
|
6c0a01dc90 | ||
|
|
41c9c214fc | ||
|
|
9f999f6554 | ||
|
|
9f59ff325b | ||
|
|
c415ccaed5 | ||
|
|
403904ecd1 | ||
|
|
32550154f9 | ||
|
|
6996c0f330 | ||
|
|
cf4f1ed03a | ||
|
|
c913f05fb5 | ||
|
|
88d76d4be5 | ||
|
|
b52ab96e2c | ||
|
|
f0a8b34198 | ||
|
|
64d29b0c31 | ||
|
|
9b47f463b7 | ||
|
|
9605ad76c5 | ||
|
|
c129f0bbaa | ||
|
|
9e22f019db | ||
|
|
6f58d508b8 | ||
|
|
84eadd92a1 | ||
|
|
fd918bf6bf | ||
|
|
4e1806947d | ||
|
|
8aca606a6f | ||
|
|
56799a21be | ||
|
|
d2a0e416ea | ||
|
|
43afad9f51 | ||
|
|
5d73a412c6 | ||
|
|
d0e8faea97 | ||
|
|
cd25d69b4d | ||
|
|
c3adc50cb2 | ||
|
|
cbb9872478 | ||
|
|
9b9bbae501 | ||
|
|
7bfc32fe33 | ||
|
|
b073deee20 |
No files matched your search
+10
-2
@@ -10,16 +10,24 @@ Docs: https://docs.clawd.bot
|
||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||
- 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot daemon` (install/uninstall/status for the Gateway service)"
|
||||
read_when:
|
||||
- You want to run the Gateway as a background service
|
||||
- You’re debugging daemon install, status, or logs
|
||||
---
|
||||
|
||||
# `clawdbot daemon`
|
||||
|
||||
Manage the Gateway daemon (background service).
|
||||
|
||||
Note: `clawdbot daemon …` is the preferred surface for Gateway service management; `daemon` remains
|
||||
as a legacy alias for compatibility.
|
||||
|
||||
Related:
|
||||
- Gateway CLI: [Gateway](/cli/gateway)
|
||||
- macOS platform notes: [macOS](/platforms/macos)
|
||||
|
||||
Tip: run `clawdbot daemon --help` for platform-specific flags.
|
||||
|
||||
Notes:
|
||||
- `daemon status` supports `--json` for scripting.
|
||||
- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
|
||||
+42
-5
@@ -25,6 +25,12 @@ Run a local Gateway process:
|
||||
clawdbot gateway
|
||||
```
|
||||
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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*"] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -829,8 +829,6 @@
|
||||
"cli/nodes",
|
||||
"cli/approvals",
|
||||
"cli/gateway",
|
||||
"cli/daemon",
|
||||
"cli/service",
|
||||
"cli/tui",
|
||||
"cli/voicecall",
|
||||
"cli/wake",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
@@ -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?
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
diff --git a/dist/providers/anthropic.js b/dist/providers/anthropic.js
|
||||
index 1cba2f1365812fd2f88993009c9cc06e9c348279..664dd6d8b400ec523fb735480741b9ad64f9a68c 100644
|
||||
--- a/dist/providers/anthropic.js
|
||||
+++ b/dist/providers/anthropic.js
|
||||
@@ -298,10 +298,11 @@ function createClient(model, apiKey, interleavedThinking) {
|
||||
});
|
||||
return { client, isOAuthToken: true };
|
||||
}
|
||||
+ const apiBetaFeatures = ["extended-cache-ttl-2025-04-11", ...betaFeatures];
|
||||
const defaultHeaders = {
|
||||
accept: "application/json",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
- "anthropic-beta": betaFeatures.join(","),
|
||||
+ "anthropic-beta": apiBetaFeatures.join(","),
|
||||
...(model.headers || {}),
|
||||
};
|
||||
const client = new Anthropic({
|
||||
@@ -313,9 +314,11 @@ function createClient(model, apiKey, interleavedThinking) {
|
||||
return { client, isOAuthToken: false };
|
||||
}
|
||||
function buildParams(model, context, isOAuthToken, options) {
|
||||
+ const cacheControlTtl = !isOAuthToken ? (options?.cacheControlTtl ?? "1h") : undefined;
|
||||
+ const cacheControl = cacheControlTtl ? { type: "ephemeral", ttl: cacheControlTtl } : { type: "ephemeral" };
|
||||
const params = {
|
||||
model: model.id,
|
||||
- messages: convertMessages(context.messages, model, isOAuthToken),
|
||||
+ messages: convertMessages(context.messages, model, isOAuthToken, cacheControl),
|
||||
max_tokens: options?.maxTokens || (model.maxTokens / 3) | 0,
|
||||
stream: true,
|
||||
};
|
||||
@@ -325,18 +328,14 @@ function buildParams(model, context, isOAuthToken, options) {
|
||||
{
|
||||
type: "text",
|
||||
text: "You are Claude Code, Anthropic's official CLI for Claude.",
|
||||
- cache_control: {
|
||||
- type: "ephemeral",
|
||||
- },
|
||||
+ cache_control: cacheControl,
|
||||
},
|
||||
];
|
||||
if (context.systemPrompt) {
|
||||
params.system.push({
|
||||
type: "text",
|
||||
text: sanitizeSurrogates(context.systemPrompt),
|
||||
- cache_control: {
|
||||
- type: "ephemeral",
|
||||
- },
|
||||
+ cache_control: cacheControl,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -346,9 +345,7 @@ function buildParams(model, context, isOAuthToken, options) {
|
||||
{
|
||||
type: "text",
|
||||
text: sanitizeSurrogates(context.systemPrompt),
|
||||
- cache_control: {
|
||||
- type: "ephemeral",
|
||||
- },
|
||||
+ cache_control: cacheControl,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -378,7 +375,7 @@ function buildParams(model, context, isOAuthToken, options) {
|
||||
function normalizeToolCallId(id) {
|
||||
return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
|
||||
}
|
||||
-function convertMessages(messages, model, isOAuthToken) {
|
||||
+function convertMessages(messages, model, isOAuthToken, cacheControl) {
|
||||
const params = [];
|
||||
// Transform messages for cross-provider compatibility
|
||||
const transformedMessages = transformMessages(messages, model, normalizeToolCallId);
|
||||
@@ -514,7 +511,7 @@ function convertMessages(messages, model, isOAuthToken) {
|
||||
const lastBlock = lastMessage.content[lastMessage.content.length - 1];
|
||||
if (lastBlock &&
|
||||
(lastBlock.type === "text" || lastBlock.type === "image" || lastBlock.type === "tool_result")) {
|
||||
- lastBlock.cache_control = { type: "ephemeral" };
|
||||
+ lastBlock.cache_control = cacheControl;
|
||||
}
|
||||
}
|
||||
}
|
||||
diff --git a/dist/providers/openai-completions.js b/dist/providers/openai-completions.js
|
||||
index ee5c88d8e280ceeff45ed075f2c7357d40005578..89daad7b0e53753e094028291226d32da9446440 100644
|
||||
--- a/dist/providers/openai-completions.js
|
||||
+++ b/dist/providers/openai-completions.js
|
||||
@@ -305,7 +305,7 @@ function createClient(model, context, apiKey) {
|
||||
function buildParams(model, context, options) {
|
||||
const compat = getCompat(model);
|
||||
const messages = convertMessages(model, context, compat);
|
||||
- maybeAddOpenRouterAnthropicCacheControl(model, messages);
|
||||
+ maybeAddOpenRouterAnthropicCacheControl(model, messages, options?.cacheControlTtl);
|
||||
const params = {
|
||||
model: model.id,
|
||||
messages,
|
||||
@@ -349,9 +349,10 @@ function buildParams(model, context, options) {
|
||||
}
|
||||
return params;
|
||||
}
|
||||
-function maybeAddOpenRouterAnthropicCacheControl(model, messages) {
|
||||
+function maybeAddOpenRouterAnthropicCacheControl(model, messages, cacheControlTtl) {
|
||||
if (model.provider !== "openrouter" || !model.id.startsWith("anthropic/"))
|
||||
return;
|
||||
+ const cacheControl = cacheControlTtl ? { type: "ephemeral", ttl: cacheControlTtl } : { type: "ephemeral" };
|
||||
// Anthropic-style caching requires cache_control on a text part. Add a breakpoint
|
||||
// on the last user/assistant message (walking backwards until we find text content).
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
@@ -361,7 +362,7 @@ function maybeAddOpenRouterAnthropicCacheControl(model, messages) {
|
||||
const content = msg.content;
|
||||
if (typeof content === "string") {
|
||||
msg.content = [
|
||||
- Object.assign({ type: "text", text: content }, { cache_control: { type: "ephemeral" } }),
|
||||
+ Object.assign({ type: "text", text: content }, { cache_control: cacheControl }),
|
||||
];
|
||||
return;
|
||||
}
|
||||
@@ -371,7 +372,7 @@ function maybeAddOpenRouterAnthropicCacheControl(model, messages) {
|
||||
for (let j = content.length - 1; j >= 0; j--) {
|
||||
const part = content[j];
|
||||
if (part?.type === "text") {
|
||||
- Object.assign(part, { cache_control: { type: "ephemeral" } });
|
||||
+ Object.assign(part, { cache_control: cacheControl });
|
||||
return;
|
||||
}
|
||||
}
|
||||
diff --git a/dist/stream.js b/dist/stream.js
|
||||
index d23fdd9f226a949fac4f2c7160af76f7f5fe71d1..3500f074bd88b85f4c7dd9bf42279f80fdf264d1 100644
|
||||
--- a/dist/stream.js
|
||||
+++ b/dist/stream.js
|
||||
@@ -146,6 +146,7 @@ function mapOptionsForApi(model, options, apiKey) {
|
||||
signal: options?.signal,
|
||||
apiKey: apiKey || options?.apiKey,
|
||||
sessionId: options?.sessionId,
|
||||
+ cacheControlTtl: options?.cacheControlTtl,
|
||||
};
|
||||
// Helper to clamp xhigh to high for providers that don't support it
|
||||
const clampReasoning = (effort) => (effort === "xhigh" ? "high" : effort);
|
||||
Generated
+28
-60
@@ -9,11 +9,6 @@ overrides:
|
||||
hono: 4.11.4
|
||||
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: {}
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+73
-14
@@ -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();
|
||||
});
|
||||
|
||||
+2
-2
@@ -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: {
|
||||
|
||||
+15
-15
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+4
-5
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+22
-59
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
+1
@@ -172,6 +172,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
opts: { isHeartbeat: false, onPartialReply },
|
||||
typingMode: "message",
|
||||
});
|
||||
await run();
|
||||
|
||||
|
||||
@@ -196,6 +196,8 @@ export async function runReplyAgent(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
await typingSignals.signalRunStart();
|
||||
|
||||
activeSessionEntry = await runMemoryFlushIfNeeded({
|
||||
cfg,
|
||||
followupRun,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -436,3 +436,42 @@ describe("initSessionState reset policy", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("initSessionState channel reset overrides", () => {
|
||||
it("uses channel-specific reset policy when configured", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-channel-idle-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:main:discord:dm:123";
|
||||
const sessionId = "session-override";
|
||||
const updatedAt = Date.now() - (10080 - 1) * 60_000;
|
||||
|
||||
await saveSessionStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
idleMinutes: 60,
|
||||
resetByType: { dm: { mode: "idle", idleMinutes: 10 } },
|
||||
resetByChannel: { discord: { mode: "idle", idleMinutes: 10080 } },
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "Hello",
|
||||
SessionKey: sessionKey,
|
||||
Provider: "discord",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.isNewSession).toBe(false);
|
||||
expect(result.sessionEntry.sessionId).toBe(sessionId);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
evaluateSessionFreshness,
|
||||
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 @@
|
||||
d9a36b111dfd93cbbb629fddf075800690ce0ae32a3a2ef201b365f4f6d6f5d5
|
||||
27d5aed982d9f110b44e85254877597e49efae61141de480b4e9f254c04131ce
|
||||
+15246
-17439
File diff suppressed because it is too large.
Load diff
@@ -16,6 +16,8 @@ export type AllowlistMatch<TSource extends string = AllowlistMatchSource> = {
|
||||
matchSource?: TSource;
|
||||
};
|
||||
|
||||
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"}`;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
Reference in new issue
Block a user