Compare commits

..
Author SHA1 Message Date
Peter Steinberger 862e89e683 fix: add per-channel markdown table conversion (#1495) (thanks @odysseus0) 2026-01-23 17:56:50 +00:00
George Zhang 03384e4b80 feat(telegram): convert markdown tables to bullet points
Tables render poorly in Telegram (pipes stripped, whitespace collapses).
This adds a 'tableMode' option to markdownToIR that converts tables to
nested bullet points, which render cleanly on mobile.

- Add tableMode: 'flat' | 'bullets' to MarkdownParseOptions
- Track table state during token rendering
- Render tables as bullet points with first column as row labels
- Apply bold styling to row labels for visual hierarchy
- Enable tableMode: 'bullets' for Telegram formatter

Closes #TBD
2026-01-23 12:00:26 +00:00
259 changed files with 3473 additions and 6301 deletions

No files matched your search

+6 -11
View File
@@ -23,14 +23,13 @@
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
## exe.dev VM ops (general)
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops.
- Update: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
- Config: use `clawdbot config set ...`; ensure `gateway.mode=local` is set.
- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix).
- Restart: stop old gateway and run:
- Access: SSH to the VM directly: `ssh vm-name.exe.xyz` (or use exe.dev web terminal).
- Updates: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
- Config: use `clawdbot config set ...`; set `gateway.mode=local` if unset.
- Restart: exe.dev often lacks systemd user bus; stop old gateway and run:
`pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &`
- Verify: `clawdbot channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/clawdbot-gateway.log`.
- Verify: `clawdbot --version`, `clawdbot health`, `ss -ltnp | rg 18789`.
- SSH flaky: use exe.dev web terminal or Shelley (web agent) instead of CLI SSH.
## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
@@ -129,10 +128,6 @@
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- Lint/format churn:
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
- Only ask when changes are semantic (logic/data/behavior).
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
-12
View File
@@ -6,23 +6,11 @@ Docs: https://docs.clawd.bot
### Changes
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
### Fixes
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
- TUI: include Gateway slash commands in autocomplete and `/help`.
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
- CLI: suppress diagnostic session/run noise during auth probes.
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
## 2026.1.22
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 202601230
versionName = "2026.1.23"
versionCode = 202601210
versionName = "2026.1.21"
}
buildTypes {
@@ -8,14 +8,10 @@ object WakeWords {
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
}
fun parseIfChanged(input: String, current: List<String>): List<String>? {
val parsed = parseCommaSeparated(input)
return if (parsed == current) null else parsed
}
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
val cleaned =
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
return cleaned.ifEmpty { defaults }
}
}
@@ -28,8 +28,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
@@ -51,10 +49,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -63,7 +58,6 @@ import com.clawdbot.android.LocationMode
import com.clawdbot.android.MainViewModel
import com.clawdbot.android.NodeForegroundService
import com.clawdbot.android.VoiceWakeMode
import com.clawdbot.android.WakeWords
@Composable
fun SettingsSheet(viewModel: MainViewModel) {
@@ -92,8 +86,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
val listState = rememberLazyListState()
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
var wakeWordsHadFocus by remember { mutableStateOf(false) }
val deviceModel =
remember {
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
@@ -112,12 +104,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
val commitWakeWords = {
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
if (parsed != null) {
viewModel.setWakeWords(parsed)
}
}
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
@@ -495,27 +481,25 @@ fun SettingsSheet(viewModel: MainViewModel) {
value = wakeWordsText,
onValueChange = setWakeWordsText,
label = { Text("Wake Words (comma-separated)") },
modifier =
Modifier.fillMaxWidth().onFocusChanged { focusState ->
if (focusState.isFocused) {
wakeWordsHadFocus = true
} else if (wakeWordsHadFocus) {
wakeWordsHadFocus = false
commitWakeWords()
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
commitWakeWords()
focusManager.clearFocus()
},
),
)
}
item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } }
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
viewModel.setWakeWords(parsed)
},
enabled = isConnected,
) {
Text("Save + Sync")
}
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
}
}
item {
Text(
if (isConnected) {
@@ -1,7 +1,6 @@
package com.clawdbot.android
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class WakeWordsTest {
@@ -33,18 +32,5 @@ class WakeWordsTest {
assertEquals("w1", sanitized.first())
assertEquals("w${WakeWords.maxWords}", sanitized.last())
}
@Test
fun parseIfChangedSkipsWhenUnchanged() {
val current = listOf("clawd", "claude")
val parsed = WakeWords.parseIfChanged(" clawd , claude ", current)
assertNull(parsed)
}
@Test
fun parseIfChangedReturnsUpdatedList() {
val current = listOf("clawd")
val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current)
assertEquals(listOf("clawd", "jarvis"), parsed)
}
}
+2 -2
View File
@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.23</string>
<string>2026.1.21</string>
<key>CFBundleVersion</key>
<string>20260123</string>
<string>20260121</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
@@ -1,10 +1,8 @@
import SwiftUI
import Combine
struct VoiceWakeWordsSettingsView: View {
@Environment(NodeAppModel.self) private var appModel
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
@FocusState private var focusedTriggerIndex: Int?
@State private var syncTask: Task<Void, Never>?
var body: some View {
@@ -14,10 +12,6 @@ struct VoiceWakeWordsSettingsView: View {
TextField("Wake word", text: self.binding(for: index))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused(self.$focusedTriggerIndex, equals: index)
.onSubmit {
self.commitTriggerWords()
}
}
.onDelete(perform: self.removeWords)
@@ -45,18 +39,17 @@ struct VoiceWakeWordsSettingsView: View {
.onAppear {
if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
self.commitTriggerWords()
}
}
.onChange(of: self.focusedTriggerIndex) { oldValue, newValue in
guard oldValue != nil, oldValue != newValue else { return }
self.commitTriggerWords()
}
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
guard self.focusedTriggerIndex == nil else { return }
let updated = VoiceWakePreferences.loadTriggerWords()
if updated != self.triggerWords {
self.triggerWords = updated
.onChange(of: self.triggerWords) { _, newValue in
// Keep local voice wake responsive even if the gateway isn't connected yet.
VoiceWakePreferences.saveTriggerWords(newValue)
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)
self.syncTask?.cancel()
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
try? await Task.sleep(nanoseconds: 650_000_000)
await appModel?.setGlobalWakeWords(snapshot)
}
}
}
@@ -70,7 +63,6 @@ struct VoiceWakeWordsSettingsView: View {
if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
}
self.commitTriggerWords()
}
private func binding(for index: Int) -> Binding<String> {
@@ -84,15 +76,4 @@ struct VoiceWakeWordsSettingsView: View {
self.triggerWords[index] = newValue
})
}
private func commitTriggerWords() {
VoiceWakePreferences.saveTriggerWords(self.triggerWords)
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords)
self.syncTask?.cancel()
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
try? await Task.sleep(nanoseconds: 650_000_000)
await appModel?.setGlobalWakeWords(snapshot)
}
}
}
@@ -6,8 +6,6 @@ enum VoiceWakePreferences {
// Keep defaults aligned with the mac app.
static let defaultTriggerWords: [String] = ["clawd", "claude"]
static let maxWords = 32
static let maxWordLength = 64
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
guard let data = payloadJSON.data(using: .utf8) else { return nil }
@@ -32,8 +30,6 @@ enum VoiceWakePreferences {
let cleaned = words
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.prefix(Self.maxWords)
.map { String($0.prefix(Self.maxWordLength)) }
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
}
+2 -2
View File
@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.23</string>
<string>2026.1.21</string>
<key>CFBundleVersion</key>
<string>20260123</string>
<string>20260121</string>
</dict>
</plist>
@@ -11,18 +11,6 @@ import Testing
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
}
@Test func sanitizeTriggerWordsLimitsWordLength() {
let long = String(repeating: "x", count: VoiceWakePreferences.maxWordLength + 5)
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(["ok", long])
#expect(cleaned[1].count == VoiceWakePreferences.maxWordLength)
}
@Test func sanitizeTriggerWordsLimitsWordCount() {
let words = (1...VoiceWakePreferences.maxWords + 3).map { "w\($0)" }
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(words)
#expect(cleaned.count == VoiceWakePreferences.maxWords)
}
@Test func displayStringUsesSanitizedWords() {
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
}
+4 -4
View File
@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.23"
CFBundleVersion: "20260123"
CFBundleShortVersionString: "2026.1.21"
CFBundleVersion: "20260121"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.23"
CFBundleVersion: "20260123"
CFBundleShortVersionString: "2026.1.21"
CFBundleVersion: "20260121"
@@ -12,8 +12,6 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime"
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
let showDockIconKey = "clawdbot.showDockIcon"
let defaultVoiceWakeTriggers = ["clawd", "claude"]
let voiceWakeMaxWords = 32
let voiceWakeMaxWordLength = 64
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
@@ -84,52 +84,11 @@ enum ExecApprovalDecision: String, Codable, Sendable {
case deny
}
struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
var id: UUID
struct ExecAllowlistEntry: Codable, Hashable {
var pattern: String
var lastUsedAt: Double?
var lastUsedCommand: String?
var lastResolvedPath: String?
init(
id: UUID = UUID(),
pattern: String,
lastUsedAt: Double? = nil,
lastUsedCommand: String? = nil,
lastResolvedPath: String? = nil)
{
self.id = id
self.pattern = pattern
self.lastUsedAt = lastUsedAt
self.lastUsedCommand = lastUsedCommand
self.lastResolvedPath = lastResolvedPath
}
private enum CodingKeys: String, CodingKey {
case id
case pattern
case lastUsedAt
case lastUsedCommand
case lastResolvedPath
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
self.pattern = try container.decode(String.self, forKey: .pattern)
self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt)
self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand)
self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.pattern, forKey: .pattern)
try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt)
try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand)
try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath)
}
}
struct ExecApprovalsDefaults: Codable {
@@ -336,7 +295,6 @@ enum ExecApprovalsStore {
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
.map { entry in
ExecAllowlistEntry(
id: entry.id,
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
@@ -421,7 +379,6 @@ enum ExecApprovalsStore {
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
guard item.pattern == pattern else { return item }
return ExecAllowlistEntry(
id: item.id,
pattern: item.pattern,
lastUsedAt: Date().timeIntervalSince1970 * 1000,
lastUsedCommand: command,
@@ -441,7 +398,6 @@ enum ExecApprovalsStore {
let cleaned = allowlist
.map { item in
ExecAllowlistEntry(
id: item.id,
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: item.lastUsedAt,
lastUsedCommand: item.lastUsedCommand,
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.23</string>
<string>2026.1.21</string>
<key>CFBundleVersion</key>
<string>202601230</string>
<string>202601210</string>
<key>CFBundleIconFile</key>
<string>Clawdbot</string>
<key>CFBundleURLTypes</key>
@@ -123,12 +123,12 @@ struct SystemRunSettingsView: View {
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(self.model.entries, id: \.id) { entry in
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
ExecAllowlistRow(
entry: Binding(
get: { self.model.entry(for: entry.id) ?? entry },
set: { self.model.updateEntry($0, id: entry.id) }),
onRemove: { self.model.removeEntry(id: entry.id) })
get: { self.model.entries[index] },
set: { self.model.updateEntry($0, at: index) }),
onRemove: { self.model.removeEntry(at: index) })
}
}
}
@@ -373,24 +373,20 @@ final class ExecApprovalsSettingsModel {
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) {
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
guard !self.isDefaultsScope else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
guard self.entries.indices.contains(index) else { return }
self.entries[index] = entry
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func removeEntry(id: UUID) {
func removeEntry(at index: Int) {
guard !self.isDefaultsScope else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
guard self.entries.indices.contains(index) else { return }
self.entries.remove(at: index)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func entry(for id: UUID) -> ExecAllowlistEntry? {
self.entries.first(where: { $0.id == id })
}
func refreshSkillBins(force: Bool = false) async {
guard self.autoAllowSkills else {
self.skillBins = []
@@ -4,8 +4,6 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] {
let cleaned = words
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.prefix(voiceWakeMaxWords)
.map { String($0.prefix(voiceWakeMaxWordLength)) }
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
}
@@ -21,7 +21,6 @@ struct VoiceWakeSettings: View {
@State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>?
@State private var availableLocales: [Locale] = []
@State private var triggerEntries: [TriggerEntry] = []
private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240
private let isPreview = ProcessInfo.processInfo.isPreview
@@ -32,9 +31,9 @@ struct VoiceWakeSettings: View {
var id: String { self.uid }
}
private struct TriggerEntry: Identifiable {
let id: UUID
var value: String
private struct IndexedWord: Identifiable {
let id: Int
let value: String
}
private var voiceWakeBinding: Binding<Bool> {
@@ -106,7 +105,6 @@ struct VoiceWakeSettings: View {
.onAppear {
guard !self.isPreview else { return }
self.startMicObserver()
self.loadTriggerEntries()
}
.onChange(of: self.state.voiceWakeMicID) { _, _ in
guard !self.isPreview else { return }
@@ -124,10 +122,8 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
Task { await self.meter.stop() }
self.micObserver.stop()
self.syncTriggerEntriesToState()
} else {
self.startMicObserver()
self.loadTriggerEntries()
}
}
.onDisappear {
@@ -140,16 +136,11 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
self.micObserver.stop()
Task { await self.meter.stop() }
self.syncTriggerEntriesToState()
}
}
private func loadTriggerEntries() {
self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
}
private func syncTriggerEntriesToState() {
self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
private var indexedWords: [IndexedWord] {
self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) }
}
private var triggerTable: some View {
@@ -163,42 +154,29 @@ struct VoiceWakeSettings: View {
} label: {
Label("Add word", systemImage: "plus")
}
.disabled(self.triggerEntries
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
.disabled(self.state.swabbleTriggerWords
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") {
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
self.syncTriggerEntriesToState()
}
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
}
VStack(spacing: 0) {
ForEach(self.$triggerEntries) { $entry in
HStack(spacing: 8) {
TextField("Wake word", text: $entry.value)
.textFieldStyle(.roundedBorder)
.onSubmit {
self.syncTriggerEntriesToState()
}
Button {
self.removeWord(id: entry.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
.frame(width: 24)
}
.padding(8)
if entry.id != self.triggerEntries.last?.id {
Divider()
}
Table(self.indexedWords) {
TableColumn("Word") { row in
TextField("Wake word", text: self.binding(for: row.id))
.textFieldStyle(.roundedBorder)
}
TableColumn("") { row in
Button {
self.removeWord(at: row.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
}
.width(36)
}
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
.background(Color(nsColor: .textBackgroundColor))
.frame(minHeight: 180)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
@@ -233,12 +211,24 @@ struct VoiceWakeSettings: View {
}
private func addWord() {
self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
self.state.swabbleTriggerWords.append("")
}
private func removeWord(id: UUID) {
self.triggerEntries.removeAll { $0.id == id }
self.syncTriggerEntriesToState()
private func removeWord(at index: Int) {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.state.swabbleTriggerWords.remove(at: index)
}
private func binding(for index: Int) -> Binding<String> {
Binding(
get: {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" }
return self.state.swabbleTriggerWords[index]
},
set: { newValue in
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.state.swabbleTriggerWords[index] = newValue
})
}
private func toggleTest() {
@@ -648,14 +638,13 @@ extension VoiceWakeSettings {
state.voicePushToTalkEnabled = true
state.swabbleTriggerWords = ["Claude", "Hey"]
var view = VoiceWakeSettings(state: state, isActive: true)
let view = VoiceWakeSettings(state: state, isActive: true)
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
view.availableLocales = [Locale(identifier: "en_US")]
view.meterLevel = 0.42
view.meterError = "No input"
view.testState = .detected("ok")
view.isTesting = true
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
_ = view.body
_ = view.localePicker
@@ -665,9 +654,8 @@ extension VoiceWakeSettings {
_ = view.chimeSection
view.addWord()
if let entryId = view.triggerEntries.first?.id {
view.removeWord(id: entryId)
}
_ = view.binding(for: 0).wrappedValue
view.removeWord(at: 0)
}
}
#endif
@@ -12,18 +12,6 @@ struct VoiceWakeHelpersTests {
#expect(cleaned == defaultVoiceWakeTriggers)
}
@Test func sanitizeTriggersLimitsWordLength() {
let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5)
let cleaned = sanitizeVoiceWakeTriggers(["ok", long])
#expect(cleaned[1].count == voiceWakeMaxWordLength)
}
@Test func sanitizeTriggersLimitsWordCount() {
let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" }
let cleaned = sanitizeVoiceWakeTriggers(words)
#expect(cleaned.count == voiceWakeMaxWords)
}
@Test func normalizeLocaleStripsCollation() {
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
}
-7
View File
@@ -700,15 +700,8 @@ Options:
- `--json`
- `--plain`
- `--check` (exit 1=expired/missing, 2=expiring)
- `--probe` (live probe of configured auth profiles)
- `--probe-provider <name>`
- `--probe-profile <id>` (repeat or comma-separated)
- `--probe-timeout <ms>`
- `--probe-concurrency <n>`
- `--probe-max-tokens <n>`
Always includes the auth overview and OAuth expiry status for profiles in the auth store.
`--probe` runs live requests (may consume tokens and trigger rate limits).
### `models set <model>`
Set `agents.defaults.model.primary`.
-14
View File
@@ -25,26 +25,12 @@ clawdbot models scan
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
When provider usage snapshots are available, the OAuth/token status section includes
provider usage headers.
Add `--probe` to run live auth probes against each configured provider profile.
Probes are real requests (may consume tokens and trigger rate limits).
Notes:
- `models set <model-or-alias>` accepts `provider/model` or an alias.
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
### `models status`
Options:
- `--json`
- `--plain`
- `--check` (exit 1=expired/missing, 2=expiring)
- `--probe` (live probe of configured auth profiles)
- `--probe-provider <name>` (probe one provider)
- `--probe-profile <id>` (repeat or comma-separated profile ids)
- `--probe-timeout <ms>`
- `--probe-concurrency <n>`
- `--probe-max-tokens <n>`
## Aliases + fallbacks
```bash
-8
View File
@@ -43,14 +43,6 @@ Almost always a Node/npm PATH issue. Start here:
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Control UI](/web/control-ui#insecure-http)
### `docs.clawd.bot` shows an SSL error (Comcast/Xfinity)
Some Comcast/Xfinity connections block `docs.clawd.bot` via Xfinity Advanced Security.
Disable Advanced Security or add `docs.clawd.bot` to the allowlist, then retry.
- Xfinity Advanced Security help: https://www.xfinity.com/support/articles/using-xfinity-xfi-advanced-security
- Quick sanity checks: try a mobile hotspot or VPN to confirm it’s ISP-level filtering
### Service says running, but RPC probe fails
- [Gateway troubleshooting](/gateway/troubleshooting)
+7 -7
View File
@@ -30,17 +30,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.23 \
APP_VERSION=2026.1.21 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.23.zip
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.21.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.23 \
APP_VERSION=2026.1.21 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.23.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.21.dSYM.zip
```
## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.23.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.21.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify
- Upload `Clawdbot-2026.1.23.zip` (and `Clawdbot-2026.1.23.dSYM.zip`) to the GitHub release for tag `v2026.1.23`.
- Upload `Clawdbot-2026.1.21.zip` (and `Clawdbot-2026.1.21.dSYM.zip`) to the GitHub release for tag `v2026.1.21`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
-1
View File
@@ -324,7 +324,6 @@ brew install <formula>
```
If you run Clawdbot via systemd, ensure the service PATH includes `/home/linuxbrew/.linuxbrew/bin` (or your brew prefix) so `brew`-installed tools resolve in non‑login shells.
Recent builds also prepend common user bin dirs on Linux systemd services (for example `~/.local/bin`, `~/.npm-global/bin`, `~/.local/share/pnpm`, `~/.bun/bin`) and honor `PNPM_HOME`, `NPM_CONFIG_PREFIX`, `BUN_INSTALL`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `NVM_DIR`, and `FNM_DIR` when set.
### Can I switch between npm and git installs later?
-2
View File
@@ -54,7 +54,6 @@ Example schema:
"autoAllowSkills": true,
"allowlist": [
{
"id": "B0C8C0B3-2C2D-4F8A-9A3C-5A4B3C2D1E0F",
"pattern": "~/Projects/**/bin/rg",
"lastUsedAt": 1737150000000,
"lastUsedCommand": "rg -n TODO",
@@ -97,7 +96,6 @@ Examples:
- `/opt/homebrew/bin/rg`
Each allowlist entry tracks:
- **id** stable UUID used for UI identity (optional)
- **last used** timestamp
- **last used command**
- **last resolved path**
-2
View File
@@ -88,8 +88,6 @@ Session lifecycle:
- `/settings`
- `/exit`
Other Gateway slash commands (for example, `/context`) are forwarded to the Gateway and shown as system output. See [Slash commands](/tools/slash-commands).
## Local shell commands
- Prefix a line with `!` to run a local shell command on the TUI host.
- The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/bluebubbles",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": {
+36 -48
View File
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IncomingMessage, ServerResponse } from "node:http";
import { EventEmitter } from "node:events";
import { removeAckReactionAfterReply, shouldAckReaction } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import {
handleBlueBubblesWebhookRequest,
@@ -129,7 +128,6 @@ function createMockRuntime(): PluginRuntime {
session: {
resolveStorePath: mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
readSessionUpdatedAt: mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
recordInboundSession: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
recordSessionMetaFromInbound: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
updateLastRoute: vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
},
@@ -137,10 +135,6 @@ function createMockRuntime(): PluginRuntime {
buildMentionRegexes: mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
matchesMentionPatterns: mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
},
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
},
groups: {
resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
@@ -228,12 +222,6 @@ function createMockResponse(): ServerResponse & { body: string; statusCode: numb
return res;
}
const flushAsync = async () => {
for (let i = 0; i < 2; i += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
};
describe("BlueBubbles webhook monitor", () => {
let unregister: () => void;
@@ -520,7 +508,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
expect.objectContaining({
@@ -568,7 +556,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
@@ -615,7 +603,7 @@ describe("BlueBubbles webhook monitor", () => {
await handleBlueBubblesWebhookRequest(req, res);
// Wait for async processing
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
@@ -654,7 +642,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
@@ -695,7 +683,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockUpsertPairingRequest).toHaveBeenCalled();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
@@ -738,7 +726,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockUpsertPairingRequest).toHaveBeenCalled();
// Should not send pairing reply since created=false
@@ -779,7 +767,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -816,7 +804,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -856,7 +844,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -894,7 +882,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -933,7 +921,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -972,7 +960,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -1013,7 +1001,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1054,7 +1042,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -1092,7 +1080,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -1135,7 +1123,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1181,7 +1169,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1227,7 +1215,7 @@ describe("BlueBubbles webhook monitor", () => {
const originalRes = createMockResponse();
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Only assert the reply message behavior below.
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
@@ -1251,7 +1239,7 @@ describe("BlueBubbles webhook monitor", () => {
const replyRes = createMockResponse();
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1297,7 +1285,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1345,7 +1333,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1398,7 +1386,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Should process even without mention because it's an authorized control command
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
@@ -1441,7 +1429,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -1484,7 +1472,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(markBlueBubblesChatRead).toHaveBeenCalled();
});
@@ -1525,7 +1513,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(markBlueBubblesChatRead).not.toHaveBeenCalled();
});
@@ -1568,7 +1556,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Should call typing start when reply flow triggers it.
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
@@ -1618,7 +1606,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String),
@@ -1663,7 +1651,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String),
@@ -1711,7 +1699,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
@@ -1756,7 +1744,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction added"),
@@ -1796,7 +1784,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction removed"),
@@ -1836,7 +1824,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
});
@@ -1874,7 +1862,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("👍"),
@@ -1915,7 +1903,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1955,7 +1943,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// The short ID "1" should resolve back to the full UUID
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
@@ -2007,7 +1995,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
+53 -66
View File
@@ -1,13 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import {
logAckFailure,
logInboundDrop,
logTypingFailure,
resolveAckReaction,
resolveControlCommandGate,
} from "clawdbot/plugin-sdk";
import { resolveAckReaction } from "clawdbot/plugin-sdk";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { downloadBlueBubblesAttachment } from "./attachments.js";
@@ -1352,25 +1346,23 @@ async function processMessage(
})
: false;
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
allowTextCommands: true,
hasControlCommand: hasControlCmd,
});
const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
const commandAuthorized = isGroup
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
})
: dmAuthorized;
// Block control commands from unauthorized senders in groups
if (isGroup && commandGate.shouldBlock) {
logInboundDrop({
log: (msg) => logVerbose(core, runtime, msg),
channel: "bluebubbles",
reason: "control command (unauthorized)",
target: message.senderId,
});
if (isGroup && hasControlCmd && !commandAuthorized) {
logVerbose(
core,
runtime,
`bluebubbles: drop control command from unauthorized sender ${message.senderId}`,
);
return;
}
@@ -1529,20 +1521,19 @@ async function processMessage(
core,
runtime,
});
const shouldAckReaction = () =>
Boolean(
ackReactionValue &&
core.channel.reactions.shouldAckReaction({
scope: ackReactionScope,
isDirect: !isGroup,
isGroup,
isMentionableGroup: isGroup,
requireMention: Boolean(requireMention),
canDetectMention,
effectiveWasMentioned,
shouldBypassMention,
}),
);
const shouldAckReaction = () => {
if (!ackReactionValue) return false;
if (ackReactionScope === "all") return true;
if (ackReactionScope === "direct") return !isGroup;
if (ackReactionScope === "group-all") return isGroup;
if (ackReactionScope === "group-mentions") {
if (!isGroup) return false;
if (!requireMention) return false;
if (!canDetectMention) return false;
return effectiveWasMentioned;
}
return false;
};
const ackMessageId = message.messageId?.trim() || "";
const ackReactionPromise =
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
@@ -1758,27 +1749,29 @@ async function processMessage(
},
});
} finally {
if (sentMessage && chatGuidForActions && ackMessageId) {
core.channel.reactions.removeAckReactionAfterReply({
removeAfterReply: removeAckAfterReply,
ackReactionPromise,
ackReactionValue: ackReactionValue ?? null,
remove: () =>
sendBlueBubblesReaction({
chatGuid: chatGuidForActions,
messageGuid: ackMessageId,
emoji: ackReactionValue ?? "",
remove: true,
opts: { cfg: config, accountId: account.accountId },
}),
onError: (err) => {
logAckFailure({
log: (msg) => logVerbose(core, runtime, msg),
channel: "bluebubbles",
target: `${chatGuidForActions}/${ackMessageId}`,
error: err,
});
},
if (
removeAckAfterReply &&
sentMessage &&
ackReactionPromise &&
ackReactionValue &&
chatGuidForActions &&
ackMessageId
) {
void ackReactionPromise.then((didAck) => {
if (!didAck) return;
sendBlueBubblesReaction({
chatGuid: chatGuidForActions,
messageGuid: ackMessageId,
emoji: ackReactionValue,
remove: true,
opts: { cfg: config, accountId: account.accountId },
}).catch((err) => {
logVerbose(
core,
runtime,
`ack reaction removal failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
);
});
});
}
if (chatGuidForActions && baseUrl && password && !sentMessage) {
@@ -1787,13 +1780,7 @@ async function processMessage(
cfg: config,
accountId: account.accountId,
}).catch((err) => {
logTypingFailure({
log: (msg) => logVerbose(core, runtime, msg),
channel: "bluebubbles",
action: "stop",
target: chatGuidForActions,
error: err,
});
logVerbose(core, runtime, `typing stop (no reply) failed: ${String(err)}`);
});
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/copilot-proxy",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/diagnostics-otel",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/discord",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Discord channel plugin",
"clawdbot": {
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/imessage",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot iMessage channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/lobster",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/matrix",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Matrix channel plugin",
"clawdbot": {
+46 -76
View File
@@ -1,12 +1,7 @@
import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk";
import {
createReplyPrefixContext,
createTypingCallbacks,
formatAllowlistMatchMeta,
logInboundDrop,
logTypingFailure,
resolveControlCommandGate,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
import type { CoreConfig, ReplyToMode } from "../../types.js";
@@ -381,25 +376,21 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
userName: senderName,
})
: false;
const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
const commandGate = resolveControlCommandGate({
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
],
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
});
const commandAuthorized = commandGate.commandAuthorized;
if (isRoom && commandGate.shouldBlock) {
logInboundDrop({
log: logVerboseMessage,
channel: "matrix",
reason: "control command (unauthorized)",
target: senderId,
});
if (
isRoom &&
allowTextCommands &&
core.channel.text.hasControlCommand(bodyText, cfg) &&
!commandAuthorized
) {
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
return;
}
const shouldRequireMention = isRoom
@@ -418,8 +409,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
!wasMentioned &&
!hasExplicitMention &&
commandAuthorized &&
hasControlCommandInMessage;
const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
core.channel.text.hasControlCommand(bodyText);
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
return;
@@ -496,45 +486,47 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
OriginatingTo: `room:${roomId}`,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
updateLastRoute: isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "matrix",
to: `room:${roomId}`,
accountId: route.accountId,
}
: undefined,
onRecordError: (err) => {
void core.channel.session
.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
})
.catch((err) => {
logger.warn(
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
"failed updating session meta",
);
},
});
});
if (isDirectMessage) {
await core.channel.session.updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
channel: "matrix",
to: `room:${roomId}`,
accountId: route.accountId,
ctx: ctxPayload,
});
}
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const shouldAckReaction = () =>
Boolean(
ackReaction &&
core.channel.reactions.shouldAckReaction({
scope: ackScope,
isDirect: isDirectMessage,
isGroup: isRoom,
isMentionableGroup: isRoom,
requireMention: Boolean(shouldRequireMention),
canDetectMention,
effectiveWasMentioned: wasMentioned || shouldBypassMention,
shouldBypassMention,
}),
);
const shouldAckReaction = () => {
if (!ackReaction) return false;
if (ackScope === "all") return true;
if (ackScope === "direct") return isDirectMessage;
if (ackScope === "group-all") return isRoom;
if (ackScope === "group-mentions") {
if (!isRoom) return false;
if (!shouldRequireMention) return false;
return wasMentioned || shouldBypassMention;
}
return false;
};
if (shouldAckReaction() && messageId) {
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
@@ -561,33 +553,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
channel: "matrix",
accountId: route.accountId,
});
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
const typingCallbacks = createTypingCallbacks({
start: () => sendTypingMatrix(roomId, true, undefined, client),
stop: () => sendTypingMatrix(roomId, false, undefined, client),
onStartError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix",
action: "start",
target: roomId,
error: err,
});
},
onStopError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix",
action: "stop",
target: roomId,
error: err,
});
},
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
.responsePrefix,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
await deliverMatrixReplies({
@@ -606,8 +575,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
onError: (err, info) => {
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks.onReplyStart,
onIdle: typingCallbacks.onIdle,
onReplyStart: () =>
sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
onIdle: () =>
sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
});
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
@@ -617,7 +588,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
onModelSelected: prefixContext.onModelSelected,
},
});
markDispatchIdle();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/mattermost",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Mattermost channel plugin",
"clawdbot": {
+53 -59
View File
@@ -7,15 +7,10 @@ import type {
RuntimeEnv,
} from "clawdbot/plugin-sdk";
import {
createReplyPrefixContext,
createTypingCallbacks,
logInboundDrop,
logTypingFailure,
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled,
resolveControlCommandGate,
recordPendingHistoryEntry,
resolveChannelMediaMaxBytes,
type HistoryEntry,
} from "clawdbot/plugin-sdk";
@@ -35,9 +30,12 @@ import {
} from "./client.js";
import {
createDedupeCache,
extractShortModelName,
formatInboundFromLabel,
rawDataToString,
resolveIdentityName,
resolveThreadSessionKeys,
type ResponsePrefixContext,
} from "./monitor-helpers.js";
import { sendMessageMattermost } from "./send.js";
@@ -309,7 +307,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
};
const sendTypingIndicator = async (channelId: string, parentId?: string) => {
await sendMattermostTyping(client, { channelId, parentId });
try {
await sendMattermostTyping(client, { channelId, parentId });
} catch (err) {
logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`);
}
};
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
@@ -401,8 +403,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
cfg,
surface: "mattermost",
});
const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg);
const isControlCommand = allowTextCommands && hasControlCommand;
const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed({
senderId,
@@ -414,20 +415,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
senderName,
allowFrom: effectiveGroupAllowFrom,
});
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{
configured: effectiveGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
},
],
allowTextCommands,
hasControlCommand,
});
const commandAuthorized =
kind === "dm" ? dmPolicy === "open" || senderAllowedForCommands : commandGate.commandAuthorized;
kind === "dm"
? dmPolicy === "open" || senderAllowedForCommands
: core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{
configured: effectiveGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
},
],
});
if (kind === "dm") {
if (dmPolicy === "disabled") {
@@ -488,13 +488,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}
}
if (kind !== "dm" && commandGate.shouldBlock) {
logInboundDrop({
log: logVerboseMessage,
channel: "mattermost",
reason: "control command (unauthorized)",
target: senderId,
});
if (kind !== "dm" && isControlCommand && !commandAuthorized) {
logVerboseMessage(
`mattermost: drop control command from unauthorized sender ${senderId}`,
);
return;
}
@@ -537,19 +534,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
: "");
const pendingSender = senderName;
const recordPendingHistory = () => {
if (!historyKey || historyLimit <= 0) return;
const trimmed = pendingBody.trim();
recordPendingHistoryEntryIfEnabled({
if (!trimmed) return;
recordPendingHistoryEntry({
historyMap: channelHistories,
historyKey,
limit: historyLimit,
historyKey: historyKey ?? "",
entry: historyKey && trimmed
? {
sender: pendingSender,
body: trimmed,
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
messageId: post.id ?? undefined,
}
: null,
entry: {
sender: pendingSender,
body: trimmed,
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
messageId: post.id ?? undefined,
},
});
};
@@ -626,7 +623,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
sender: { name: senderName, id: senderId },
});
let combinedBody = body;
if (historyKey) {
if (historyKey && historyLimit > 0) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: channelHistories,
historyKey,
@@ -716,23 +713,15 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
accountId: account.accountId,
});
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId),
};
const typingCallbacks = createTypingCallbacks({
start: () => sendTypingIndicator(channelId, threadRootId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
channel: "mattermost",
target: channelId,
error: err,
});
},
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
.responsePrefix,
responsePrefixContextProvider: () => prefixContext,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
@@ -763,7 +752,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
onError: (err, info) => {
runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks.onReplyStart,
onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
});
await core.channel.reply.dispatchReplyFromConfig({
@@ -774,12 +763,17 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
...replyOptions,
disableBlockStreaming:
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
onModelSelected: prefixContext.onModelSelected,
onModelSelected: (ctx) => {
prefixContext.provider = ctx.provider;
prefixContext.model = extractShortModelName(ctx.model);
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
},
},
});
markDispatchIdle();
if (historyKey) {
clearHistoryEntriesIfEnabled({ historyMap: channelHistories, historyKey, limit: historyLimit });
if (historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: channelHistories, historyKey });
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-core",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot core memory search plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-lancedb",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/msteams",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {
@@ -68,10 +68,10 @@ function scopeCandidatesForUrl(url: string): string[] {
host.endsWith("1drv.ms") ||
host.includes("sharepoint");
return looksLikeGraph
? ["https://graph.microsoft.com", "https://api.botframework.com"]
: ["https://api.botframework.com", "https://graph.microsoft.com"];
? ["https://graph.microsoft.com/.default", "https://api.botframework.com/.default"]
: ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"];
} catch {
return ["https://api.botframework.com", "https://graph.microsoft.com"];
return ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"];
}
}
+1 -1
View File
@@ -198,7 +198,7 @@ export async function downloadMSTeamsGraphMedia(params: {
const messageUrl = params.messageUrl;
let accessToken: string;
try {
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com");
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
} catch {
return { media: [], messageUrl, tokenError: true };
}
+1 -1
View File
@@ -64,7 +64,7 @@ async function resolveGraphToken(cfg: unknown): Promise<string> {
if (!creds) throw new Error("MS Teams credentials missing");
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
const accessToken = readAccessToken(token);
if (!accessToken) throw new Error("MS Teams graph token unavailable");
return accessToken;
+1 -1
View File
@@ -13,7 +13,7 @@ import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
const GRAPH_BETA = "https://graph.microsoft.com/beta";
const GRAPH_SCOPE = "https://graph.microsoft.com";
const GRAPH_SCOPE = "https://graph.microsoft.com/.default";
export interface OneDriveUploadResult {
id: string;
@@ -1,10 +1,8 @@
import {
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT,
logInboundDrop,
recordPendingHistoryEntryIfEnabled,
resolveControlCommandGate,
recordPendingHistoryEntry,
resolveMentionGating,
formatAllowlistMatchMeta,
type HistoryEntry,
@@ -253,24 +251,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
senderId,
senderName,
});
const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
const commandGate = resolveControlCommandGate({
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
});
const commandAuthorized = commandGate.commandAuthorized;
if (commandGate.shouldBlock) {
logInboundDrop({
log: logVerboseMessage,
channel: "msteams",
reason: "control command (unauthorized)",
target: senderId,
});
if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) {
logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`);
return;
}
@@ -382,17 +371,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
requireMention,
mentioned,
});
recordPendingHistoryEntryIfEnabled({
historyMap: conversationHistories,
historyKey: conversationId,
limit: historyLimit,
entry: {
sender: senderName,
body: rawBody,
timestamp: timestamp?.getTime(),
messageId: activity.id ?? undefined,
},
});
if (historyLimit > 0) {
recordPendingHistoryEntry({
historyMap: conversationHistories,
historyKey: conversationId,
limit: historyLimit,
entry: {
sender: senderName,
body: rawBody,
timestamp: timestamp?.getTime(),
messageId: activity.id ?? undefined,
},
});
}
return;
}
}
@@ -435,7 +426,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
let combinedBody = body;
const isRoomish = !isDirectMessage;
const historyKey = isRoomish ? conversationId : undefined;
if (isRoomish && historyKey) {
if (isRoomish && historyKey && historyLimit > 0) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: conversationHistories,
historyKey,
@@ -476,13 +467,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
...mediaPayload,
});
await core.channel.session.recordInboundSession({
storePath,
void core.channel.session.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
},
}).catch((err) => {
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
});
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
@@ -522,11 +512,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const didSendReply = counts.final + counts.tool + counts.block > 0;
if (!queuedFinal) {
if (isRoomish && historyKey) {
clearHistoryEntriesIfEnabled({
if (isRoomish && historyKey && historyLimit > 0) {
clearHistoryEntries({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
});
}
return;
@@ -535,12 +524,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
logVerboseMessage(
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
);
if (isRoomish && historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
});
if (isRoomish && historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
}
} catch (err) {
log.error("dispatch failed", { error: String(err) });
+32 -51
View File
@@ -1,7 +1,4 @@
import {
createReplyPrefixContext,
createTypingCallbacks,
logTypingFailure,
resolveChannelMediaMaxBytes,
type ClawdbotConfig,
type MSTeamsReplyStyle,
@@ -42,33 +39,23 @@ export function createMSTeamsReplyDispatcher(params: {
}) {
const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => {
await params.context.sendActivities([{ type: "typing" }]);
try {
await params.context.sendActivities([{ type: "typing" }]);
} catch {
// Typing indicator is best-effort.
}
};
const typingCallbacks = createTypingCallbacks({
start: sendTypingIndicator,
onStartError: (err) => {
logTypingFailure({
log: (message) => params.log.debug(message),
channel: "msteams",
action: "start",
error: err,
});
},
});
const prefixContext = createReplyPrefixContext({
cfg: params.cfg,
agentId: params.agentId,
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
deliver: async (payload) => {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: params.cfg,
channel: "msteams",
return core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(
params.cfg,
params.agentId,
).responsePrefix,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
deliver: async (payload) => {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: params.cfg,
channel: "msteams",
});
const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit,
@@ -100,27 +87,21 @@ export function createMSTeamsReplyDispatcher(params: {
mediaMaxBytes,
});
if (ids.length > 0) params.onSentMessageIds?.(ids);
},
onError: (err, info) => {
const errMsg = formatUnknownError(err);
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
params.runtime.error?.(
`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
);
params.log.error("reply failed", {
kind: info.kind,
error: errMsg,
classification,
hint,
});
},
onReplyStart: typingCallbacks.onReplyStart,
});
return {
dispatcher,
replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected },
markDispatchIdle,
};
},
onError: (err, info) => {
const errMsg = formatUnknownError(err);
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
params.runtime.error?.(
`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
);
params.log.error("reply failed", {
kind: info.kind,
error: errMsg,
classification,
hint,
});
},
onReplyStart: sendTypingIndicator,
});
}
+1 -1
View File
@@ -143,7 +143,7 @@ async function resolveGraphToken(cfg: unknown): Promise<string> {
if (!creds) throw new Error("MS Teams credentials missing");
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
const accessToken = readAccessToken(token);
if (!accessToken) throw new Error("MS Teams graph token unavailable");
return accessToken;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nextcloud-talk",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": {
+23 -28
View File
@@ -1,9 +1,4 @@
import {
logInboundDrop,
resolveControlCommandGate,
type ClawdbotConfig,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
import {
@@ -123,11 +118,7 @@ export async function handleNextcloudTalkInbound(params: {
senderId,
senderName,
}).allowed;
const hasControlCommand = core.channel.text.hasControlCommand(
rawBody,
config as ClawdbotConfig,
);
const commandGate = resolveControlCommandGate({
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{
@@ -136,10 +127,7 @@ export async function handleNextcloudTalkInbound(params: {
allowed: senderAllowedForCommands,
},
],
allowTextCommands,
hasControlCommand,
});
const commandAuthorized = commandGate.commandAuthorized;
if (isGroup) {
const groupAllow = resolveNextcloudTalkGroupAllow({
@@ -200,13 +188,15 @@ export async function handleNextcloudTalkInbound(params: {
}
}
if (isGroup && commandGate.shouldBlock) {
logInboundDrop({
log: (message) => runtime.log?.(message),
channel: CHANNEL_ID,
reason: "control command (unauthorized)",
target: senderId,
});
if (
isGroup &&
allowTextCommands &&
core.channel.text.hasControlCommand(rawBody, config as ClawdbotConfig) &&
commandAuthorized !== true
) {
runtime.log?.(
`nextcloud-talk: drop control command from unauthorized sender ${senderId}`,
);
return;
}
@@ -222,6 +212,10 @@ export async function handleNextcloudTalkInbound(params: {
wildcardConfig: roomMatch.wildcardConfig,
})
: false;
const hasControlCommand = core.channel.text.hasControlCommand(
rawBody,
config as ClawdbotConfig,
);
const mentionGate = resolveNextcloudTalkMentionGate({
isGroup,
requireMention: shouldRequireMention,
@@ -293,14 +287,15 @@ export async function handleNextcloudTalkInbound(params: {
CommandAuthorized: commandAuthorized,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
void core.channel.session
.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
})
.catch((err) => {
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
},
});
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nostr",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
"clawdbot": {
+2 -4
View File
@@ -1,11 +1,9 @@
{
"name": "@clawdbot/open-prose",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"clawdbot": {
"extensions": [
"./index.ts"
]
"extensions": ["./index.ts"]
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/signal",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Signal channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/slack",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Slack channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/telegram",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Telegram channel plugin",
"clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/voice-call",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot voice-call plugin",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/whatsapp",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot WhatsApp channel plugin",
"clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalo",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Zalo channel plugin",
"clawdbot": {
+3 -4
View File
@@ -570,13 +570,12 @@ async function processMessageWithPipeline(params: {
OriginatingTo: `zalo:${chatId}`,
});
await core.channel.session.recordInboundSession({
void core.channel.session.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
},
}).catch((err) => {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalouser",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {
+3 -4
View File
@@ -311,13 +311,12 @@ async function processMessage(
OriginatingTo: `zalouser:${chatId}`,
});
await core.channel.session.recordInboundSession({
void core.channel.session.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
},
}).catch((err) => {
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.23",
"version": "2026.1.22",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
-1
View File
@@ -3,7 +3,6 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
vi.mock("./tools/gateway.js", () => ({
-1
View File
@@ -16,7 +16,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("agents_list", () => {
-1
View File
@@ -10,7 +10,6 @@ vi.mock("../media/image-ops.js", () => ({
resizeToJpeg: vi.fn(async () => Buffer.from("jpeg")),
}));
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("nodes camera_snap", () => {
@@ -75,7 +75,6 @@ vi.mock("../infra/provider-usage.js", () => ({
formatUsageSummaryLine: () => null,
}));
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("session_status tool", () => {
+3 -1
View File
@@ -4,6 +4,9 @@ const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../plugins/tools.js", () => ({
resolvePluginTools: () => [],
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
@@ -20,7 +23,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2000) => {
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -22,7 +22,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
});
import { emitAgentEvent } from "../infra/agent-events.js";
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -22,7 +22,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
});
import { emitAgentEvent } from "../infra/agent-events.js";
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
-107
View File
@@ -1,107 +0,0 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import {
estimateMessagesTokens,
pruneHistoryForContextShare,
splitMessagesByTokenShare,
} from "./compaction.js";
function makeMessage(id: number, size: number): AgentMessage {
return {
role: "user",
content: "x".repeat(size),
timestamp: id,
};
}
describe("splitMessagesByTokenShare", () => {
it("splits messages into two non-empty parts", () => {
const messages: AgentMessage[] = [
makeMessage(1, 4000),
makeMessage(2, 4000),
makeMessage(3, 4000),
makeMessage(4, 4000),
];
const parts = splitMessagesByTokenShare(messages, 2);
expect(parts.length).toBeGreaterThanOrEqual(2);
expect(parts[0]?.length).toBeGreaterThan(0);
expect(parts[1]?.length).toBeGreaterThan(0);
expect(parts.flat().length).toBe(messages.length);
});
it("preserves message order across parts", () => {
const messages: AgentMessage[] = [
makeMessage(1, 4000),
makeMessage(2, 4000),
makeMessage(3, 4000),
makeMessage(4, 4000),
makeMessage(5, 4000),
makeMessage(6, 4000),
];
const parts = splitMessagesByTokenShare(messages, 3);
expect(parts.flat().map((msg) => msg.timestamp)).toEqual(messages.map((msg) => msg.timestamp));
});
});
describe("pruneHistoryForContextShare", () => {
it("drops older chunks until the history budget is met", () => {
const messages: AgentMessage[] = [
makeMessage(1, 4000),
makeMessage(2, 4000),
makeMessage(3, 4000),
makeMessage(4, 4000),
];
const maxContextTokens = 2000; // budget is 1000 tokens (50%)
const pruned = pruneHistoryForContextShare({
messages,
maxContextTokens,
maxHistoryShare: 0.5,
parts: 2,
});
expect(pruned.droppedChunks).toBeGreaterThan(0);
expect(pruned.keptTokens).toBeLessThanOrEqual(Math.floor(maxContextTokens * 0.5));
expect(pruned.messages.length).toBeGreaterThan(0);
});
it("keeps the newest messages when pruning", () => {
const messages: AgentMessage[] = [
makeMessage(1, 4000),
makeMessage(2, 4000),
makeMessage(3, 4000),
makeMessage(4, 4000),
makeMessage(5, 4000),
makeMessage(6, 4000),
];
const totalTokens = estimateMessagesTokens(messages);
const maxContextTokens = Math.max(1, Math.floor(totalTokens * 0.5)); // budget = 25%
const pruned = pruneHistoryForContextShare({
messages,
maxContextTokens,
maxHistoryShare: 0.5,
parts: 2,
});
const keptIds = pruned.messages.map((msg) => msg.timestamp);
const expectedSuffix = messages.slice(-keptIds.length).map((msg) => msg.timestamp);
expect(keptIds).toEqual(expectedSuffix);
});
it("keeps history when already within budget", () => {
const messages: AgentMessage[] = [makeMessage(1, 1000)];
const maxContextTokens = 2000;
const pruned = pruneHistoryForContextShare({
messages,
maxContextTokens,
maxHistoryShare: 0.5,
parts: 2,
});
expect(pruned.droppedChunks).toBe(0);
expect(pruned.messages.length).toBe(messages.length);
expect(pruned.keptTokens).toBe(estimateMessagesTokens(messages));
});
});
-341
View File
@@ -1,341 +0,0 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
export const BASE_CHUNK_RATIO = 0.4;
export const MIN_CHUNK_RATIO = 0.15;
export const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
const DEFAULT_SUMMARY_FALLBACK = "No prior history.";
const DEFAULT_PARTS = 2;
const MERGE_SUMMARIES_INSTRUCTIONS =
"Merge these partial summaries into a single cohesive summary. Preserve decisions," +
" TODOs, open questions, and any constraints.";
export function estimateMessagesTokens(messages: AgentMessage[]): number {
return messages.reduce((sum, message) => sum + estimateTokens(message), 0);
}
function normalizeParts(parts: number, messageCount: number): number {
if (!Number.isFinite(parts) || parts <= 1) return 1;
return Math.min(Math.max(1, Math.floor(parts)), Math.max(1, messageCount));
}
export function splitMessagesByTokenShare(
messages: AgentMessage[],
parts = DEFAULT_PARTS,
): AgentMessage[][] {
if (messages.length === 0) return [];
const normalizedParts = normalizeParts(parts, messages.length);
if (normalizedParts <= 1) return [messages];
const totalTokens = estimateMessagesTokens(messages);
const targetTokens = totalTokens / normalizedParts;
const chunks: AgentMessage[][] = [];
let current: AgentMessage[] = [];
let currentTokens = 0;
for (const message of messages) {
const messageTokens = estimateTokens(message);
if (
chunks.length < normalizedParts - 1 &&
current.length > 0 &&
currentTokens + messageTokens > targetTokens
) {
chunks.push(current);
current = [];
currentTokens = 0;
}
current.push(message);
currentTokens += messageTokens;
}
if (current.length > 0) {
chunks.push(current);
}
return chunks;
}
export function chunkMessagesByMaxTokens(
messages: AgentMessage[],
maxTokens: number,
): AgentMessage[][] {
if (messages.length === 0) return [];
const chunks: AgentMessage[][] = [];
let currentChunk: AgentMessage[] = [];
let currentTokens = 0;
for (const message of messages) {
const messageTokens = estimateTokens(message);
if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) {
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
currentChunk.push(message);
currentTokens += messageTokens;
if (messageTokens > maxTokens) {
// Split oversized messages to avoid unbounded chunk growth.
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
}
if (currentChunk.length > 0) {
chunks.push(currentChunk);
}
return chunks;
}
/**
* Compute adaptive chunk ratio based on average message size.
* When messages are large, we use smaller chunks to avoid exceeding model limits.
*/
export function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number {
if (messages.length === 0) return BASE_CHUNK_RATIO;
const totalTokens = estimateMessagesTokens(messages);
const avgTokens = totalTokens / messages.length;
// Apply safety margin to account for estimation inaccuracy
const safeAvgTokens = avgTokens * SAFETY_MARGIN;
const avgRatio = safeAvgTokens / contextWindow;
// If average message is > 10% of context, reduce chunk ratio
if (avgRatio > 0.1) {
const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
}
return BASE_CHUNK_RATIO;
}
/**
* Check if a single message is too large to summarize.
* If single message > 50% of context, it can't be summarized safely.
*/
export function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean {
const tokens = estimateTokens(msg) * SAFETY_MARGIN;
return tokens > contextWindow * 0.5;
}
async function summarizeChunks(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
if (params.messages.length === 0) {
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
const chunks = chunkMessagesByMaxTokens(params.messages, params.maxChunkTokens);
let summary = params.previousSummary;
for (const chunk of chunks) {
summary = await generateSummary(
chunk,
params.model,
params.reserveTokens,
params.apiKey,
params.signal,
params.customInstructions,
summary,
);
}
return summary ?? DEFAULT_SUMMARY_FALLBACK;
}
/**
* Summarize with progressive fallback for handling oversized messages.
* If full summarization fails, tries partial summarization excluding oversized messages.
*/
export async function summarizeWithFallback(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
const { messages, contextWindow } = params;
if (messages.length === 0) {
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
// Try full summarization first
try {
return await summarizeChunks(params);
} catch (fullError) {
console.warn(
`Full summarization failed, trying partial: ${
fullError instanceof Error ? fullError.message : String(fullError)
}`,
);
}
// Fallback 1: Summarize only small messages, note oversized ones
const smallMessages: AgentMessage[] = [];
const oversizedNotes: string[] = [];
for (const msg of messages) {
if (isOversizedForSummary(msg, contextWindow)) {
const role = (msg as { role?: string }).role ?? "message";
const tokens = estimateTokens(msg);
oversizedNotes.push(
`[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`,
);
} else {
smallMessages.push(msg);
}
}
if (smallMessages.length > 0) {
try {
const partialSummary = await summarizeChunks({
...params,
messages: smallMessages,
});
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
return partialSummary + notes;
} catch (partialError) {
console.warn(
`Partial summarization also failed: ${
partialError instanceof Error ? partialError.message : String(partialError)
}`,
);
}
}
// Final fallback: Just note what was there
return (
`Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` +
`Summary unavailable due to size limits.`
);
}
export async function summarizeInStages(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
previousSummary?: string;
parts?: number;
minMessagesForSplit?: number;
}): Promise<string> {
const { messages } = params;
if (messages.length === 0) {
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
const minMessagesForSplit = Math.max(2, params.minMessagesForSplit ?? 4);
const parts = normalizeParts(params.parts ?? DEFAULT_PARTS, messages.length);
const totalTokens = estimateMessagesTokens(messages);
if (parts <= 1 || messages.length < minMessagesForSplit || totalTokens <= params.maxChunkTokens) {
return summarizeWithFallback(params);
}
const splits = splitMessagesByTokenShare(messages, parts).filter((chunk) => chunk.length > 0);
if (splits.length <= 1) {
return summarizeWithFallback(params);
}
const partialSummaries: string[] = [];
for (const chunk of splits) {
partialSummaries.push(
await summarizeWithFallback({
...params,
messages: chunk,
previousSummary: undefined,
}),
);
}
if (partialSummaries.length === 1) {
return partialSummaries[0];
}
const summaryMessages: AgentMessage[] = partialSummaries.map((summary) => ({
role: "user",
content: summary,
timestamp: Date.now(),
}));
const mergeInstructions = params.customInstructions
? `${MERGE_SUMMARIES_INSTRUCTIONS}\n\nAdditional focus:\n${params.customInstructions}`
: MERGE_SUMMARIES_INSTRUCTIONS;
return summarizeWithFallback({
...params,
messages: summaryMessages,
customInstructions: mergeInstructions,
});
}
export function pruneHistoryForContextShare(params: {
messages: AgentMessage[];
maxContextTokens: number;
maxHistoryShare?: number;
parts?: number;
}): {
messages: AgentMessage[];
droppedChunks: number;
droppedMessages: number;
droppedTokens: number;
keptTokens: number;
budgetTokens: number;
} {
const maxHistoryShare = params.maxHistoryShare ?? 0.5;
const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare));
let keptMessages = params.messages;
let droppedChunks = 0;
let droppedMessages = 0;
let droppedTokens = 0;
const parts = normalizeParts(params.parts ?? DEFAULT_PARTS, keptMessages.length);
while (keptMessages.length > 0 && estimateMessagesTokens(keptMessages) > budgetTokens) {
const chunks = splitMessagesByTokenShare(keptMessages, parts);
if (chunks.length <= 1) break;
const [dropped, ...rest] = chunks;
droppedChunks += 1;
droppedMessages += dropped.length;
droppedTokens += estimateMessagesTokens(dropped);
keptMessages = rest.flat();
}
return {
messages: keptMessages,
droppedChunks,
droppedMessages,
droppedTokens,
keptTokens: estimateMessagesTokens(keptMessages),
budgetTokens,
};
}
export function resolveContextWindowTokens(model?: ExtensionContext["model"]): number {
return Math.max(1, Math.floor(model?.contextWindow ?? DEFAULT_CONTEXT_TOKENS));
}
+2 -6
View File
@@ -109,18 +109,14 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH
state: "processing",
reason: wasActive ? "run_replaced" : "run_started",
});
if (!sessionId.startsWith("probe-")) {
diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) {
if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) {
ACTIVE_EMBEDDED_RUNS.delete(sessionId);
logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" });
if (!sessionId.startsWith("probe-")) {
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
notifyEmbeddedRunEnded(sessionId);
} else {
diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`);
+187 -52
View File
@@ -1,16 +1,12 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent";
import {
BASE_CHUNK_RATIO,
MIN_CHUNK_RATIO,
SAFETY_MARGIN,
computeAdaptiveChunkRatio,
estimateMessagesTokens,
isOversizedForSummary,
pruneHistoryForContextShare,
resolveContextWindowTokens,
summarizeInStages,
} from "../compaction.js";
import type { ExtensionAPI, ExtensionContext, FileOperations } from "@mariozechner/pi-coding-agent";
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
const BASE_CHUNK_RATIO = 0.4;
const MIN_CHUNK_RATIO = 0.15;
const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
const FALLBACK_SUMMARY =
"Summary unavailable due to context limits. Older messages were truncated.";
const TURN_PREFIX_INSTRUCTIONS =
@@ -133,6 +129,175 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[]): str
return `\n\n${sections.join("\n\n")}`;
}
function chunkMessages(messages: AgentMessage[], maxTokens: number): AgentMessage[][] {
if (messages.length === 0) return [];
const chunks: AgentMessage[][] = [];
let currentChunk: AgentMessage[] = [];
let currentTokens = 0;
for (const message of messages) {
const messageTokens = estimateTokens(message);
if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) {
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
currentChunk.push(message);
currentTokens += messageTokens;
if (messageTokens > maxTokens) {
// Split oversized messages to avoid unbounded chunk growth.
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
}
if (currentChunk.length > 0) {
chunks.push(currentChunk);
}
return chunks;
}
/**
* Compute adaptive chunk ratio based on average message size.
* When messages are large, we use smaller chunks to avoid exceeding model limits.
*/
function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number {
if (messages.length === 0) return BASE_CHUNK_RATIO;
const totalTokens = messages.reduce((sum, m) => sum + estimateTokens(m), 0);
const avgTokens = totalTokens / messages.length;
// Apply safety margin to account for estimation inaccuracy
const safeAvgTokens = avgTokens * SAFETY_MARGIN;
const avgRatio = safeAvgTokens / contextWindow;
// If average message is > 10% of context, reduce chunk ratio
if (avgRatio > 0.1) {
const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
}
return BASE_CHUNK_RATIO;
}
/**
* Check if a single message is too large to summarize.
* If single message > 50% of context, it can't be summarized safely.
*/
function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean {
const tokens = estimateTokens(msg) * SAFETY_MARGIN;
return tokens > contextWindow * 0.5;
}
async function summarizeChunks(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
if (params.messages.length === 0) {
return params.previousSummary ?? "No prior history.";
}
const chunks = chunkMessages(params.messages, params.maxChunkTokens);
let summary = params.previousSummary;
for (const chunk of chunks) {
summary = await generateSummary(
chunk,
params.model,
params.reserveTokens,
params.apiKey,
params.signal,
params.customInstructions,
summary,
);
}
return summary ?? "No prior history.";
}
/**
* Summarize with progressive fallback for handling oversized messages.
* If full summarization fails, tries partial summarization excluding oversized messages.
*/
async function summarizeWithFallback(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
const { messages, contextWindow } = params;
if (messages.length === 0) {
return params.previousSummary ?? "No prior history.";
}
// Try full summarization first
try {
return await summarizeChunks(params);
} catch (fullError) {
console.warn(
`Full summarization failed, trying partial: ${
fullError instanceof Error ? fullError.message : String(fullError)
}`,
);
}
// Fallback 1: Summarize only small messages, note oversized ones
const smallMessages: AgentMessage[] = [];
const oversizedNotes: string[] = [];
for (const msg of messages) {
if (isOversizedForSummary(msg, contextWindow)) {
const role = (msg as { role?: string }).role ?? "message";
const tokens = estimateTokens(msg);
oversizedNotes.push(
`[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`,
);
} else {
smallMessages.push(msg);
}
}
if (smallMessages.length > 0) {
try {
const partialSummary = await summarizeChunks({
...params,
messages: smallMessages,
});
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
return partialSummary + notes;
} catch (partialError) {
console.warn(
`Partial summarization also failed: ${
partialError instanceof Error ? partialError.message : String(partialError)
}`,
);
}
}
// Final fallback: Just note what was there
return (
`Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` +
`Summary unavailable due to size limits.`
);
}
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
api.on("session_before_compact", async (event, ctx) => {
const { preparation, customInstructions, signal } = event;
@@ -170,48 +335,19 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
}
try {
const contextWindowTokens = resolveContextWindowTokens(model);
const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
let messagesToSummarize = preparation.messagesToSummarize;
const tokensBefore =
typeof preparation.tokensBefore === "number" && Number.isFinite(preparation.tokensBefore)
? preparation.tokensBefore
: undefined;
if (tokensBefore !== undefined) {
const summarizableTokens =
estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages);
const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens));
const maxHistoryTokens = Math.floor(contextWindowTokens * 0.5);
if (newContentTokens > maxHistoryTokens) {
const pruned = pruneHistoryForContextShare({
messages: messagesToSummarize,
maxContextTokens: contextWindowTokens,
maxHistoryShare: 0.5,
parts: 2,
});
if (pruned.droppedChunks > 0) {
const newContentRatio = (newContentTokens / contextWindowTokens) * 100;
console.warn(
`Compaction safeguard: new content uses ${newContentRatio.toFixed(
1,
)}% of context; dropped ${pruned.droppedChunks} older chunk(s) ` +
`(${pruned.droppedMessages} messages) to fit history budget.`,
);
messagesToSummarize = pruned.messages;
}
}
}
const contextWindowTokens = Math.max(
1,
Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS),
);
// Use adaptive chunk ratio based on message sizes
const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
const allMessages = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens);
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio));
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
const historySummary = await summarizeInStages({
messages: messagesToSummarize,
const historySummary = await summarizeWithFallback({
messages: preparation.messagesToSummarize,
model,
apiKey,
signal,
@@ -223,9 +359,9 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
});
let summary = historySummary;
if (preparation.isSplitTurn && turnPrefixMessages.length > 0) {
const prefixSummary = await summarizeInStages({
messages: turnPrefixMessages,
if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) {
const prefixSummary = await summarizeWithFallback({
messages: preparation.turnPrefixMessages,
model,
apiKey,
signal,
@@ -233,7 +369,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
maxChunkTokens,
contextWindow: contextWindowTokens,
customInstructions: TURN_PREFIX_INSTRUCTIONS,
previousSummary: undefined,
});
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
}
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotCodingTools } from "./pi-tools.js";
const defaultTools = createClawdbotCodingTools();
@@ -3,7 +3,6 @@ import os from "node:os";
import path from "node:path";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotCodingTools } from "./pi-tools.js";
const defaultTools = createClawdbotCodingTools();
@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotCodingTools } from "./pi-tools.js";
describe("createClawdbotCodingTools", () => {
@@ -3,7 +3,6 @@ import os from "node:os";
import path from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { describe, expect, it, vi } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createSandboxedReadTool } from "./pi-tools.read.js";
-34
View File
@@ -1,34 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { acquireSessionWriteLock } from "./session-write-lock.js";
describe("acquireSessionWriteLock", () => {
it("reuses locks across symlinked session paths", async () => {
if (process.platform === "win32") {
expect(true).toBe(true);
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
try {
const realDir = path.join(root, "real");
const linkDir = path.join(root, "link");
await fs.mkdir(realDir, { recursive: true });
await fs.symlink(realDir, linkDir);
const sessionReal = path.join(realDir, "sessions.json");
const sessionLink = path.join(linkDir, "sessions.json");
const lockA = await acquireSessionWriteLock({ sessionFile: sessionReal, timeoutMs: 500 });
const lockB = await acquireSessionWriteLock({ sessionFile: sessionLink, timeoutMs: 500 });
await lockB.release();
await lockA.release();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
+9 -17
View File
@@ -45,28 +45,20 @@ export async function acquireSessionWriteLock(params: {
}> {
const timeoutMs = params.timeoutMs ?? 10_000;
const staleMs = params.staleMs ?? 30 * 60 * 1000;
const sessionFile = path.resolve(params.sessionFile);
const sessionDir = path.dirname(sessionFile);
await fs.mkdir(sessionDir, { recursive: true });
let normalizedDir = sessionDir;
try {
normalizedDir = await fs.realpath(sessionDir);
} catch {
// Fall back to the resolved path if realpath fails (permissions, transient FS).
}
const normalizedSessionFile = path.join(normalizedDir, path.basename(sessionFile));
const lockPath = `${normalizedSessionFile}.lock`;
const sessionFile = params.sessionFile;
const lockPath = `${sessionFile}.lock`;
await fs.mkdir(path.dirname(lockPath), { recursive: true });
const held = HELD_LOCKS.get(normalizedSessionFile);
const held = HELD_LOCKS.get(sessionFile);
if (held) {
held.count += 1;
return {
release: async () => {
const current = HELD_LOCKS.get(normalizedSessionFile);
const current = HELD_LOCKS.get(sessionFile);
if (!current) return;
current.count -= 1;
if (current.count > 0) return;
HELD_LOCKS.delete(normalizedSessionFile);
HELD_LOCKS.delete(sessionFile);
await current.handle.close();
await fs.rm(current.lockPath, { force: true });
},
@@ -83,14 +75,14 @@ export async function acquireSessionWriteLock(params: {
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2),
"utf8",
);
HELD_LOCKS.set(normalizedSessionFile, { count: 1, handle, lockPath });
HELD_LOCKS.set(sessionFile, { count: 1, handle, lockPath });
return {
release: async () => {
const current = HELD_LOCKS.get(normalizedSessionFile);
const current = HELD_LOCKS.get(sessionFile);
if (!current) return;
current.count -= 1;
if (current.count > 0) return;
HELD_LOCKS.delete(normalizedSessionFile);
HELD_LOCKS.delete(sessionFile);
await current.handle.close();
await fs.rm(current.lockPath, { force: true });
},
@@ -1,22 +0,0 @@
import { vi } from "vitest";
const stubTool = (name: string) => ({
name,
description: `${name} stub`,
parameters: { type: "object", properties: {} },
execute: vi.fn(),
});
vi.mock("../tools/image-tool.js", () => ({
createImageTool: () => stubTool("image"),
}));
vi.mock("../tools/web-tools.js", () => ({
createWebSearchTool: () => null,
createWebFetchTool: () => null,
}));
vi.mock("../../plugins/tools.js", () => ({
resolvePluginTools: () => [],
getPluginToolMeta: () => undefined,
}));
@@ -1,30 +0,0 @@
import { vi } from "vitest";
const stubTool = (name: string) => ({
name,
description: `${name} stub`,
parameters: { type: "object", properties: {} },
execute: vi.fn(),
});
vi.mock("../tools/browser-tool.js", () => ({
createBrowserTool: () => stubTool("browser"),
}));
vi.mock("../tools/canvas-tool.js", () => ({
createCanvasTool: () => stubTool("canvas"),
}));
vi.mock("../tools/image-tool.js", () => ({
createImageTool: () => stubTool("image"),
}));
vi.mock("../tools/web-tools.js", () => ({
createWebSearchTool: () => null,
createWebFetchTool: () => null,
}));
vi.mock("../../plugins/tools.js", () => ({
resolvePluginTools: () => [],
getPluginToolMeta: () => undefined,
}));
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveCommandAuthorization } from "./command-auth.js";
import type { MsgContext } from "./templating.js";
describe("resolveCommandAuthorization", () => {
it("falls back from empty SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: "",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back from whitespace SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: " ",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back to From when SenderId and SenderE164 are whitespace", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+999"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: " ",
SenderE164: " ",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+999");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back from un-normalizable SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: "wat",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("prefers SenderE164 when SenderId does not match allowFrom", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+41796666864"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:120363401234567890@g.us",
SenderId: "123@lid",
SenderE164: "+41796666864",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+41796666864");
expect(auth.isAuthorizedSender).toBe(true);
});
});
@@ -1,14 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { resolveCommandAuthorization } from "./command-auth.js";
import { hasControlCommand, hasInlineCommandTokens } from "./command-detection.js";
import { listChatCommands } from "./commands-registry.js";
import { parseActivationCommand } from "./group-activation.js";
import { parseSendPolicyCommand } from "./send-policy.js";
import type { MsgContext } from "./templating.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
beforeEach(() => {
setActivePluginRegistry(createTestRegistry([]));
@@ -18,123 +14,6 @@ afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
});
describe("resolveCommandAuthorization", () => {
it("falls back from empty SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: "",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back from whitespace SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: " ",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back to From when SenderId and SenderE164 are whitespace", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+999"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: " ",
SenderE164: " ",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+999");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back from un-normalizable SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: "wat",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("prefers SenderE164 when SenderId does not match allowFrom", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+41796666864"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:120363401234567890@g.us",
SenderId: "123@lid",
SenderE164: "+41796666864",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+41796666864");
expect(auth.isAuthorizedSender).toBe(true);
});
});
describe("control command parsing", () => {
it("requires slash for send policy", () => {
expect(parseSendPolicyCommand("/send on")).toEqual({
@@ -0,0 +1,156 @@
import { describe, expect, it } from "vitest";
import {
buildCommandTextFromArgs,
parseCommandArgs,
resolveCommandArgMenu,
serializeCommandArgs,
} from "./commands-registry.js";
import type { ChatCommandDefinition } from "./commands-registry.types.js";
describe("commands registry args", () => {
it("parses positional args and captureRemaining", () => {
const command: ChatCommandDefinition = {
key: "debug",
description: "debug",
textAliases: [],
scope: "both",
argsParsing: "positional",
args: [
{ name: "action", description: "action", type: "string" },
{ name: "path", description: "path", type: "string" },
{ name: "value", description: "value", type: "string", captureRemaining: true },
],
};
const args = parseCommandArgs(command, "set foo bar baz");
expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" });
});
it("serializes args via raw first, then values", () => {
const command: ChatCommandDefinition = {
key: "model",
description: "model",
textAliases: [],
scope: "both",
argsParsing: "positional",
args: [{ name: "model", description: "model", type: "string", captureRemaining: true }],
};
expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex");
expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
"gpt-5.2-codex",
);
expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
"/model gpt-5.2-codex",
);
});
it("resolves auto arg menus when missing a choice arg", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
});
it("does not show menus when arg already provided", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({
command,
args: { values: { mode: "tokens" } },
cfg: {} as never,
});
expect(menu).toBeNull();
});
it("resolves function-based choices with a default provider/model context", () => {
let seen: { provider: string; model: string; commandKey: string; argName: string } | null =
null;
const command: ChatCommandDefinition = {
key: "think",
description: "think",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "level",
description: "level",
type: "string",
choices: ({ provider, model, command, arg }) => {
seen = { provider, model, commandKey: command.key, argName: arg.name };
return ["low", "high"];
},
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("level");
expect(menu?.choices).toEqual(["low", "high"]);
expect(seen?.commandKey).toBe("think");
expect(seen?.argName).toBe("level");
expect(seen?.provider).toBeTruthy();
expect(seen?.model).toBeTruthy();
});
it("does not show menus when args were provided as raw text only", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "none",
args: [
{
name: "mode",
description: "on or off",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({
command,
args: { raw: "on" },
cfg: {} as never,
});
expect(menu).toBeNull();
});
});
-152
View File
@@ -2,19 +2,14 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
buildCommandText,
buildCommandTextFromArgs,
getCommandDetection,
listChatCommands,
listChatCommandsForConfig,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
normalizeCommandBody,
parseCommandArgs,
resolveCommandArgMenu,
serializeCommandArgs,
shouldHandleTextCommands,
} from "./commands-registry.js";
import type { ChatCommandDefinition } from "./commands-registry.types.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
@@ -159,150 +154,3 @@ describe("commands registry", () => {
expect(normalizeCommandBody("/dock_telegram")).toBe("/dock-telegram");
});
});
describe("commands registry args", () => {
it("parses positional args and captureRemaining", () => {
const command: ChatCommandDefinition = {
key: "debug",
description: "debug",
textAliases: [],
scope: "both",
argsParsing: "positional",
args: [
{ name: "action", description: "action", type: "string" },
{ name: "path", description: "path", type: "string" },
{ name: "value", description: "value", type: "string", captureRemaining: true },
],
};
const args = parseCommandArgs(command, "set foo bar baz");
expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" });
});
it("serializes args via raw first, then values", () => {
const command: ChatCommandDefinition = {
key: "model",
description: "model",
textAliases: [],
scope: "both",
argsParsing: "positional",
args: [{ name: "model", description: "model", type: "string", captureRemaining: true }],
};
expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex");
expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
"gpt-5.2-codex",
);
expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
"/model gpt-5.2-codex",
);
});
it("resolves auto arg menus when missing a choice arg", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
});
it("does not show menus when arg already provided", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({
command,
args: { values: { mode: "tokens" } },
cfg: {} as never,
});
expect(menu).toBeNull();
});
it("resolves function-based choices with a default provider/model context", () => {
let seen: { provider: string; model: string; commandKey: string; argName: string } | null =
null;
const command: ChatCommandDefinition = {
key: "think",
description: "think",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "level",
description: "level",
type: "string",
choices: ({ provider, model, command, arg }) => {
seen = { provider, model, commandKey: command.key, argName: arg.name };
return ["low", "high"];
},
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("level");
expect(menu?.choices).toEqual(["low", "high"]);
expect(seen?.commandKey).toBe("think");
expect(seen?.argName).toBe("level");
expect(seen?.provider).toBeTruthy();
expect(seen?.model).toBeTruthy();
});
it("does not show menus when args were provided as raw text only", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "none",
args: [
{
name: "mode",
description: "on or off",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({
command,
args: { raw: "on" },
cfg: {} as never,
});
expect(menu).toBeNull();
});
});
-77
View File
@@ -1,77 +0,0 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { FinalizedMsgContext, MsgContext } from "./templating.js";
import type { GetReplyOptions } from "./types.js";
import { finalizeInboundContext } from "./reply/inbound-context.js";
import type { DispatchFromConfigResult } from "./reply/dispatch-from-config.js";
import { dispatchReplyFromConfig } from "./reply/dispatch-from-config.js";
import {
createReplyDispatcher,
createReplyDispatcherWithTyping,
type ReplyDispatcher,
type ReplyDispatcherOptions,
type ReplyDispatcherWithTypingOptions,
} from "./reply/reply-dispatcher.js";
export type DispatchInboundResult = DispatchFromConfigResult;
export async function dispatchInboundMessage(params: {
ctx: MsgContext | FinalizedMsgContext;
cfg: ClawdbotConfig;
dispatcher: ReplyDispatcher;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("./reply.js").getReplyFromConfig;
}): Promise<DispatchInboundResult> {
const finalized = finalizeInboundContext(params.ctx);
return await dispatchReplyFromConfig({
ctx: finalized,
cfg: params.cfg,
dispatcher: params.dispatcher,
replyOptions: params.replyOptions,
replyResolver: params.replyResolver,
});
}
export async function dispatchInboundMessageWithBufferedDispatcher(params: {
ctx: MsgContext | FinalizedMsgContext;
cfg: ClawdbotConfig;
dispatcherOptions: ReplyDispatcherWithTypingOptions;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("./reply.js").getReplyFromConfig;
}): Promise<DispatchInboundResult> {
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping(
params.dispatcherOptions,
);
const result = await dispatchInboundMessage({
ctx: params.ctx,
cfg: params.cfg,
dispatcher,
replyResolver: params.replyResolver,
replyOptions: {
...params.replyOptions,
...replyOptions,
},
});
markDispatchIdle();
return result;
}
export async function dispatchInboundMessageWithDispatcher(params: {
ctx: MsgContext | FinalizedMsgContext;
cfg: ClawdbotConfig;
dispatcherOptions: ReplyDispatcherOptions;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("./reply.js").getReplyFromConfig;
}): Promise<DispatchInboundResult> {
const dispatcher = createReplyDispatcher(params.dispatcherOptions);
const result = await dispatchInboundMessage({
ctx: params.ctx,
cfg: params.cfg,
dispatcher,
replyResolver: params.replyResolver,
replyOptions: params.replyOptions,
});
await dispatcher.waitForIdle();
return result;
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { createInboundDebouncer } from "./inbound-debounce.js";
describe("createInboundDebouncer", () => {
it("debounces and combines items", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
debounceMs: 10,
buildKey: (item) => item.key,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1" });
await debouncer.enqueue({ key: "a", id: "2" });
expect(calls).toEqual([]);
await vi.advanceTimersByTimeAsync(10);
expect(calls).toEqual([["1", "2"]]);
vi.useRealTimers();
});
it("flushes buffered items before non-debounced item", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
debounceMs: 50,
buildKey: (item) => item.key,
shouldDebounce: (item) => item.debounce,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
expect(calls).toEqual([["1"], ["2"]]);
vi.useRealTimers();
});
});
-402
View File
@@ -1,402 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import type { GroupKeyResolution } from "../config/sessions.js";
import { createInboundDebouncer } from "./inbound-debounce.js";
import { applyTemplate, type MsgContext, type TemplateContext } from "./templating.js";
import { finalizeInboundContext } from "./reply/inbound-context.js";
import {
buildInboundDedupeKey,
resetInboundDedupe,
shouldSkipDuplicateInbound,
} from "./reply/inbound-dedupe.js";
import { formatInboundBodyWithSenderMeta } from "./reply/inbound-sender-meta.js";
import { normalizeInboundTextNewlines } from "./reply/inbound-text.js";
import { resolveGroupRequireMention } from "./reply/groups.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
normalizeMentionText,
} from "./reply/mentions.js";
import { initSessionState } from "./reply/session.js";
describe("applyTemplate", () => {
it("renders primitive values", () => {
const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext;
const overrides = ctx as Record<string, unknown>;
overrides.MessageSid = 42;
overrides.IsNewSession = true;
expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true");
});
it("renders arrays of primitives", () => {
const ctx = { MediaPaths: ["a"] } as TemplateContext;
(ctx as Record<string, unknown>).MediaPaths = ["a", 2, true, null, { ok: false }];
expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true");
});
it("drops object values", () => {
const ctx: TemplateContext = { CommandArgs: { raw: "go" } };
expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args=");
});
it("renders missing placeholders as empty", () => {
const ctx: TemplateContext = {};
expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing=");
});
});
describe("normalizeInboundTextNewlines", () => {
it("keeps real newlines", () => {
expect(normalizeInboundTextNewlines("a\nb")).toBe("a\nb");
});
it("normalizes CRLF/CR to LF", () => {
expect(normalizeInboundTextNewlines("a\r\nb")).toBe("a\nb");
expect(normalizeInboundTextNewlines("a\rb")).toBe("a\nb");
});
it("decodes literal \\n to newlines when no real newlines exist", () => {
expect(normalizeInboundTextNewlines("a\\nb")).toBe("a\nb");
});
});
describe("finalizeInboundContext", () => {
it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => {
const ctx: MsgContext = {
Body: "a\\nb\r\nc",
RawBody: "raw\\nline",
ChatType: "channel",
From: "whatsapp:group:123@g.us",
GroupSubject: "Test",
};
const out = finalizeInboundContext(ctx);
expect(out.Body).toBe("a\nb\nc");
expect(out.RawBody).toBe("raw\nline");
expect(out.BodyForAgent).toBe("a\nb\nc");
expect(out.BodyForCommands).toBe("raw\nline");
expect(out.CommandAuthorized).toBe(false);
expect(out.ChatType).toBe("channel");
expect(out.ConversationLabel).toContain("Test");
});
it("can force BodyForCommands to follow updated CommandBody", () => {
const ctx: MsgContext = {
Body: "base",
BodyForCommands: "<media:audio>",
CommandBody: "say hi",
From: "signal:+15550001111",
ChatType: "direct",
};
finalizeInboundContext(ctx, { forceBodyForCommands: true });
expect(ctx.BodyForCommands).toBe("say hi");
});
});
describe("formatInboundBodyWithSenderMeta", () => {
it("does nothing for direct messages", () => {
const ctx: MsgContext = { ChatType: "direct", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi");
});
it("appends a sender meta line for non-direct messages", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
"[X] hi\n[from: Alice (A1)]",
);
});
it("prefers SenderE164 in the label when present", () => {
const ctx: MsgContext = {
ChatType: "group",
SenderName: "Bob",
SenderId: "bob@s.whatsapp.net",
SenderE164: "+222",
};
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
"[X] hi\n[from: Bob (+222)]",
);
});
it("appends with a real newline even if the body contains literal \\n", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe(
"[X] one\\n[X] two\n[from: Bob (+222)]",
);
});
it("does not duplicate a sender meta line when one is already present", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi\n[from: Alice (A1)]" })).toBe(
"[X] hi\n[from: Alice (A1)]",
);
});
it("does not append when the body already includes a sender prefix", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "Alice (A1): hi" })).toBe("Alice (A1): hi");
});
it("does not append when the sender prefix follows an envelope header", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[Signal Group] Alice (A1): hi" })).toBe(
"[Signal Group] Alice (A1): hi",
);
});
});
describe("inbound dedupe", () => {
it("builds a stable key when MessageSid is present", () => {
const ctx: MsgContext = {
Provider: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:123",
MessageSid: "42",
};
expect(buildInboundDedupeKey(ctx)).toBe("telegram|telegram:123|42");
});
it("skips duplicates with the same key", () => {
resetInboundDedupe();
const ctx: MsgContext = {
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+1555",
MessageSid: "msg-1",
};
expect(shouldSkipDuplicateInbound(ctx, { now: 100 })).toBe(false);
expect(shouldSkipDuplicateInbound(ctx, { now: 200 })).toBe(true);
});
it("does not dedupe when the peer changes", () => {
resetInboundDedupe();
const base: MsgContext = {
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
MessageSid: "msg-1",
};
expect(
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+1000" }, { now: 100 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+2000" }, { now: 200 }),
).toBe(false);
});
it("does not dedupe across session keys", () => {
resetInboundDedupe();
const base: MsgContext = {
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+1555",
MessageSid: "msg-1",
};
expect(
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }),
).toBe(true);
});
});
describe("createInboundDebouncer", () => {
it("debounces and combines items", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
debounceMs: 10,
buildKey: (item) => item.key,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1" });
await debouncer.enqueue({ key: "a", id: "2" });
expect(calls).toEqual([]);
await vi.advanceTimersByTimeAsync(10);
expect(calls).toEqual([["1", "2"]]);
vi.useRealTimers();
});
it("flushes buffered items before non-debounced item", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
debounceMs: 50,
buildKey: (item) => item.key,
shouldDebounce: (item) => item.debounce,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
expect(calls).toEqual([["1"], ["2"]]);
vi.useRealTimers();
});
});
describe("initSessionState sender meta", () => {
it("injects sender meta into BodyStripped for group chats", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-"));
const storePath = path.join(root, "sessions.json");
const cfg = { session: { store: storePath } } as ClawdbotConfig;
const result = await initSessionState({
ctx: {
Body: "[WhatsApp 123@g.us] ping",
ChatType: "group",
SenderName: "Bob",
SenderE164: "+222",
SenderId: "222@s.whatsapp.net",
SessionKey: "agent:main:whatsapp:group:123@g.us",
},
cfg,
commandAuthorized: true,
});
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp 123@g.us] ping\n[from: Bob (+222)]");
});
it("does not inject sender meta for direct chats", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-direct-"));
const storePath = path.join(root, "sessions.json");
const cfg = { session: { store: storePath } } as ClawdbotConfig;
const result = await initSessionState({
ctx: {
Body: "[WhatsApp +1] ping",
ChatType: "direct",
SenderName: "Bob",
SenderE164: "+222",
SessionKey: "agent:main:whatsapp:dm:+222",
},
cfg,
commandAuthorized: true,
});
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp +1] ping");
});
});
describe("mention helpers", () => {
it("builds regexes and skips invalid patterns", () => {
const regexes = buildMentionRegexes({
messages: {
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
},
});
expect(regexes).toHaveLength(1);
expect(regexes[0]?.test("clawd")).toBe(true);
});
it("normalizes zero-width characters", () => {
expect(normalizeMentionText("cl\u200bawd")).toBe("clawd");
});
it("matches patterns case-insensitively", () => {
const regexes = buildMentionRegexes({
messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
});
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
});
it("uses per-agent mention patterns when configured", () => {
const regexes = buildMentionRegexes(
{
messages: {
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
},
agents: {
list: [
{
id: "work",
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
},
],
},
},
"work",
);
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
});
});
describe("resolveGroupRequireMention", () => {
it("respects Discord guild/channel requireMention settings", () => {
const cfg: ClawdbotConfig = {
channels: {
discord: {
guilds: {
"145": {
requireMention: false,
channels: {
general: { allow: true },
},
},
},
},
},
};
const ctx: TemplateContext = {
Provider: "discord",
From: "discord:group:123",
GroupChannel: "#general",
GroupSpace: "145",
};
const groupResolution: GroupKeyResolution = {
channel: "discord",
id: "123",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
it("respects Slack channel requireMention settings", () => {
const cfg: ClawdbotConfig = {
channels: {
slack: {
channels: {
C123: { requireMention: false },
},
},
},
};
const ctx: TemplateContext = {
Provider: "slack",
From: "slack:channel:C123",
GroupSubject: "#general",
};
const groupResolution: GroupKeyResolution = {
channel: "slack",
id: "C123",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
});
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff. Show more