Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
865e84d78b | ||
|
|
7d7f376a16 | ||
|
|
7529800dc9 | ||
|
|
c3b025db26 | ||
|
|
5893e8c759 | ||
|
|
744852d313 |
No files matched your search
@@ -23,14 +23,13 @@
|
|||||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
- 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)
|
## exe.dev VM ops (general)
|
||||||
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
|
- Access: SSH to the VM directly: `ssh vm-name.exe.xyz` (or use exe.dev web terminal).
|
||||||
- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops.
|
- Updates: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
|
||||||
- Update: `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.
|
||||||
- Config: use `clawdbot config set ...`; ensure `gateway.mode=local` is set.
|
- Restart: exe.dev often lacks systemd user bus; stop old gateway and run:
|
||||||
- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix).
|
|
||||||
- Restart: 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 &`
|
`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
|
## Build, Test, and Development Commands
|
||||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||||
|
|||||||
+23
-1
@@ -2,21 +2,43 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.23
|
## 2026.1.23 (Unreleased)
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
|
||||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
- 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.
|
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
||||||
|
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
|
||||||
|
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
|
||||||
|
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.
|
||||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||||
|
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
||||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
||||||
|
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
|
||||||
|
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.
|
||||||
|
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
|
||||||
|
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
|
||||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
- 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.
|
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
||||||
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
||||||
- TUI: include Gateway slash commands in autocomplete and `/help`.
|
- TUI: include Gateway slash commands in autocomplete and `/help`.
|
||||||
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
|
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
|
||||||
- CLI: suppress diagnostic session/run noise during auth probes.
|
- CLI: suppress diagnostic session/run noise during auth probes.
|
||||||
|
- CLI: hide auth probe timeout warnings from embedded runs.
|
||||||
|
- CLI: render auth probe results as a table in `clawdbot models status`.
|
||||||
|
- CLI: suppress probe-only embedded logs unless `--verbose` is set.
|
||||||
|
- CLI: move auth probe errors below the table to reduce wrapping.
|
||||||
|
- CLI: prevent ANSI color bleed when table cells wrap.
|
||||||
|
- CLI: explain when auth profiles are excluded by auth.order in probe details.
|
||||||
|
- CLI: drop the em dash when the banner tagline wraps to a second line.
|
||||||
|
- CLI: inline auth probe errors in status rows to reduce wrapping.
|
||||||
|
- Telegram: render markdown in media captions. (#1478)
|
||||||
|
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.
|
||||||
|
- Daemon: use platform PATH delimiters when building minimal service paths.
|
||||||
|
- Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.
|
||||||
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
|
- 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`).
|
- 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.
|
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "com.clawdbot.android"
|
applicationId = "com.clawdbot.android"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 202601230
|
versionCode = 202601210
|
||||||
versionName = "2026.1.23"
|
versionName = "2026.1.21"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -8,14 +8,10 @@ object WakeWords {
|
|||||||
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
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> {
|
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
||||||
val cleaned =
|
val cleaned =
|
||||||
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
||||||
return cleaned.ifEmpty { defaults }
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
@@ -51,10 +49,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -63,7 +58,6 @@ import com.clawdbot.android.LocationMode
|
|||||||
import com.clawdbot.android.MainViewModel
|
import com.clawdbot.android.MainViewModel
|
||||||
import com.clawdbot.android.NodeForegroundService
|
import com.clawdbot.android.NodeForegroundService
|
||||||
import com.clawdbot.android.VoiceWakeMode
|
import com.clawdbot.android.VoiceWakeMode
|
||||||
import com.clawdbot.android.WakeWords
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsSheet(viewModel: MainViewModel) {
|
fun SettingsSheet(viewModel: MainViewModel) {
|
||||||
@@ -92,8 +86,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||||
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
var wakeWordsHadFocus by remember { mutableStateOf(false) }
|
|
||||||
val deviceModel =
|
val deviceModel =
|
||||||
remember {
|
remember {
|
||||||
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||||
@@ -112,12 +104,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||||
val commitWakeWords = {
|
|
||||||
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
|
|
||||||
if (parsed != null) {
|
|
||||||
viewModel.setWakeWords(parsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val permissionLauncher =
|
val permissionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||||
@@ -495,27 +481,25 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
value = wakeWordsText,
|
value = wakeWordsText,
|
||||||
onValueChange = setWakeWordsText,
|
onValueChange = setWakeWordsText,
|
||||||
label = { Text("Wake Words (comma-separated)") },
|
label = { Text("Wake Words (comma-separated)") },
|
||||||
modifier =
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Modifier.fillMaxWidth().onFocusChanged { focusState ->
|
|
||||||
if (focusState.isFocused) {
|
|
||||||
wakeWordsHadFocus = true
|
|
||||||
} else if (wakeWordsHadFocus) {
|
|
||||||
wakeWordsHadFocus = false
|
|
||||||
commitWakeWords()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
singleLine = true,
|
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 {
|
item {
|
||||||
Text(
|
Text(
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.clawdbot.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class WakeWordsTest {
|
class WakeWordsTest {
|
||||||
@@ -33,18 +32,5 @@ class WakeWordsTest {
|
|||||||
assertEquals("w1", sanitized.first())
|
assertEquals("w1", sanitized.first())
|
||||||
assertEquals("w${WakeWords.maxWords}", sanitized.last())
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.23</string>
|
<string>2026.1.21</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260123</string>
|
<string>20260121</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
|
||||||
|
|
||||||
struct VoiceWakeWordsSettingsView: View {
|
struct VoiceWakeWordsSettingsView: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel
|
@Environment(NodeAppModel.self) private var appModel
|
||||||
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
||||||
@FocusState private var focusedTriggerIndex: Int?
|
|
||||||
@State private var syncTask: Task<Void, Never>?
|
@State private var syncTask: Task<Void, Never>?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -14,10 +12,6 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
TextField("Wake word", text: self.binding(for: index))
|
TextField("Wake word", text: self.binding(for: index))
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.focused(self.$focusedTriggerIndex, equals: index)
|
|
||||||
.onSubmit {
|
|
||||||
self.commitTriggerWords()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onDelete(perform: self.removeWords)
|
.onDelete(perform: self.removeWords)
|
||||||
|
|
||||||
@@ -45,18 +39,17 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
if self.triggerWords.isEmpty {
|
if self.triggerWords.isEmpty {
|
||||||
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||||
self.commitTriggerWords()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.focusedTriggerIndex) { oldValue, newValue in
|
.onChange(of: self.triggerWords) { _, newValue in
|
||||||
guard oldValue != nil, oldValue != newValue else { return }
|
// Keep local voice wake responsive even if the gateway isn't connected yet.
|
||||||
self.commitTriggerWords()
|
VoiceWakePreferences.saveTriggerWords(newValue)
|
||||||
}
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
|
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)
|
||||||
guard self.focusedTriggerIndex == nil else { return }
|
self.syncTask?.cancel()
|
||||||
let updated = VoiceWakePreferences.loadTriggerWords()
|
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
|
||||||
if updated != self.triggerWords {
|
try? await Task.sleep(nanoseconds: 650_000_000)
|
||||||
self.triggerWords = updated
|
await appModel?.setGlobalWakeWords(snapshot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +63,6 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
if self.triggerWords.isEmpty {
|
if self.triggerWords.isEmpty {
|
||||||
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||||
}
|
}
|
||||||
self.commitTriggerWords()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func binding(for index: Int) -> Binding<String> {
|
private func binding(for index: Int) -> Binding<String> {
|
||||||
@@ -84,15 +76,4 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
self.triggerWords[index] = newValue
|
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.
|
// Keep defaults aligned with the mac app.
|
||||||
static let defaultTriggerWords: [String] = ["clawd", "claude"]
|
static let defaultTriggerWords: [String] = ["clawd", "claude"]
|
||||||
static let maxWords = 32
|
|
||||||
static let maxWordLength = 64
|
|
||||||
|
|
||||||
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
|
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
|
||||||
guard let data = payloadJSON.data(using: .utf8) else { return nil }
|
guard let data = payloadJSON.data(using: .utf8) else { return nil }
|
||||||
@@ -32,8 +30,6 @@ enum VoiceWakePreferences {
|
|||||||
let cleaned = words
|
let cleaned = words
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
.prefix(Self.maxWords)
|
|
||||||
.map { String($0.prefix(Self.maxWordLength)) }
|
|
||||||
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
|
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>BNDL</string>
|
<string>BNDL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.23</string>
|
<string>2026.1.21</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260123</string>
|
<string>20260121</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -11,18 +11,6 @@ import Testing
|
|||||||
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
|
#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() {
|
@Test func displayStringUsesSanitizedWords() {
|
||||||
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
|
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Clawdbot
|
CFBundleDisplayName: Clawdbot
|
||||||
CFBundleIconName: AppIcon
|
CFBundleIconName: AppIcon
|
||||||
CFBundleShortVersionString: "2026.1.23"
|
CFBundleShortVersionString: "2026.1.21"
|
||||||
CFBundleVersion: "20260123"
|
CFBundleVersion: "20260121"
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UIApplicationSceneManifest:
|
UIApplicationSceneManifest:
|
||||||
UIApplicationSupportsMultipleScenes: false
|
UIApplicationSupportsMultipleScenes: false
|
||||||
@@ -130,5 +130,5 @@ targets:
|
|||||||
path: Tests/Info.plist
|
path: Tests/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: ClawdbotTests
|
CFBundleDisplayName: ClawdbotTests
|
||||||
CFBundleShortVersionString: "2026.1.23"
|
CFBundleShortVersionString: "2026.1.21"
|
||||||
CFBundleVersion: "20260123"
|
CFBundleVersion: "20260121"
|
||||||
@@ -12,8 +12,6 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime"
|
|||||||
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
|
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
|
||||||
let showDockIconKey = "clawdbot.showDockIcon"
|
let showDockIconKey = "clawdbot.showDockIcon"
|
||||||
let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
||||||
let voiceWakeMaxWords = 32
|
|
||||||
let voiceWakeMaxWordLength = 64
|
|
||||||
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
|
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
|
||||||
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
|
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
|
||||||
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
|
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.23</string>
|
<string>2026.1.21</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601230</string>
|
<string>202601210</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>Clawdbot</string>
|
<string>Clawdbot</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] {
|
|||||||
let cleaned = words
|
let cleaned = words
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
.prefix(voiceWakeMaxWords)
|
|
||||||
.map { String($0.prefix(voiceWakeMaxWordLength)) }
|
|
||||||
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
|
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,18 +12,6 @@ struct VoiceWakeHelpersTests {
|
|||||||
#expect(cleaned == defaultVoiceWakeTriggers)
|
#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() {
|
@Test func normalizeLocaleStripsCollation() {
|
||||||
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
|
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,10 @@ If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
|
|||||||
agent to read it. Think of it as your “heartbeat checklist”: small, stable, and
|
agent to read it. Think of it as your “heartbeat checklist”: small, stable, and
|
||||||
safe to include every 30 minutes.
|
safe to include every 30 minutes.
|
||||||
|
|
||||||
|
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
|
||||||
|
headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
|
||||||
|
If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||||
|
|
||||||
Keep it tiny (short checklist or reminders) to avoid prompt bloat.
|
Keep it tiny (short checklist or reminders) to avoid prompt bloat.
|
||||||
|
|
||||||
Example `HEARTBEAT.md`:
|
Example `HEARTBEAT.md`:
|
||||||
|
|||||||
@@ -43,14 +43,6 @@ Almost always a Node/npm PATH issue. Start here:
|
|||||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||||
- [Control UI](/web/control-ui#insecure-http)
|
- [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
|
### Service says running, but RPC probe fails
|
||||||
|
|
||||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||||
|
|||||||
@@ -30,17 +30,17 @@ Notes:
|
|||||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||||
BUNDLE_ID=com.clawdbot.mac \
|
BUNDLE_ID=com.clawdbot.mac \
|
||||||
APP_VERSION=2026.1.23 \
|
APP_VERSION=2026.1.21 \
|
||||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||||
BUILD_CONFIG=release \
|
BUILD_CONFIG=release \
|
||||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||||
scripts/package-mac-app.sh
|
scripts/package-mac-app.sh
|
||||||
|
|
||||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
# 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)
|
# 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
|
# Recommended: build + notarize/staple zip + DMG
|
||||||
# First, create a keychain profile once:
|
# 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>"
|
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||||
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
||||||
BUNDLE_ID=com.clawdbot.mac \
|
BUNDLE_ID=com.clawdbot.mac \
|
||||||
APP_VERSION=2026.1.23 \
|
APP_VERSION=2026.1.21 \
|
||||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||||
BUILD_CONFIG=release \
|
BUILD_CONFIG=release \
|
||||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||||
scripts/package-mac-dist.sh
|
scripts/package-mac-dist.sh
|
||||||
|
|
||||||
# Optional: ship dSYM alongside the release
|
# 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
|
## Appcast entry
|
||||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||||
```bash
|
```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.
|
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.
|
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
||||||
|
|
||||||
## Publish & verify
|
## 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`.
|
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
||||||
- Sanity checks:
|
- Sanity checks:
|
||||||
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ read_when:
|
|||||||
---
|
---
|
||||||
# HEARTBEAT.md
|
# HEARTBEAT.md
|
||||||
|
|
||||||
Keep this file empty unless you want a tiny checklist. Keep it small.
|
# Keep this file empty (or with only comments) to skip heartbeat API calls.
|
||||||
|
# Add tasks below when you want the agent to check something periodically.
|
||||||
@@ -182,6 +182,8 @@ By default, Clawdbot runs a heartbeat every 30 minutes with the prompt:
|
|||||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||||
Set `agents.defaults.heartbeat.every: "0m"` to disable.
|
Set `agents.defaults.heartbeat.every: "0m"` to disable.
|
||||||
|
|
||||||
|
- If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
|
||||||
|
- If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||||
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
|
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
|
||||||
- Heartbeats run full agent turns — shorter intervals burn more tokens.
|
- Heartbeats run full agent turns — shorter intervals burn more tokens.
|
||||||
|
|
||||||
|
|||||||
@@ -971,6 +971,10 @@ Heartbeats run every **30m** by default. Tune or disable them:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
|
||||||
|
headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
|
||||||
|
If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||||
|
|
||||||
Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat).
|
Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat).
|
||||||
|
|
||||||
### Do I need to add a “bot account” to a WhatsApp group?
|
### Do I need to add a “bot account” to a WhatsApp group?
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/bluebubbles",
|
"name": "@clawdbot/bluebubbles",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot BlueBubbles channel plugin",
|
"description": "Clawdbot BlueBubbles channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
import { removeAckReactionAfterReply, shouldAckReaction } from "clawdbot/plugin-sdk";
|
|
||||||
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
handleBlueBubblesWebhookRequest,
|
handleBlueBubblesWebhookRequest,
|
||||||
@@ -129,7 +128,6 @@ function createMockRuntime(): PluginRuntime {
|
|||||||
session: {
|
session: {
|
||||||
resolveStorePath: mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
|
resolveStorePath: mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
|
||||||
readSessionUpdatedAt: mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
|
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"],
|
recordSessionMetaFromInbound: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
|
||||||
updateLastRoute: vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
|
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"],
|
buildMentionRegexes: mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
|
||||||
matchesMentionPatterns: mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
|
matchesMentionPatterns: mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
|
||||||
},
|
},
|
||||||
reactions: {
|
|
||||||
shouldAckReaction,
|
|
||||||
removeAckReactionAfterReply,
|
|
||||||
},
|
|
||||||
groups: {
|
groups: {
|
||||||
resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
|
resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
|
||||||
resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
|
resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
import {
|
import { resolveAckReaction } from "clawdbot/plugin-sdk";
|
||||||
logAckFailure,
|
|
||||||
logInboundDrop,
|
|
||||||
logTypingFailure,
|
|
||||||
resolveAckReaction,
|
|
||||||
resolveControlCommandGate,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
|
||||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||||
@@ -1352,25 +1346,23 @@ async function processMessage(
|
|||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
|
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
|
||||||
const commandGate = resolveControlCommandGate({
|
const commandAuthorized = isGroup
|
||||||
useAccessGroups,
|
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||||
authorizers: [
|
useAccessGroups,
|
||||||
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
authorizers: [
|
||||||
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
||||||
],
|
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
||||||
allowTextCommands: true,
|
],
|
||||||
hasControlCommand: hasControlCmd,
|
})
|
||||||
});
|
: dmAuthorized;
|
||||||
const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
|
|
||||||
|
|
||||||
// Block control commands from unauthorized senders in groups
|
// Block control commands from unauthorized senders in groups
|
||||||
if (isGroup && commandGate.shouldBlock) {
|
if (isGroup && hasControlCmd && !commandAuthorized) {
|
||||||
logInboundDrop({
|
logVerbose(
|
||||||
log: (msg) => logVerbose(core, runtime, msg),
|
core,
|
||||||
channel: "bluebubbles",
|
runtime,
|
||||||
reason: "control command (unauthorized)",
|
`bluebubbles: drop control command from unauthorized sender ${message.senderId}`,
|
||||||
target: message.senderId,
|
);
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1529,20 +1521,19 @@ async function processMessage(
|
|||||||
core,
|
core,
|
||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
const shouldAckReaction = () =>
|
const shouldAckReaction = () => {
|
||||||
Boolean(
|
if (!ackReactionValue) return false;
|
||||||
ackReactionValue &&
|
if (ackReactionScope === "all") return true;
|
||||||
core.channel.reactions.shouldAckReaction({
|
if (ackReactionScope === "direct") return !isGroup;
|
||||||
scope: ackReactionScope,
|
if (ackReactionScope === "group-all") return isGroup;
|
||||||
isDirect: !isGroup,
|
if (ackReactionScope === "group-mentions") {
|
||||||
isGroup,
|
if (!isGroup) return false;
|
||||||
isMentionableGroup: isGroup,
|
if (!requireMention) return false;
|
||||||
requireMention: Boolean(requireMention),
|
if (!canDetectMention) return false;
|
||||||
canDetectMention,
|
return effectiveWasMentioned;
|
||||||
effectiveWasMentioned,
|
}
|
||||||
shouldBypassMention,
|
return false;
|
||||||
}),
|
};
|
||||||
);
|
|
||||||
const ackMessageId = message.messageId?.trim() || "";
|
const ackMessageId = message.messageId?.trim() || "";
|
||||||
const ackReactionPromise =
|
const ackReactionPromise =
|
||||||
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
|
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
|
||||||
@@ -1758,27 +1749,29 @@ async function processMessage(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (sentMessage && chatGuidForActions && ackMessageId) {
|
if (
|
||||||
core.channel.reactions.removeAckReactionAfterReply({
|
removeAckAfterReply &&
|
||||||
removeAfterReply: removeAckAfterReply,
|
sentMessage &&
|
||||||
ackReactionPromise,
|
ackReactionPromise &&
|
||||||
ackReactionValue: ackReactionValue ?? null,
|
ackReactionValue &&
|
||||||
remove: () =>
|
chatGuidForActions &&
|
||||||
sendBlueBubblesReaction({
|
ackMessageId
|
||||||
chatGuid: chatGuidForActions,
|
) {
|
||||||
messageGuid: ackMessageId,
|
void ackReactionPromise.then((didAck) => {
|
||||||
emoji: ackReactionValue ?? "",
|
if (!didAck) return;
|
||||||
remove: true,
|
sendBlueBubblesReaction({
|
||||||
opts: { cfg: config, accountId: account.accountId },
|
chatGuid: chatGuidForActions,
|
||||||
}),
|
messageGuid: ackMessageId,
|
||||||
onError: (err) => {
|
emoji: ackReactionValue,
|
||||||
logAckFailure({
|
remove: true,
|
||||||
log: (msg) => logVerbose(core, runtime, msg),
|
opts: { cfg: config, accountId: account.accountId },
|
||||||
channel: "bluebubbles",
|
}).catch((err) => {
|
||||||
target: `${chatGuidForActions}/${ackMessageId}`,
|
logVerbose(
|
||||||
error: err,
|
core,
|
||||||
});
|
runtime,
|
||||||
},
|
`ack reaction removal failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (chatGuidForActions && baseUrl && password && !sentMessage) {
|
if (chatGuidForActions && baseUrl && password && !sentMessage) {
|
||||||
@@ -1787,13 +1780,7 @@ async function processMessage(
|
|||||||
cfg: config,
|
cfg: config,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
logTypingFailure({
|
logVerbose(core, runtime, `typing stop (no reply) failed: ${String(err)}`);
|
||||||
log: (msg) => logVerbose(core, runtime, msg),
|
|
||||||
channel: "bluebubbles",
|
|
||||||
action: "stop",
|
|
||||||
target: chatGuidForActions,
|
|
||||||
error: err,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/copilot-proxy",
|
"name": "@clawdbot/copilot-proxy",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Copilot Proxy provider plugin",
|
"description": "Clawdbot Copilot Proxy provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/diagnostics-otel",
|
"name": "@clawdbot/diagnostics-otel",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/discord",
|
"name": "@clawdbot/discord",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Discord channel plugin",
|
"description": "Clawdbot Discord channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/google-antigravity-auth",
|
"name": "@clawdbot/google-antigravity-auth",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/google-gemini-cli-auth",
|
"name": "@clawdbot/google-gemini-cli-auth",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/imessage",
|
"name": "@clawdbot/imessage",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot iMessage channel plugin",
|
"description": "Clawdbot iMessage channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/lobster",
|
"name": "@clawdbot/lobster",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.23
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Version alignment with core Clawdbot release numbers.
|
|
||||||
|
|
||||||
## 2026.1.22
|
## 2026.1.22
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/matrix",
|
"name": "@clawdbot/matrix",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Matrix channel plugin",
|
"description": "Clawdbot Matrix channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk";
|
import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createReplyPrefixContext,
|
|
||||||
createTypingCallbacks,
|
|
||||||
formatAllowlistMatchMeta,
|
formatAllowlistMatchMeta,
|
||||||
logInboundDrop,
|
|
||||||
logTypingFailure,
|
|
||||||
resolveControlCommandGate,
|
|
||||||
type RuntimeEnv,
|
type RuntimeEnv,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
||||||
@@ -381,25 +376,21 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
userName: senderName,
|
userName: senderName,
|
||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
|
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||||
const commandGate = resolveControlCommandGate({
|
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
authorizers: [
|
authorizers: [
|
||||||
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||||
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
|
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
|
||||||
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
|
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
|
||||||
],
|
],
|
||||||
allowTextCommands,
|
|
||||||
hasControlCommand: hasControlCommandInMessage,
|
|
||||||
});
|
});
|
||||||
const commandAuthorized = commandGate.commandAuthorized;
|
if (
|
||||||
if (isRoom && commandGate.shouldBlock) {
|
isRoom &&
|
||||||
logInboundDrop({
|
allowTextCommands &&
|
||||||
log: logVerboseMessage,
|
core.channel.text.hasControlCommand(bodyText, cfg) &&
|
||||||
channel: "matrix",
|
!commandAuthorized
|
||||||
reason: "control command (unauthorized)",
|
) {
|
||||||
target: senderId,
|
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const shouldRequireMention = isRoom
|
const shouldRequireMention = isRoom
|
||||||
@@ -418,8 +409,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
!wasMentioned &&
|
!wasMentioned &&
|
||||||
!hasExplicitMention &&
|
!hasExplicitMention &&
|
||||||
commandAuthorized &&
|
commandAuthorized &&
|
||||||
hasControlCommandInMessage;
|
core.channel.text.hasControlCommand(bodyText);
|
||||||
const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
|
|
||||||
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
|
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
|
||||||
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
|
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
|
||||||
return;
|
return;
|
||||||
@@ -496,45 +486,47 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
OriginatingTo: `room:${roomId}`,
|
OriginatingTo: `room:${roomId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await core.channel.session.recordInboundSession({
|
void core.channel.session
|
||||||
storePath,
|
.recordSessionMetaFromInbound({
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
storePath,
|
||||||
ctx: ctxPayload,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
updateLastRoute: isDirectMessage
|
ctx: ctxPayload,
|
||||||
? {
|
})
|
||||||
sessionKey: route.mainSessionKey,
|
.catch((err) => {
|
||||||
channel: "matrix",
|
|
||||||
to: `room:${roomId}`,
|
|
||||||
accountId: route.accountId,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onRecordError: (err) => {
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
|
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
|
||||||
"failed updating session meta",
|
"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");
|
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||||
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
||||||
|
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||||
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||||
const shouldAckReaction = () =>
|
const shouldAckReaction = () => {
|
||||||
Boolean(
|
if (!ackReaction) return false;
|
||||||
ackReaction &&
|
if (ackScope === "all") return true;
|
||||||
core.channel.reactions.shouldAckReaction({
|
if (ackScope === "direct") return isDirectMessage;
|
||||||
scope: ackScope,
|
if (ackScope === "group-all") return isRoom;
|
||||||
isDirect: isDirectMessage,
|
if (ackScope === "group-mentions") {
|
||||||
isGroup: isRoom,
|
if (!isRoom) return false;
|
||||||
isMentionableGroup: isRoom,
|
if (!shouldRequireMention) return false;
|
||||||
requireMention: Boolean(shouldRequireMention),
|
return wasMentioned || shouldBypassMention;
|
||||||
canDetectMention,
|
}
|
||||||
effectiveWasMentioned: wasMentioned || shouldBypassMention,
|
return false;
|
||||||
shouldBypassMention,
|
};
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (shouldAckReaction() && messageId) {
|
if (shouldAckReaction() && messageId) {
|
||||||
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
|
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
|
||||||
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
|
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
|
||||||
@@ -561,33 +553,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
accountId: route.accountId,
|
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 } =
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
responsePrefix: prefixContext.responsePrefix,
|
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
.responsePrefix,
|
||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
await deliverMatrixReplies({
|
await deliverMatrixReplies({
|
||||||
@@ -606,8 +575,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
|
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
|
||||||
},
|
},
|
||||||
onReplyStart: typingCallbacks.onReplyStart,
|
onReplyStart: () =>
|
||||||
onIdle: typingCallbacks.onIdle,
|
sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
|
||||||
|
onIdle: () =>
|
||||||
|
sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||||
@@ -617,7 +588,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
replyOptions: {
|
replyOptions: {
|
||||||
...replyOptions,
|
...replyOptions,
|
||||||
skillFilter: roomConfig?.skills,
|
skillFilter: roomConfig?.skills,
|
||||||
onModelSelected: prefixContext.onModelSelected,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
markDispatchIdle();
|
markDispatchIdle();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/mattermost",
|
"name": "@clawdbot/mattermost",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Mattermost channel plugin",
|
"description": "Clawdbot Mattermost channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -7,15 +7,10 @@ import type {
|
|||||||
RuntimeEnv,
|
RuntimeEnv,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
createReplyPrefixContext,
|
|
||||||
createTypingCallbacks,
|
|
||||||
logInboundDrop,
|
|
||||||
logTypingFailure,
|
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
clearHistoryEntriesIfEnabled,
|
clearHistoryEntries,
|
||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
recordPendingHistoryEntryIfEnabled,
|
recordPendingHistoryEntry,
|
||||||
resolveControlCommandGate,
|
|
||||||
resolveChannelMediaMaxBytes,
|
resolveChannelMediaMaxBytes,
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
@@ -35,9 +30,12 @@ import {
|
|||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
import {
|
import {
|
||||||
createDedupeCache,
|
createDedupeCache,
|
||||||
|
extractShortModelName,
|
||||||
formatInboundFromLabel,
|
formatInboundFromLabel,
|
||||||
rawDataToString,
|
rawDataToString,
|
||||||
|
resolveIdentityName,
|
||||||
resolveThreadSessionKeys,
|
resolveThreadSessionKeys,
|
||||||
|
type ResponsePrefixContext,
|
||||||
} from "./monitor-helpers.js";
|
} from "./monitor-helpers.js";
|
||||||
import { sendMessageMattermost } from "./send.js";
|
import { sendMessageMattermost } from "./send.js";
|
||||||
|
|
||||||
@@ -309,7 +307,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sendTypingIndicator = async (channelId: string, parentId?: string) => {
|
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> => {
|
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
|
||||||
@@ -401,8 +403,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
cfg,
|
cfg,
|
||||||
surface: "mattermost",
|
surface: "mattermost",
|
||||||
});
|
});
|
||||||
const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg);
|
const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
|
||||||
const isControlCommand = allowTextCommands && hasControlCommand;
|
|
||||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||||
const senderAllowedForCommands = isSenderAllowed({
|
const senderAllowedForCommands = isSenderAllowed({
|
||||||
senderId,
|
senderId,
|
||||||
@@ -414,20 +415,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
senderName,
|
senderName,
|
||||||
allowFrom: effectiveGroupAllowFrom,
|
allowFrom: effectiveGroupAllowFrom,
|
||||||
});
|
});
|
||||||
const commandGate = resolveControlCommandGate({
|
|
||||||
useAccessGroups,
|
|
||||||
authorizers: [
|
|
||||||
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
|
||||||
{
|
|
||||||
configured: effectiveGroupAllowFrom.length > 0,
|
|
||||||
allowed: groupAllowedForCommands,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
allowTextCommands,
|
|
||||||
hasControlCommand,
|
|
||||||
});
|
|
||||||
const commandAuthorized =
|
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 (kind === "dm") {
|
||||||
if (dmPolicy === "disabled") {
|
if (dmPolicy === "disabled") {
|
||||||
@@ -488,13 +488,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind !== "dm" && commandGate.shouldBlock) {
|
if (kind !== "dm" && isControlCommand && !commandAuthorized) {
|
||||||
logInboundDrop({
|
logVerboseMessage(
|
||||||
log: logVerboseMessage,
|
`mattermost: drop control command from unauthorized sender ${senderId}`,
|
||||||
channel: "mattermost",
|
);
|
||||||
reason: "control command (unauthorized)",
|
|
||||||
target: senderId,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,19 +534,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
: "");
|
: "");
|
||||||
const pendingSender = senderName;
|
const pendingSender = senderName;
|
||||||
const recordPendingHistory = () => {
|
const recordPendingHistory = () => {
|
||||||
|
if (!historyKey || historyLimit <= 0) return;
|
||||||
const trimmed = pendingBody.trim();
|
const trimmed = pendingBody.trim();
|
||||||
recordPendingHistoryEntryIfEnabled({
|
if (!trimmed) return;
|
||||||
|
recordPendingHistoryEntry({
|
||||||
historyMap: channelHistories,
|
historyMap: channelHistories,
|
||||||
|
historyKey,
|
||||||
limit: historyLimit,
|
limit: historyLimit,
|
||||||
historyKey: historyKey ?? "",
|
entry: {
|
||||||
entry: historyKey && trimmed
|
sender: pendingSender,
|
||||||
? {
|
body: trimmed,
|
||||||
sender: pendingSender,
|
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
||||||
body: trimmed,
|
messageId: post.id ?? undefined,
|
||||||
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
},
|
||||||
messageId: post.id ?? undefined,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -626,7 +623,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
sender: { name: senderName, id: senderId },
|
sender: { name: senderName, id: senderId },
|
||||||
});
|
});
|
||||||
let combinedBody = body;
|
let combinedBody = body;
|
||||||
if (historyKey) {
|
if (historyKey && historyLimit > 0) {
|
||||||
combinedBody = buildPendingHistoryContextFromMap({
|
combinedBody = buildPendingHistoryContextFromMap({
|
||||||
historyMap: channelHistories,
|
historyMap: channelHistories,
|
||||||
historyKey,
|
historyKey,
|
||||||
@@ -716,23 +713,15 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
accountId: account.accountId,
|
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 } =
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
responsePrefix: prefixContext.responsePrefix,
|
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
.responsePrefix,
|
||||||
|
responsePrefixContextProvider: () => prefixContext,
|
||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
deliver: async (payload: ReplyPayload) => {
|
deliver: async (payload: ReplyPayload) => {
|
||||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
@@ -763,7 +752,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
|
runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
|
||||||
},
|
},
|
||||||
onReplyStart: typingCallbacks.onReplyStart,
|
onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
|
||||||
});
|
});
|
||||||
|
|
||||||
await core.channel.reply.dispatchReplyFromConfig({
|
await core.channel.reply.dispatchReplyFromConfig({
|
||||||
@@ -774,12 +763,17 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
...replyOptions,
|
...replyOptions,
|
||||||
disableBlockStreaming:
|
disableBlockStreaming:
|
||||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
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();
|
markDispatchIdle();
|
||||||
if (historyKey) {
|
if (historyKey && historyLimit > 0) {
|
||||||
clearHistoryEntriesIfEnabled({ historyMap: channelHistories, historyKey, limit: historyLimit });
|
clearHistoryEntries({ historyMap: channelHistories, historyKey });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/memory-core",
|
"name": "@clawdbot/memory-core",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot core memory search plugin",
|
"description": "Clawdbot core memory search plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/memory-lancedb",
|
"name": "@clawdbot/memory-lancedb",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.23
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Version alignment with core Clawdbot release numbers.
|
|
||||||
|
|
||||||
## 2026.1.22
|
## 2026.1.22
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/msteams",
|
"name": "@clawdbot/msteams",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Microsoft Teams channel plugin",
|
"description": "Clawdbot Microsoft Teams channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ function scopeCandidatesForUrl(url: string): string[] {
|
|||||||
host.endsWith("1drv.ms") ||
|
host.endsWith("1drv.ms") ||
|
||||||
host.includes("sharepoint");
|
host.includes("sharepoint");
|
||||||
return looksLikeGraph
|
return looksLikeGraph
|
||||||
? ["https://graph.microsoft.com", "https://api.botframework.com"]
|
? ["https://graph.microsoft.com/.default", "https://api.botframework.com/.default"]
|
||||||
: ["https://api.botframework.com", "https://graph.microsoft.com"];
|
: ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"];
|
||||||
} catch {
|
} catch {
|
||||||
return ["https://api.botframework.com", "https://graph.microsoft.com"];
|
return ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|||||||
const messageUrl = params.messageUrl;
|
const messageUrl = params.messageUrl;
|
||||||
let accessToken: string;
|
let accessToken: string;
|
||||||
try {
|
try {
|
||||||
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com");
|
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
|
||||||
} catch {
|
} catch {
|
||||||
return { media: [], messageUrl, tokenError: true };
|
return { media: [], messageUrl, tokenError: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ async function resolveGraphToken(cfg: unknown): Promise<string> {
|
|||||||
if (!creds) throw new Error("MS Teams credentials missing");
|
if (!creds) throw new Error("MS Teams credentials missing");
|
||||||
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
||||||
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
|
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);
|
const accessToken = readAccessToken(token);
|
||||||
if (!accessToken) throw new Error("MS Teams graph token unavailable");
|
if (!accessToken) throw new Error("MS Teams graph token unavailable");
|
||||||
return accessToken;
|
return accessToken;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
|||||||
|
|
||||||
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
||||||
const GRAPH_BETA = "https://graph.microsoft.com/beta";
|
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 {
|
export interface OneDriveUploadResult {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
clearHistoryEntriesIfEnabled,
|
clearHistoryEntries,
|
||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
logInboundDrop,
|
recordPendingHistoryEntry,
|
||||||
recordPendingHistoryEntryIfEnabled,
|
|
||||||
resolveControlCommandGate,
|
|
||||||
resolveMentionGating,
|
resolveMentionGating,
|
||||||
formatAllowlistMatchMeta,
|
formatAllowlistMatchMeta,
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
@@ -253,24 +251,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
senderId,
|
senderId,
|
||||||
senderName,
|
senderName,
|
||||||
});
|
});
|
||||||
const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
|
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||||
const commandGate = resolveControlCommandGate({
|
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
authorizers: [
|
authorizers: [
|
||||||
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
||||||
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
||||||
],
|
],
|
||||||
allowTextCommands: true,
|
|
||||||
hasControlCommand: hasControlCommandInMessage,
|
|
||||||
});
|
});
|
||||||
const commandAuthorized = commandGate.commandAuthorized;
|
if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) {
|
||||||
if (commandGate.shouldBlock) {
|
logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`);
|
||||||
logInboundDrop({
|
|
||||||
log: logVerboseMessage,
|
|
||||||
channel: "msteams",
|
|
||||||
reason: "control command (unauthorized)",
|
|
||||||
target: senderId,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,17 +371,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
requireMention,
|
requireMention,
|
||||||
mentioned,
|
mentioned,
|
||||||
});
|
});
|
||||||
recordPendingHistoryEntryIfEnabled({
|
if (historyLimit > 0) {
|
||||||
historyMap: conversationHistories,
|
recordPendingHistoryEntry({
|
||||||
historyKey: conversationId,
|
historyMap: conversationHistories,
|
||||||
limit: historyLimit,
|
historyKey: conversationId,
|
||||||
entry: {
|
limit: historyLimit,
|
||||||
sender: senderName,
|
entry: {
|
||||||
body: rawBody,
|
sender: senderName,
|
||||||
timestamp: timestamp?.getTime(),
|
body: rawBody,
|
||||||
messageId: activity.id ?? undefined,
|
timestamp: timestamp?.getTime(),
|
||||||
},
|
messageId: activity.id ?? undefined,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,7 +426,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
let combinedBody = body;
|
let combinedBody = body;
|
||||||
const isRoomish = !isDirectMessage;
|
const isRoomish = !isDirectMessage;
|
||||||
const historyKey = isRoomish ? conversationId : undefined;
|
const historyKey = isRoomish ? conversationId : undefined;
|
||||||
if (isRoomish && historyKey) {
|
if (isRoomish && historyKey && historyLimit > 0) {
|
||||||
combinedBody = buildPendingHistoryContextFromMap({
|
combinedBody = buildPendingHistoryContextFromMap({
|
||||||
historyMap: conversationHistories,
|
historyMap: conversationHistories,
|
||||||
historyKey,
|
historyKey,
|
||||||
@@ -476,13 +467,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
...mediaPayload,
|
...mediaPayload,
|
||||||
});
|
});
|
||||||
|
|
||||||
await core.channel.session.recordInboundSession({
|
void core.channel.session.recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
onRecordError: (err) => {
|
}).catch((err) => {
|
||||||
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
|
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
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;
|
const didSendReply = counts.final + counts.tool + counts.block > 0;
|
||||||
if (!queuedFinal) {
|
if (!queuedFinal) {
|
||||||
if (isRoomish && historyKey) {
|
if (isRoomish && historyKey && historyLimit > 0) {
|
||||||
clearHistoryEntriesIfEnabled({
|
clearHistoryEntries({
|
||||||
historyMap: conversationHistories,
|
historyMap: conversationHistories,
|
||||||
historyKey,
|
historyKey,
|
||||||
limit: historyLimit,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -535,12 +524,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
logVerboseMessage(
|
logVerboseMessage(
|
||||||
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
||||||
);
|
);
|
||||||
if (isRoomish && historyKey) {
|
if (isRoomish && historyKey && historyLimit > 0) {
|
||||||
clearHistoryEntriesIfEnabled({
|
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
|
||||||
historyMap: conversationHistories,
|
|
||||||
historyKey,
|
|
||||||
limit: historyLimit,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("dispatch failed", { error: String(err) });
|
log.error("dispatch failed", { error: String(err) });
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
createReplyPrefixContext,
|
|
||||||
createTypingCallbacks,
|
|
||||||
logTypingFailure,
|
|
||||||
resolveChannelMediaMaxBytes,
|
resolveChannelMediaMaxBytes,
|
||||||
type ClawdbotConfig,
|
type ClawdbotConfig,
|
||||||
type MSTeamsReplyStyle,
|
type MSTeamsReplyStyle,
|
||||||
@@ -42,33 +39,23 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
}) {
|
}) {
|
||||||
const core = getMSTeamsRuntime();
|
const core = getMSTeamsRuntime();
|
||||||
const sendTypingIndicator = async () => {
|
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 } =
|
return core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(
|
||||||
responsePrefix: prefixContext.responsePrefix,
|
params.cfg,
|
||||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
params.agentId,
|
||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
).responsePrefix,
|
||||||
deliver: async (payload) => {
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
deliver: async (payload) => {
|
||||||
cfg: params.cfg,
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
channel: "msteams",
|
cfg: params.cfg,
|
||||||
|
channel: "msteams",
|
||||||
});
|
});
|
||||||
const messages = renderReplyPayloadsToMessages([payload], {
|
const messages = renderReplyPayloadsToMessages([payload], {
|
||||||
textChunkLimit: params.textLimit,
|
textChunkLimit: params.textLimit,
|
||||||
@@ -100,27 +87,21 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
mediaMaxBytes,
|
mediaMaxBytes,
|
||||||
});
|
});
|
||||||
if (ids.length > 0) params.onSentMessageIds?.(ids);
|
if (ids.length > 0) params.onSentMessageIds?.(ids);
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
const errMsg = formatUnknownError(err);
|
const errMsg = formatUnknownError(err);
|
||||||
const classification = classifyMSTeamsSendError(err);
|
const classification = classifyMSTeamsSendError(err);
|
||||||
const hint = formatMSTeamsSendErrorHint(classification);
|
const hint = formatMSTeamsSendErrorHint(classification);
|
||||||
params.runtime.error?.(
|
params.runtime.error?.(
|
||||||
`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
|
`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
|
||||||
);
|
);
|
||||||
params.log.error("reply failed", {
|
params.log.error("reply failed", {
|
||||||
kind: info.kind,
|
kind: info.kind,
|
||||||
error: errMsg,
|
error: errMsg,
|
||||||
classification,
|
classification,
|
||||||
hint,
|
hint,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onReplyStart: typingCallbacks.onReplyStart,
|
onReplyStart: sendTypingIndicator,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
|
||||||
dispatcher,
|
|
||||||
replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected },
|
|
||||||
markDispatchIdle,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ async function resolveGraphToken(cfg: unknown): Promise<string> {
|
|||||||
if (!creds) throw new Error("MS Teams credentials missing");
|
if (!creds) throw new Error("MS Teams credentials missing");
|
||||||
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
||||||
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
|
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);
|
const accessToken = readAccessToken(token);
|
||||||
if (!accessToken) throw new Error("MS Teams graph token unavailable");
|
if (!accessToken) throw new Error("MS Teams graph token unavailable");
|
||||||
return accessToken;
|
return accessToken;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/nextcloud-talk",
|
"name": "@clawdbot/nextcloud-talk",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Nextcloud Talk channel plugin",
|
"description": "Clawdbot Nextcloud Talk channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
logInboundDrop,
|
|
||||||
resolveControlCommandGate,
|
|
||||||
type ClawdbotConfig,
|
|
||||||
type RuntimeEnv,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
@@ -123,11 +118,7 @@ export async function handleNextcloudTalkInbound(params: {
|
|||||||
senderId,
|
senderId,
|
||||||
senderName,
|
senderName,
|
||||||
}).allowed;
|
}).allowed;
|
||||||
const hasControlCommand = core.channel.text.hasControlCommand(
|
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||||
rawBody,
|
|
||||||
config as ClawdbotConfig,
|
|
||||||
);
|
|
||||||
const commandGate = resolveControlCommandGate({
|
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
authorizers: [
|
authorizers: [
|
||||||
{
|
{
|
||||||
@@ -136,10 +127,7 @@ export async function handleNextcloudTalkInbound(params: {
|
|||||||
allowed: senderAllowedForCommands,
|
allowed: senderAllowedForCommands,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
allowTextCommands,
|
|
||||||
hasControlCommand,
|
|
||||||
});
|
});
|
||||||
const commandAuthorized = commandGate.commandAuthorized;
|
|
||||||
|
|
||||||
if (isGroup) {
|
if (isGroup) {
|
||||||
const groupAllow = resolveNextcloudTalkGroupAllow({
|
const groupAllow = resolveNextcloudTalkGroupAllow({
|
||||||
@@ -200,13 +188,15 @@ export async function handleNextcloudTalkInbound(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGroup && commandGate.shouldBlock) {
|
if (
|
||||||
logInboundDrop({
|
isGroup &&
|
||||||
log: (message) => runtime.log?.(message),
|
allowTextCommands &&
|
||||||
channel: CHANNEL_ID,
|
core.channel.text.hasControlCommand(rawBody, config as ClawdbotConfig) &&
|
||||||
reason: "control command (unauthorized)",
|
commandAuthorized !== true
|
||||||
target: senderId,
|
) {
|
||||||
});
|
runtime.log?.(
|
||||||
|
`nextcloud-talk: drop control command from unauthorized sender ${senderId}`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +212,10 @@ export async function handleNextcloudTalkInbound(params: {
|
|||||||
wildcardConfig: roomMatch.wildcardConfig,
|
wildcardConfig: roomMatch.wildcardConfig,
|
||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
|
const hasControlCommand = core.channel.text.hasControlCommand(
|
||||||
|
rawBody,
|
||||||
|
config as ClawdbotConfig,
|
||||||
|
);
|
||||||
const mentionGate = resolveNextcloudTalkMentionGate({
|
const mentionGate = resolveNextcloudTalkMentionGate({
|
||||||
isGroup,
|
isGroup,
|
||||||
requireMention: shouldRequireMention,
|
requireMention: shouldRequireMention,
|
||||||
@@ -293,14 +287,15 @@ export async function handleNextcloudTalkInbound(params: {
|
|||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
});
|
});
|
||||||
|
|
||||||
await core.channel.session.recordInboundSession({
|
void core.channel.session
|
||||||
storePath,
|
.recordSessionMetaFromInbound({
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
storePath,
|
||||||
ctx: ctxPayload,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
onRecordError: (err) => {
|
ctx: ctxPayload,
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
|
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.23
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Version alignment with core Clawdbot release numbers.
|
|
||||||
|
|
||||||
## 2026.1.22
|
## 2026.1.22
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/nostr",
|
"name": "@clawdbot/nostr",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
|
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/open-prose",
|
"name": "@clawdbot/open-prose",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": [
|
"extensions": ["./index.ts"]
|
||||||
"./index.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/signal",
|
"name": "@clawdbot/signal",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Signal channel plugin",
|
"description": "Clawdbot Signal channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/slack",
|
"name": "@clawdbot/slack",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Slack channel plugin",
|
"description": "Clawdbot Slack channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/telegram",
|
"name": "@clawdbot/telegram",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Telegram channel plugin",
|
"description": "Clawdbot Telegram channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.23
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Version alignment with core Clawdbot release numbers.
|
|
||||||
|
|
||||||
## 2026.1.22
|
## 2026.1.22
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/voice-call",
|
"name": "@clawdbot/voice-call",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot voice-call plugin",
|
"description": "Clawdbot voice-call plugin",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/whatsapp",
|
"name": "@clawdbot/whatsapp",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot WhatsApp channel plugin",
|
"description": "Clawdbot WhatsApp channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.23
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Version alignment with core Clawdbot release numbers.
|
|
||||||
|
|
||||||
## 2026.1.22
|
## 2026.1.22
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/zalo",
|
"name": "@clawdbot/zalo",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Zalo channel plugin",
|
"description": "Clawdbot Zalo channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -570,13 +570,12 @@ async function processMessageWithPipeline(params: {
|
|||||||
OriginatingTo: `zalo:${chatId}`,
|
OriginatingTo: `zalo:${chatId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await core.channel.session.recordInboundSession({
|
void core.channel.session.recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
onRecordError: (err) => {
|
}).catch((err) => {
|
||||||
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
|
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.23
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Version alignment with core Clawdbot release numbers.
|
|
||||||
|
|
||||||
## 2026.1.22
|
## 2026.1.22
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/zalouser",
|
"name": "@clawdbot/zalouser",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -311,13 +311,12 @@ async function processMessage(
|
|||||||
OriginatingTo: `zalouser:${chatId}`,
|
OriginatingTo: `zalouser:${chatId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await core.channel.session.recordInboundSession({
|
void core.channel.session.recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
onRecordError: (err) => {
|
}).catch((err) => {
|
||||||
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
|
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawdbot",
|
"name": "clawdbot",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
Generated
+2
@@ -376,6 +376,8 @@ importers:
|
|||||||
specifier: ^4.3.5
|
specifier: ^4.3.5
|
||||||
version: 4.3.5
|
version: 4.3.5
|
||||||
|
|
||||||
|
extensions/open-prose: {}
|
||||||
|
|
||||||
extensions/signal: {}
|
extensions/signal: {}
|
||||||
|
|
||||||
extensions/slack: {}
|
extensions/slack: {}
|
||||||
|
|||||||
@@ -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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI, ExtensionContext, FileOperations } from "@mariozechner/pi-coding-agent";
|
||||||
import {
|
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
|
||||||
BASE_CHUNK_RATIO,
|
|
||||||
MIN_CHUNK_RATIO,
|
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||||
SAFETY_MARGIN,
|
|
||||||
computeAdaptiveChunkRatio,
|
const BASE_CHUNK_RATIO = 0.4;
|
||||||
estimateMessagesTokens,
|
const MIN_CHUNK_RATIO = 0.15;
|
||||||
isOversizedForSummary,
|
const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
|
||||||
pruneHistoryForContextShare,
|
|
||||||
resolveContextWindowTokens,
|
|
||||||
summarizeInStages,
|
|
||||||
} from "../compaction.js";
|
|
||||||
const FALLBACK_SUMMARY =
|
const FALLBACK_SUMMARY =
|
||||||
"Summary unavailable due to context limits. Older messages were truncated.";
|
"Summary unavailable due to context limits. Older messages were truncated.";
|
||||||
const TURN_PREFIX_INSTRUCTIONS =
|
const TURN_PREFIX_INSTRUCTIONS =
|
||||||
@@ -133,6 +129,175 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[]): str
|
|||||||
return `\n\n${sections.join("\n\n")}`;
|
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 {
|
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||||
api.on("session_before_compact", async (event, ctx) => {
|
api.on("session_before_compact", async (event, ctx) => {
|
||||||
const { preparation, customInstructions, signal } = event;
|
const { preparation, customInstructions, signal } = event;
|
||||||
@@ -170,48 +335,19 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contextWindowTokens = resolveContextWindowTokens(model);
|
const contextWindowTokens = Math.max(
|
||||||
const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
|
1,
|
||||||
let messagesToSummarize = preparation.messagesToSummarize;
|
Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS),
|
||||||
|
);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use adaptive chunk ratio based on message sizes
|
// Use adaptive chunk ratio based on message sizes
|
||||||
const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
|
const allMessages = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
|
||||||
const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens);
|
const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens);
|
||||||
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio));
|
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio));
|
||||||
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
|
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
|
||||||
|
|
||||||
const historySummary = await summarizeInStages({
|
const historySummary = await summarizeWithFallback({
|
||||||
messages: messagesToSummarize,
|
messages: preparation.messagesToSummarize,
|
||||||
model,
|
model,
|
||||||
apiKey,
|
apiKey,
|
||||||
signal,
|
signal,
|
||||||
@@ -223,9 +359,9 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let summary = historySummary;
|
let summary = historySummary;
|
||||||
if (preparation.isSplitTurn && turnPrefixMessages.length > 0) {
|
if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) {
|
||||||
const prefixSummary = await summarizeInStages({
|
const prefixSummary = await summarizeWithFallback({
|
||||||
messages: turnPrefixMessages,
|
messages: preparation.turnPrefixMessages,
|
||||||
model,
|
model,
|
||||||
apiKey,
|
apiKey,
|
||||||
signal,
|
signal,
|
||||||
@@ -233,7 +369,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
|||||||
maxChunkTokens,
|
maxChunkTokens,
|
||||||
contextWindow: contextWindowTokens,
|
contextWindow: contextWindowTokens,
|
||||||
customInstructions: TURN_PREFIX_INSTRUCTIONS,
|
customInstructions: TURN_PREFIX_INSTRUCTIONS,
|
||||||
previousSummary: undefined,
|
|
||||||
});
|
});
|
||||||
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
|
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "./heartbeat.js";
|
import {
|
||||||
|
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||||
|
isHeartbeatContentEffectivelyEmpty,
|
||||||
|
stripHeartbeatToken,
|
||||||
|
} from "./heartbeat.js";
|
||||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||||
|
|
||||||
describe("stripHeartbeatToken", () => {
|
describe("stripHeartbeatToken", () => {
|
||||||
@@ -105,3 +109,76 @@ describe("stripHeartbeatToken", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isHeartbeatContentEffectivelyEmpty", () => {
|
||||||
|
it("returns false for undefined/null (missing file should not skip)", () => {
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(undefined)).toBe(false);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for empty string", () => {
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for whitespace only", () => {
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(" ")).toBe(true);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("\n\n\n")).toBe(true);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(" \n \n ")).toBe(true);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("\t\t")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for header-only content", () => {
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md")).toBe(true);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n")).toBe(true);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for comments only", () => {
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("# Header\n# Another comment")).toBe(true);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("## Subheader\n### Another")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for default template content (header + comment)", () => {
|
||||||
|
const defaultTemplate = `# HEARTBEAT.md
|
||||||
|
|
||||||
|
Keep this file empty unless you want a tiny checklist. Keep it small.
|
||||||
|
`;
|
||||||
|
// Note: The template has actual text content, so it's NOT effectively empty
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(defaultTemplate)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for header with only empty lines", () => {
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n\n")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when actionable content exists", () => {
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("- Check email")).toBe(false);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n- Task 1")).toBe(false);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("Remind me to call mom")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for content with tasks after header", () => {
|
||||||
|
const content = `# HEARTBEAT.md
|
||||||
|
|
||||||
|
- Task 1
|
||||||
|
- Task 2
|
||||||
|
`;
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for mixed content with non-comment text", () => {
|
||||||
|
const content = `# HEARTBEAT.md
|
||||||
|
## Tasks
|
||||||
|
Check the server logs
|
||||||
|
`;
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats markdown headers as comments (effectively empty)", () => {
|
||||||
|
const content = `# HEARTBEAT.md
|
||||||
|
## Section 1
|
||||||
|
### Subsection
|
||||||
|
`;
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,38 @@ export const HEARTBEAT_PROMPT =
|
|||||||
export const DEFAULT_HEARTBEAT_EVERY = "30m";
|
export const DEFAULT_HEARTBEAT_EVERY = "30m";
|
||||||
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
|
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if HEARTBEAT.md content is "effectively empty" - meaning it has no actionable tasks.
|
||||||
|
* This allows skipping heartbeat API calls when no tasks are configured.
|
||||||
|
*
|
||||||
|
* A file is considered effectively empty if it contains only:
|
||||||
|
* - Whitespace
|
||||||
|
* - Comment lines (lines starting with #)
|
||||||
|
* - Empty lines
|
||||||
|
*
|
||||||
|
* Note: A missing file returns false (not effectively empty) so the LLM can still
|
||||||
|
* decide what to do. This function is only for when the file exists but has no content.
|
||||||
|
*/
|
||||||
|
export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | null): boolean {
|
||||||
|
if (content === undefined || content === null) return false;
|
||||||
|
if (typeof content !== "string") return false;
|
||||||
|
|
||||||
|
const lines = content.split("\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
// Skip empty lines
|
||||||
|
if (!trimmed) continue;
|
||||||
|
// Skip markdown header lines (# followed by space or EOL, ## etc)
|
||||||
|
// This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content
|
||||||
|
// (Those aren't valid markdown headers - ATX headers require space after #)
|
||||||
|
if (/^#+(\s|$)/.test(trimmed)) continue;
|
||||||
|
// Found a non-empty, non-comment line - there's actionable content
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// All lines were either empty or comments
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveHeartbeatPrompt(raw?: string): string {
|
export function resolveHeartbeatPrompt(raw?: string): string {
|
||||||
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
||||||
return trimmed || HEARTBEAT_PROMPT;
|
return trimmed || HEARTBEAT_PROMPT;
|
||||||
|
|||||||
@@ -82,8 +82,7 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
// Track payloads sent directly (not via pipeline) during tool flush to avoid duplicates.
|
// Track payloads sent directly (not via pipeline) during tool flush to avoid duplicates.
|
||||||
const directlySentBlockKeys = new Set<string>();
|
const directlySentBlockKeys = new Set<string>();
|
||||||
|
|
||||||
const runId = params.opts?.runId ?? crypto.randomUUID();
|
const runId = crypto.randomUUID();
|
||||||
params.opts?.onAgentRunStart?.(runId);
|
|
||||||
if (params.sessionKey) {
|
if (params.sessionKey) {
|
||||||
registerAgentRunContext(runId, {
|
registerAgentRunContext(runId, {
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
@@ -175,7 +174,6 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
|
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
|
||||||
ownerNumbers: params.followupRun.run.ownerNumbers,
|
ownerNumbers: params.followupRun.run.ownerNumbers,
|
||||||
cliSessionId,
|
cliSessionId,
|
||||||
images: params.opts?.images,
|
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
@@ -250,8 +248,6 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
bashElevated: params.followupRun.run.bashElevated,
|
bashElevated: params.followupRun.run.bashElevated,
|
||||||
timeoutMs: params.followupRun.run.timeoutMs,
|
timeoutMs: params.followupRun.run.timeoutMs,
|
||||||
runId,
|
runId,
|
||||||
images: params.opts?.images,
|
|
||||||
abortSignal: params.opts?.abortSignal,
|
|
||||||
blockReplyBreak: params.resolvedBlockStreamingBreak,
|
blockReplyBreak: params.resolvedBlockStreamingBreak,
|
||||||
blockReplyChunking: params.blockReplyChunking,
|
blockReplyChunking: params.blockReplyChunking,
|
||||||
onPartialReply: allowPartialStream
|
onPartialReply: allowPartialStream
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import {
|
|||||||
buildHistoryContextFromEntries,
|
buildHistoryContextFromEntries,
|
||||||
buildHistoryContextFromMap,
|
buildHistoryContextFromMap,
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
clearHistoryEntriesIfEnabled,
|
|
||||||
HISTORY_CONTEXT_MARKER,
|
HISTORY_CONTEXT_MARKER,
|
||||||
recordPendingHistoryEntryIfEnabled,
|
|
||||||
} from "./history.js";
|
} from "./history.js";
|
||||||
import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
|
import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
|
||||||
|
|
||||||
@@ -107,46 +105,4 @@ describe("history helpers", () => {
|
|||||||
expect(result).toContain(CURRENT_MESSAGE_MARKER);
|
expect(result).toContain(CURRENT_MESSAGE_MARKER);
|
||||||
expect(result).toContain("current");
|
expect(result).toContain("current");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("records pending entries only when enabled", () => {
|
|
||||||
const historyMap = new Map<string, { sender: string; body: string }[]>();
|
|
||||||
|
|
||||||
recordPendingHistoryEntryIfEnabled({
|
|
||||||
historyMap,
|
|
||||||
historyKey: "group",
|
|
||||||
limit: 0,
|
|
||||||
entry: { sender: "A", body: "one" },
|
|
||||||
});
|
|
||||||
expect(historyMap.get("group")).toEqual(undefined);
|
|
||||||
|
|
||||||
recordPendingHistoryEntryIfEnabled({
|
|
||||||
historyMap,
|
|
||||||
historyKey: "group",
|
|
||||||
limit: 2,
|
|
||||||
entry: null,
|
|
||||||
});
|
|
||||||
expect(historyMap.get("group")).toEqual(undefined);
|
|
||||||
|
|
||||||
recordPendingHistoryEntryIfEnabled({
|
|
||||||
historyMap,
|
|
||||||
historyKey: "group",
|
|
||||||
limit: 2,
|
|
||||||
entry: { sender: "B", body: "two" },
|
|
||||||
});
|
|
||||||
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears history entries only when enabled", () => {
|
|
||||||
const historyMap = new Map<string, { sender: string; body: string }[]>();
|
|
||||||
historyMap.set("group", [
|
|
||||||
{ sender: "A", body: "one" },
|
|
||||||
{ sender: "B", body: "two" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 0 });
|
|
||||||
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]);
|
|
||||||
|
|
||||||
clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 2 });
|
|
||||||
expect(historyMap.get("group")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -47,22 +47,6 @@ export function recordPendingHistoryEntry<T extends HistoryEntry>(params: {
|
|||||||
return appendHistoryEntry(params);
|
return appendHistoryEntry(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordPendingHistoryEntryIfEnabled<T extends HistoryEntry>(params: {
|
|
||||||
historyMap: Map<string, T[]>;
|
|
||||||
historyKey: string;
|
|
||||||
entry?: T | null;
|
|
||||||
limit: number;
|
|
||||||
}): T[] {
|
|
||||||
if (!params.entry) return [];
|
|
||||||
if (params.limit <= 0) return [];
|
|
||||||
return recordPendingHistoryEntry({
|
|
||||||
historyMap: params.historyMap,
|
|
||||||
historyKey: params.historyKey,
|
|
||||||
entry: params.entry,
|
|
||||||
limit: params.limit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPendingHistoryContextFromMap(params: {
|
export function buildPendingHistoryContextFromMap(params: {
|
||||||
historyMap: Map<string, HistoryEntry[]>;
|
historyMap: Map<string, HistoryEntry[]>;
|
||||||
historyKey: string;
|
historyKey: string;
|
||||||
@@ -117,15 +101,6 @@ export function clearHistoryEntries(params: {
|
|||||||
params.historyMap.set(params.historyKey, []);
|
params.historyMap.set(params.historyKey, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearHistoryEntriesIfEnabled(params: {
|
|
||||||
historyMap: Map<string, HistoryEntry[]>;
|
|
||||||
historyKey: string;
|
|
||||||
limit: number;
|
|
||||||
}): void {
|
|
||||||
if (params.limit <= 0) return;
|
|
||||||
clearHistoryEntries({ historyMap: params.historyMap, historyKey: params.historyKey });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildHistoryContextFromEntries(params: {
|
export function buildHistoryContextFromEntries(params: {
|
||||||
entries: HistoryEntry[];
|
entries: HistoryEntry[];
|
||||||
currentMessage: string;
|
currentMessage: string;
|
||||||
|
|||||||
@@ -1,44 +1,58 @@
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import type { FinalizedMsgContext, MsgContext } from "../templating.js";
|
import type { FinalizedMsgContext } from "../templating.js";
|
||||||
import type { GetReplyOptions } from "../types.js";
|
import type { GetReplyOptions } from "../types.js";
|
||||||
import type { DispatchInboundResult } from "../dispatch.js";
|
import type { DispatchFromConfigResult } from "./dispatch-from-config.js";
|
||||||
|
import { dispatchReplyFromConfig } from "./dispatch-from-config.js";
|
||||||
import {
|
import {
|
||||||
dispatchInboundMessageWithBufferedDispatcher,
|
createReplyDispatcher,
|
||||||
dispatchInboundMessageWithDispatcher,
|
createReplyDispatcherWithTyping,
|
||||||
} from "../dispatch.js";
|
type ReplyDispatcherOptions,
|
||||||
import type {
|
type ReplyDispatcherWithTypingOptions,
|
||||||
ReplyDispatcherOptions,
|
|
||||||
ReplyDispatcherWithTypingOptions,
|
|
||||||
} from "./reply-dispatcher.js";
|
} from "./reply-dispatcher.js";
|
||||||
|
|
||||||
export async function dispatchReplyWithBufferedBlockDispatcher(params: {
|
export async function dispatchReplyWithBufferedBlockDispatcher(params: {
|
||||||
ctx: MsgContext | FinalizedMsgContext;
|
ctx: FinalizedMsgContext;
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
dispatcherOptions: ReplyDispatcherWithTypingOptions;
|
dispatcherOptions: ReplyDispatcherWithTypingOptions;
|
||||||
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
|
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
|
||||||
replyResolver?: typeof import("../reply.js").getReplyFromConfig;
|
replyResolver?: typeof import("../reply.js").getReplyFromConfig;
|
||||||
}): Promise<DispatchInboundResult> {
|
}): Promise<DispatchFromConfigResult> {
|
||||||
return await dispatchInboundMessageWithBufferedDispatcher({
|
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping(
|
||||||
|
params.dispatcherOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await dispatchReplyFromConfig({
|
||||||
ctx: params.ctx,
|
ctx: params.ctx,
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
dispatcherOptions: params.dispatcherOptions,
|
dispatcher,
|
||||||
replyResolver: params.replyResolver,
|
replyResolver: params.replyResolver,
|
||||||
replyOptions: params.replyOptions,
|
replyOptions: {
|
||||||
|
...params.replyOptions,
|
||||||
|
...replyOptions,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
markDispatchIdle();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dispatchReplyWithDispatcher(params: {
|
export async function dispatchReplyWithDispatcher(params: {
|
||||||
ctx: MsgContext | FinalizedMsgContext;
|
ctx: FinalizedMsgContext;
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
dispatcherOptions: ReplyDispatcherOptions;
|
dispatcherOptions: ReplyDispatcherOptions;
|
||||||
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
|
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
|
||||||
replyResolver?: typeof import("../reply.js").getReplyFromConfig;
|
replyResolver?: typeof import("../reply.js").getReplyFromConfig;
|
||||||
}): Promise<DispatchInboundResult> {
|
}): Promise<DispatchFromConfigResult> {
|
||||||
return await dispatchInboundMessageWithDispatcher({
|
const dispatcher = createReplyDispatcher(params.dispatcherOptions);
|
||||||
|
|
||||||
|
const result = await dispatchReplyFromConfig({
|
||||||
ctx: params.ctx,
|
ctx: params.ctx,
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
dispatcherOptions: params.dispatcherOptions,
|
dispatcher,
|
||||||
replyResolver: params.replyResolver,
|
replyResolver: params.replyResolver,
|
||||||
replyOptions: params.replyOptions,
|
replyOptions: params.replyOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await dispatcher.waitForIdle();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
|
||||||
import type { TypingController } from "./reply/typing.js";
|
import type { TypingController } from "./reply/typing.js";
|
||||||
|
|
||||||
export type BlockReplyContext = {
|
export type BlockReplyContext = {
|
||||||
@@ -14,14 +13,6 @@ export type ModelSelectedContext = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type GetReplyOptions = {
|
export type GetReplyOptions = {
|
||||||
/** Override run id for agent events (defaults to random UUID). */
|
|
||||||
runId?: string;
|
|
||||||
/** Abort signal for the underlying agent run. */
|
|
||||||
abortSignal?: AbortSignal;
|
|
||||||
/** Optional inbound images (used for webchat attachments). */
|
|
||||||
images?: ImageContent[];
|
|
||||||
/** Notifies when an agent run actually starts (useful for webchat command handling). */
|
|
||||||
onAgentRunStart?: (runId: string) => void;
|
|
||||||
onReplyStart?: () => Promise<void> | void;
|
onReplyStart?: () => Promise<void> | void;
|
||||||
onTypingController?: (typing: TypingController) => void;
|
onTypingController?: (typing: TypingController) => void;
|
||||||
isHeartbeat?: boolean;
|
isHeartbeat?: boolean;
|
||||||
|
|||||||
@@ -1,269 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
removeAckReactionAfterReply,
|
|
||||||
shouldAckReaction,
|
|
||||||
shouldAckReactionForWhatsApp,
|
|
||||||
} from "./ack-reactions.js";
|
|
||||||
|
|
||||||
describe("shouldAckReaction", () => {
|
|
||||||
it("honors direct and group-all scopes", () => {
|
|
||||||
expect(
|
|
||||||
shouldAckReaction({
|
|
||||||
scope: "direct",
|
|
||||||
isDirect: true,
|
|
||||||
isGroup: false,
|
|
||||||
isMentionableGroup: false,
|
|
||||||
requireMention: false,
|
|
||||||
canDetectMention: false,
|
|
||||||
effectiveWasMentioned: false,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
shouldAckReaction({
|
|
||||||
scope: "group-all",
|
|
||||||
isDirect: false,
|
|
||||||
isGroup: true,
|
|
||||||
isMentionableGroup: true,
|
|
||||||
requireMention: false,
|
|
||||||
canDetectMention: false,
|
|
||||||
effectiveWasMentioned: false,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips when scope is off or none", () => {
|
|
||||||
expect(
|
|
||||||
shouldAckReaction({
|
|
||||||
scope: "off",
|
|
||||||
isDirect: true,
|
|
||||||
isGroup: true,
|
|
||||||
isMentionableGroup: true,
|
|
||||||
requireMention: true,
|
|
||||||
canDetectMention: true,
|
|
||||||
effectiveWasMentioned: true,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
shouldAckReaction({
|
|
||||||
scope: "none",
|
|
||||||
isDirect: true,
|
|
||||||
isGroup: true,
|
|
||||||
isMentionableGroup: true,
|
|
||||||
requireMention: true,
|
|
||||||
canDetectMention: true,
|
|
||||||
effectiveWasMentioned: true,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to group-mentions gating", () => {
|
|
||||||
expect(
|
|
||||||
shouldAckReaction({
|
|
||||||
scope: undefined,
|
|
||||||
isDirect: false,
|
|
||||||
isGroup: true,
|
|
||||||
isMentionableGroup: true,
|
|
||||||
requireMention: true,
|
|
||||||
canDetectMention: true,
|
|
||||||
effectiveWasMentioned: true,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requires mention gating for group-mentions", () => {
|
|
||||||
expect(
|
|
||||||
shouldAckReaction({
|
|
||||||
scope: "group-mentions",
|
|
||||||
isDirect: false,
|
|
||||||
isGroup: true,
|
|
||||||
isMentionableGroup: true,
|
|
||||||
requireMention: false,
|
|
||||||
canDetectMention: true,
|
|
||||||
effectiveWasMentioned: true,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
shouldAckReaction({
|
|
||||||
scope: "group-mentions",
|
|
||||||
isDirect: false,
|
|
||||||
isGroup: true,
|
|
||||||
isMentionableGroup: true,
|
|
||||||
requireMention: true,
|
|
||||||
canDetectMention: false,
|
|
||||||
effectiveWasMentioned: true,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
shouldAckReaction({
|
|
||||||
scope: "group-mentions",
|
|
||||||
isDirect: false,
|
|
||||||
isGroup: true,
|
|
||||||
isMentionableGroup: false,
|
|
||||||
requireMention: true,
|
|
||||||
canDetectMention: true,
|
|
||||||
effectiveWasMentioned: true,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
shouldAckReaction({
|
|
||||||
scope: "group-mentions",
|
|
||||||
isDirect: false,
|
|
||||||
isGroup: true,
|
|
||||||
isMentionableGroup: true,
|
|
||||||
requireMention: true,
|
|
||||||
canDetectMention: true,
|
|
||||||
effectiveWasMentioned: true,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
shouldAckReaction({
|
|
||||||
scope: "group-mentions",
|
|
||||||
isDirect: false,
|
|
||||||
isGroup: true,
|
|
||||||
isMentionableGroup: true,
|
|
||||||
requireMention: true,
|
|
||||||
canDetectMention: true,
|
|
||||||
effectiveWasMentioned: false,
|
|
||||||
shouldBypassMention: true,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("shouldAckReactionForWhatsApp", () => {
|
|
||||||
it("respects direct and group modes", () => {
|
|
||||||
expect(
|
|
||||||
shouldAckReactionForWhatsApp({
|
|
||||||
emoji: "👀",
|
|
||||||
isDirect: true,
|
|
||||||
isGroup: false,
|
|
||||||
directEnabled: true,
|
|
||||||
groupMode: "mentions",
|
|
||||||
wasMentioned: false,
|
|
||||||
groupActivated: false,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
shouldAckReactionForWhatsApp({
|
|
||||||
emoji: "👀",
|
|
||||||
isDirect: true,
|
|
||||||
isGroup: false,
|
|
||||||
directEnabled: false,
|
|
||||||
groupMode: "mentions",
|
|
||||||
wasMentioned: false,
|
|
||||||
groupActivated: false,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
shouldAckReactionForWhatsApp({
|
|
||||||
emoji: "👀",
|
|
||||||
isDirect: false,
|
|
||||||
isGroup: true,
|
|
||||||
directEnabled: true,
|
|
||||||
groupMode: "always",
|
|
||||||
wasMentioned: false,
|
|
||||||
groupActivated: false,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
shouldAckReactionForWhatsApp({
|
|
||||||
emoji: "👀",
|
|
||||||
isDirect: false,
|
|
||||||
isGroup: true,
|
|
||||||
directEnabled: true,
|
|
||||||
groupMode: "never",
|
|
||||||
wasMentioned: true,
|
|
||||||
groupActivated: true,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("honors mentions or activation for group-mentions", () => {
|
|
||||||
expect(
|
|
||||||
shouldAckReactionForWhatsApp({
|
|
||||||
emoji: "👀",
|
|
||||||
isDirect: false,
|
|
||||||
isGroup: true,
|
|
||||||
directEnabled: true,
|
|
||||||
groupMode: "mentions",
|
|
||||||
wasMentioned: true,
|
|
||||||
groupActivated: false,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
shouldAckReactionForWhatsApp({
|
|
||||||
emoji: "👀",
|
|
||||||
isDirect: false,
|
|
||||||
isGroup: true,
|
|
||||||
directEnabled: true,
|
|
||||||
groupMode: "mentions",
|
|
||||||
wasMentioned: false,
|
|
||||||
groupActivated: true,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
shouldAckReactionForWhatsApp({
|
|
||||||
emoji: "👀",
|
|
||||||
isDirect: false,
|
|
||||||
isGroup: true,
|
|
||||||
directEnabled: true,
|
|
||||||
groupMode: "mentions",
|
|
||||||
wasMentioned: false,
|
|
||||||
groupActivated: false,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("removeAckReactionAfterReply", () => {
|
|
||||||
it("removes only when ack succeeded", async () => {
|
|
||||||
const remove = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onError = vi.fn();
|
|
||||||
removeAckReactionAfterReply({
|
|
||||||
removeAfterReply: true,
|
|
||||||
ackReactionPromise: Promise.resolve(true),
|
|
||||||
ackReactionValue: "👀",
|
|
||||||
remove,
|
|
||||||
onError,
|
|
||||||
});
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
expect(remove).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onError).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips removal when ack did not happen", async () => {
|
|
||||||
const remove = vi.fn().mockResolvedValue(undefined);
|
|
||||||
removeAckReactionAfterReply({
|
|
||||||
removeAfterReply: true,
|
|
||||||
ackReactionPromise: Promise.resolve(false),
|
|
||||||
ackReactionValue: "👀",
|
|
||||||
remove,
|
|
||||||
});
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
expect(remove).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips when not configured", async () => {
|
|
||||||
const remove = vi.fn().mockResolvedValue(undefined);
|
|
||||||
removeAckReactionAfterReply({
|
|
||||||
removeAfterReply: false,
|
|
||||||
ackReactionPromise: Promise.resolve(true),
|
|
||||||
ackReactionValue: "👀",
|
|
||||||
remove,
|
|
||||||
});
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
expect(remove).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
export type AckReactionScope = "all" | "direct" | "group-all" | "group-mentions" | "off" | "none";
|
|
||||||
|
|
||||||
export type WhatsAppAckReactionMode = "always" | "mentions" | "never";
|
|
||||||
|
|
||||||
export type AckReactionGateParams = {
|
|
||||||
scope: AckReactionScope | undefined;
|
|
||||||
isDirect: boolean;
|
|
||||||
isGroup: boolean;
|
|
||||||
isMentionableGroup: boolean;
|
|
||||||
requireMention: boolean;
|
|
||||||
canDetectMention: boolean;
|
|
||||||
effectiveWasMentioned: boolean;
|
|
||||||
shouldBypassMention?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function shouldAckReaction(params: AckReactionGateParams): boolean {
|
|
||||||
const scope = params.scope ?? "group-mentions";
|
|
||||||
if (scope === "off" || scope === "none") return false;
|
|
||||||
if (scope === "all") return true;
|
|
||||||
if (scope === "direct") return params.isDirect;
|
|
||||||
if (scope === "group-all") return params.isGroup;
|
|
||||||
if (scope === "group-mentions") {
|
|
||||||
if (!params.isMentionableGroup) return false;
|
|
||||||
if (!params.requireMention) return false;
|
|
||||||
if (!params.canDetectMention) return false;
|
|
||||||
return params.effectiveWasMentioned || params.shouldBypassMention === true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldAckReactionForWhatsApp(params: {
|
|
||||||
emoji: string;
|
|
||||||
isDirect: boolean;
|
|
||||||
isGroup: boolean;
|
|
||||||
directEnabled: boolean;
|
|
||||||
groupMode: WhatsAppAckReactionMode;
|
|
||||||
wasMentioned: boolean;
|
|
||||||
groupActivated: boolean;
|
|
||||||
}): boolean {
|
|
||||||
if (!params.emoji) return false;
|
|
||||||
if (params.isDirect) return params.directEnabled;
|
|
||||||
if (!params.isGroup) return false;
|
|
||||||
if (params.groupMode === "never") return false;
|
|
||||||
if (params.groupMode === "always") return true;
|
|
||||||
return shouldAckReaction({
|
|
||||||
scope: "group-mentions",
|
|
||||||
isDirect: false,
|
|
||||||
isGroup: true,
|
|
||||||
isMentionableGroup: true,
|
|
||||||
requireMention: true,
|
|
||||||
canDetectMention: true,
|
|
||||||
effectiveWasMentioned: params.wasMentioned,
|
|
||||||
shouldBypassMention: params.groupActivated,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeAckReactionAfterReply(params: {
|
|
||||||
removeAfterReply: boolean;
|
|
||||||
ackReactionPromise: Promise<boolean> | null;
|
|
||||||
ackReactionValue: string | null;
|
|
||||||
remove: () => Promise<void>;
|
|
||||||
onError?: (err: unknown) => void;
|
|
||||||
}) {
|
|
||||||
if (!params.removeAfterReply) return;
|
|
||||||
if (!params.ackReactionPromise) return;
|
|
||||||
if (!params.ackReactionValue) return;
|
|
||||||
void params.ackReactionPromise.then((didAck) => {
|
|
||||||
if (!didAck) return;
|
|
||||||
params.remove().catch((err) => params.onError?.(err));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
export type LogFn = (message: string) => void;
|
|
||||||
|
|
||||||
export function logInboundDrop(params: {
|
|
||||||
log: LogFn;
|
|
||||||
channel: string;
|
|
||||||
reason: string;
|
|
||||||
target?: string;
|
|
||||||
}): void {
|
|
||||||
const target = params.target ? ` target=${params.target}` : "";
|
|
||||||
params.log(`${params.channel}: drop ${params.reason}${target}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logTypingFailure(params: {
|
|
||||||
log: LogFn;
|
|
||||||
channel: string;
|
|
||||||
target?: string;
|
|
||||||
action?: "start" | "stop";
|
|
||||||
error: unknown;
|
|
||||||
}): void {
|
|
||||||
const target = params.target ? ` target=${params.target}` : "";
|
|
||||||
const action = params.action ? ` action=${params.action}` : "";
|
|
||||||
params.log(`${params.channel} typing${action} failed${target}: ${String(params.error)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logAckFailure(params: {
|
|
||||||
log: LogFn;
|
|
||||||
channel: string;
|
|
||||||
target?: string;
|
|
||||||
error: unknown;
|
|
||||||
}): void {
|
|
||||||
const target = params.target ? ` target=${params.target}` : "";
|
|
||||||
params.log(`${params.channel} ack cleanup failed${target}: ${String(params.error)}`);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js";
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
|
||||||
import type { GetReplyOptions } from "../auto-reply/types.js";
|
|
||||||
import {
|
|
||||||
extractShortModelName,
|
|
||||||
type ResponsePrefixContext,
|
|
||||||
} from "../auto-reply/reply/response-prefix-template.js";
|
|
||||||
|
|
||||||
type ModelSelectionContext = Parameters<NonNullable<GetReplyOptions["onModelSelected"]>>[0];
|
|
||||||
|
|
||||||
export type ReplyPrefixContextBundle = {
|
|
||||||
prefixContext: ResponsePrefixContext;
|
|
||||||
responsePrefix?: string;
|
|
||||||
responsePrefixContextProvider: () => ResponsePrefixContext;
|
|
||||||
onModelSelected: (ctx: ModelSelectionContext) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createReplyPrefixContext(params: {
|
|
||||||
cfg: ClawdbotConfig;
|
|
||||||
agentId: string;
|
|
||||||
}): ReplyPrefixContextBundle {
|
|
||||||
const { cfg, agentId } = params;
|
|
||||||
const prefixContext: ResponsePrefixContext = {
|
|
||||||
identityName: resolveIdentityName(cfg, agentId),
|
|
||||||
};
|
|
||||||
|
|
||||||
const onModelSelected = (ctx: ModelSelectionContext) => {
|
|
||||||
// Mutate the object directly instead of reassigning to ensure closures see updates.
|
|
||||||
prefixContext.provider = ctx.provider;
|
|
||||||
prefixContext.model = extractShortModelName(ctx.model);
|
|
||||||
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
|
|
||||||
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
prefixContext,
|
|
||||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
|
|
||||||
responsePrefixContextProvider: () => prefixContext,
|
|
||||||
onModelSelected,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { MsgContext } from "../auto-reply/templating.js";
|
|
||||||
import {
|
|
||||||
recordSessionMetaFromInbound,
|
|
||||||
type GroupKeyResolution,
|
|
||||||
type SessionEntry,
|
|
||||||
updateLastRoute,
|
|
||||||
} from "../config/sessions.js";
|
|
||||||
|
|
||||||
export type InboundLastRouteUpdate = {
|
|
||||||
sessionKey: string;
|
|
||||||
channel: SessionEntry["lastChannel"];
|
|
||||||
to: string;
|
|
||||||
accountId?: string;
|
|
||||||
threadId?: string | number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function recordInboundSession(params: {
|
|
||||||
storePath: string;
|
|
||||||
sessionKey: string;
|
|
||||||
ctx: MsgContext;
|
|
||||||
groupResolution?: GroupKeyResolution | null;
|
|
||||||
createIfMissing?: boolean;
|
|
||||||
updateLastRoute?: InboundLastRouteUpdate;
|
|
||||||
onRecordError: (err: unknown) => void;
|
|
||||||
}): Promise<void> {
|
|
||||||
const { storePath, sessionKey, ctx, groupResolution, createIfMissing } = params;
|
|
||||||
void recordSessionMetaFromInbound({
|
|
||||||
storePath,
|
|
||||||
sessionKey,
|
|
||||||
ctx,
|
|
||||||
groupResolution,
|
|
||||||
createIfMissing,
|
|
||||||
}).catch(params.onRecordError);
|
|
||||||
|
|
||||||
const update = params.updateLastRoute;
|
|
||||||
if (!update) return;
|
|
||||||
await updateLastRoute({
|
|
||||||
storePath,
|
|
||||||
sessionKey: update.sessionKey,
|
|
||||||
deliveryContext: {
|
|
||||||
channel: update.channel,
|
|
||||||
to: update.to,
|
|
||||||
accountId: update.accountId,
|
|
||||||
threadId: update.threadId,
|
|
||||||
},
|
|
||||||
ctx,
|
|
||||||
groupResolution,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { createTypingCallbacks } from "./typing.js";
|
|
||||||
|
|
||||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
describe("createTypingCallbacks", () => {
|
|
||||||
it("invokes start on reply start", async () => {
|
|
||||||
const start = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onStartError = vi.fn();
|
|
||||||
const callbacks = createTypingCallbacks({ start, onStartError });
|
|
||||||
|
|
||||||
await callbacks.onReplyStart();
|
|
||||||
|
|
||||||
expect(start).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onStartError).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports start errors", async () => {
|
|
||||||
const start = vi.fn().mockRejectedValue(new Error("fail"));
|
|
||||||
const onStartError = vi.fn();
|
|
||||||
const callbacks = createTypingCallbacks({ start, onStartError });
|
|
||||||
|
|
||||||
await callbacks.onReplyStart();
|
|
||||||
|
|
||||||
expect(onStartError).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("invokes stop on idle and reports stop errors", async () => {
|
|
||||||
const start = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const stop = vi.fn().mockRejectedValue(new Error("stop"));
|
|
||||||
const onStartError = vi.fn();
|
|
||||||
const onStopError = vi.fn();
|
|
||||||
const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError });
|
|
||||||
|
|
||||||
callbacks.onIdle?.();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(stop).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onStopError).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
export type TypingCallbacks = {
|
|
||||||
onReplyStart: () => Promise<void>;
|
|
||||||
onIdle?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createTypingCallbacks(params: {
|
|
||||||
start: () => Promise<void>;
|
|
||||||
stop?: () => Promise<void>;
|
|
||||||
onStartError: (err: unknown) => void;
|
|
||||||
onStopError?: (err: unknown) => void;
|
|
||||||
}): TypingCallbacks {
|
|
||||||
const stop = params.stop;
|
|
||||||
const onReplyStart = async () => {
|
|
||||||
try {
|
|
||||||
await params.start();
|
|
||||||
} catch (err) {
|
|
||||||
params.onStartError(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onIdle = stop
|
|
||||||
? () => {
|
|
||||||
void stop().catch((err) => (params.onStopError ?? params.onStartError)(err));
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return { onReplyStart, onIdle };
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
|
|
||||||
|
|
||||||
const dispatchMock = vi.fn();
|
const dispatchMock = vi.fn();
|
||||||
|
|
||||||
@@ -21,34 +20,15 @@ vi.mock("@buape/carbon", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
|
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
|
||||||
const actual = await importOriginal<typeof import("../auto-reply/dispatch.js")>();
|
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
|
||||||
return {
|
}));
|
||||||
...actual,
|
|
||||||
dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args),
|
|
||||||
dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
|
|
||||||
dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
dispatchMock.mockReset().mockImplementation(async (params) => {
|
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
|
||||||
if ("dispatcher" in params && params.dispatcher) {
|
dispatcher.sendToolResult({ text: "tool update" });
|
||||||
params.dispatcher.sendToolResult({ text: "tool update" });
|
dispatcher.sendFinalReply({ text: "final reply" });
|
||||||
params.dispatcher.sendFinalReply({ text: "final reply" });
|
return { queuedFinal: true, counts: { tool: 1, block: 0, final: 1 } };
|
||||||
return { queuedFinal: true, counts: { tool: 1, block: 0, final: 1 } };
|
|
||||||
}
|
|
||||||
if ("dispatcherOptions" in params && params.dispatcherOptions) {
|
|
||||||
const { dispatcher, markDispatchIdle } = createReplyDispatcherWithTyping(
|
|
||||||
params.dispatcherOptions,
|
|
||||||
);
|
|
||||||
dispatcher.sendToolResult({ text: "tool update" });
|
|
||||||
dispatcher.sendFinalReply({ text: "final reply" });
|
|
||||||
await dispatcher.waitForIdle();
|
|
||||||
markDispatchIdle();
|
|
||||||
return { queuedFinal: true, counts: dispatcher.getQueuedCounts() };
|
|
||||||
}
|
|
||||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+4
-10
@@ -18,15 +18,9 @@ vi.mock("./send.js", () => ({
|
|||||||
reactMock(...args);
|
reactMock(...args);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
|
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
|
||||||
const actual = await importOriginal<typeof import("../auto-reply/dispatch.js")>();
|
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
|
||||||
return {
|
}));
|
||||||
...actual,
|
|
||||||
dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args),
|
|
||||||
dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
|
|
||||||
dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
vi.mock("../pairing/pairing-store.js", () => ({
|
vi.mock("../pairing/pairing-store.js", () => ({
|
||||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||||
@@ -47,7 +41,7 @@ beforeEach(() => {
|
|||||||
updateLastRouteMock.mockReset();
|
updateLastRouteMock.mockReset();
|
||||||
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
|
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
|
||||||
dispatcher.sendFinalReply({ text: "hi" });
|
dispatcher.sendFinalReply({ text: "hi" });
|
||||||
return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
|
return { queuedFinal: true, counts: { final: 1 } };
|
||||||
});
|
});
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||||
|
|||||||
@@ -18,15 +18,9 @@ vi.mock("./send.js", () => ({
|
|||||||
reactMock(...args);
|
reactMock(...args);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
|
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
|
||||||
const actual = await importOriginal<typeof import("../auto-reply/dispatch.js")>();
|
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
|
||||||
return {
|
}));
|
||||||
...actual,
|
|
||||||
dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args),
|
|
||||||
dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
|
|
||||||
dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
vi.mock("../pairing/pairing-store.js", () => ({
|
vi.mock("../pairing/pairing-store.js", () => ({
|
||||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||||
@@ -46,7 +40,7 @@ beforeEach(() => {
|
|||||||
updateLastRouteMock.mockReset();
|
updateLastRouteMock.mockReset();
|
||||||
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
|
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
|
||||||
dispatcher.sendFinalReply({ text: "hi" });
|
dispatcher.sendFinalReply({ text: "hi" });
|
||||||
return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
|
return { queuedFinal: true, counts: { final: 1 } };
|
||||||
});
|
});
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||||
|
|||||||
@@ -9,24 +9,17 @@ import { expectInboundContextContract } from "../../../test/helpers/inbound-cont
|
|||||||
|
|
||||||
let capturedCtx: MsgContext | undefined;
|
let capturedCtx: MsgContext | undefined;
|
||||||
|
|
||||||
vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => {
|
vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({
|
||||||
const actual = await importOriginal<typeof import("../../auto-reply/dispatch.js")>();
|
dispatchReplyFromConfig: vi.fn(async (params: { ctx: MsgContext }) => {
|
||||||
const dispatchInboundMessage = vi.fn(async (params: { ctx: MsgContext }) => {
|
|
||||||
capturedCtx = params.ctx;
|
capturedCtx = params.ctx;
|
||||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
|
return { queuedFinal: false, counts: { tool: 0, block: 0 } };
|
||||||
});
|
}),
|
||||||
return {
|
}));
|
||||||
...actual,
|
|
||||||
dispatchInboundMessage,
|
|
||||||
dispatchInboundMessageWithDispatcher: dispatchInboundMessage,
|
|
||||||
dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import { processDiscordMessage } from "./message-handler.process.js";
|
import { processDiscordMessage } from "./message-handler.process.js";
|
||||||
|
|
||||||
describe("discord processDiscordMessage inbound contract", () => {
|
describe("discord processDiscordMessage inbound contract", () => {
|
||||||
it("passes a finalized MsgContext to dispatchInboundMessage", async () => {
|
it("passes a finalized MsgContext to dispatchReplyFromConfig", async () => {
|
||||||
capturedCtx = undefined;
|
capturedCtx = undefined;
|
||||||
|
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-"));
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { ChannelType, MessageType, type User } from "@buape/carbon";
|
|||||||
|
|
||||||
import { hasControlCommand } from "../../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../../auto-reply/command-detection.js";
|
||||||
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
|
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
|
||||||
import {
|
import { recordPendingHistoryEntry, type HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||||
recordPendingHistoryEntryIfEnabled,
|
|
||||||
type HistoryEntry,
|
|
||||||
} from "../../auto-reply/reply/history.js";
|
|
||||||
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
|
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
|
||||||
import { logVerbose, shouldLogVerbose } from "../../globals.js";
|
import { logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||||
import { recordChannelActivity } from "../../infra/channel-activity.js";
|
import { recordChannelActivity } from "../../infra/channel-activity.js";
|
||||||
@@ -21,7 +18,6 @@ import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js
|
|||||||
import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js";
|
import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js";
|
||||||
import { sendMessageDiscord } from "../send.js";
|
import { sendMessageDiscord } from "../send.js";
|
||||||
import { resolveControlCommandGate } from "../../channels/command-gating.js";
|
import { resolveControlCommandGate } from "../../channels/command-gating.js";
|
||||||
import { logInboundDrop } from "../../channels/logging.js";
|
|
||||||
import {
|
import {
|
||||||
allowListMatches,
|
allowListMatches,
|
||||||
isDiscordGroupAllowedByPolicy,
|
isDiscordGroupAllowedByPolicy,
|
||||||
@@ -386,12 +382,7 @@ export async function preflightDiscordMessage(
|
|||||||
commandAuthorized = commandGate.commandAuthorized;
|
commandAuthorized = commandGate.commandAuthorized;
|
||||||
|
|
||||||
if (commandGate.shouldBlock) {
|
if (commandGate.shouldBlock) {
|
||||||
logInboundDrop({
|
logVerbose(`Blocked discord control command from unauthorized sender ${author.id}`);
|
||||||
log: logVerbose,
|
|
||||||
channel: "discord",
|
|
||||||
reason: "control command (unauthorized)",
|
|
||||||
target: author.id,
|
|
||||||
});
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -419,12 +410,14 @@ export async function preflightDiscordMessage(
|
|||||||
},
|
},
|
||||||
"discord: skipping guild message",
|
"discord: skipping guild message",
|
||||||
);
|
);
|
||||||
recordPendingHistoryEntryIfEnabled({
|
if (historyEntry && params.historyLimit > 0) {
|
||||||
historyMap: params.guildHistories,
|
recordPendingHistoryEntry({
|
||||||
historyKey: message.channelId,
|
historyMap: params.guildHistories,
|
||||||
limit: params.historyLimit,
|
historyKey: message.channelId,
|
||||||
entry: historyEntry ?? null,
|
limit: params.historyLimit,
|
||||||
});
|
entry: historyEntry,
|
||||||
|
});
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
import { resolveAckReaction, resolveHumanDelayConfig } from "../../agents/identity.js";
|
|
||||||
import {
|
import {
|
||||||
removeAckReactionAfterReply,
|
resolveAckReaction,
|
||||||
shouldAckReaction as shouldAckReactionGate,
|
resolveEffectiveMessagesConfig,
|
||||||
} from "../../channels/ack-reactions.js";
|
resolveHumanDelayConfig,
|
||||||
import { logTypingFailure, logAckFailure } from "../../channels/logging.js";
|
resolveIdentityName,
|
||||||
import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
|
} from "../../agents/identity.js";
|
||||||
import { createTypingCallbacks } from "../../channels/typing.js";
|
import {
|
||||||
|
extractShortModelName,
|
||||||
|
type ResponsePrefixContext,
|
||||||
|
} from "../../auto-reply/reply/response-prefix-template.js";
|
||||||
import {
|
import {
|
||||||
formatInboundEnvelope,
|
formatInboundEnvelope,
|
||||||
formatThreadStarterEnvelope,
|
formatThreadStarterEnvelope,
|
||||||
resolveEnvelopeFormatOptions,
|
resolveEnvelopeFormatOptions,
|
||||||
} from "../../auto-reply/envelope.js";
|
} from "../../auto-reply/envelope.js";
|
||||||
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
|
||||||
import {
|
import {
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
clearHistoryEntriesIfEnabled,
|
clearHistoryEntries,
|
||||||
} from "../../auto-reply/reply/history.js";
|
} from "../../auto-reply/reply/history.js";
|
||||||
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
||||||
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
import { recordInboundSession } from "../../channels/session.js";
|
import {
|
||||||
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
|
readSessionUpdatedAt,
|
||||||
|
recordSessionMetaFromInbound,
|
||||||
|
resolveStorePath,
|
||||||
|
updateLastRoute,
|
||||||
|
} from "../../config/sessions.js";
|
||||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||||
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
|
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
|
||||||
@@ -67,7 +73,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
shouldRequireMention,
|
shouldRequireMention,
|
||||||
canDetectMention,
|
canDetectMention,
|
||||||
effectiveWasMentioned,
|
effectiveWasMentioned,
|
||||||
shouldBypassMention,
|
|
||||||
threadChannel,
|
threadChannel,
|
||||||
threadParentId,
|
threadParentId,
|
||||||
threadParentName,
|
threadParentName,
|
||||||
@@ -90,20 +95,20 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
}
|
}
|
||||||
const ackReaction = resolveAckReaction(cfg, route.agentId);
|
const ackReaction = resolveAckReaction(cfg, route.agentId);
|
||||||
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
||||||
const shouldAckReaction = () =>
|
const shouldAckReaction = () => {
|
||||||
Boolean(
|
if (!ackReaction) return false;
|
||||||
ackReaction &&
|
if (ackReactionScope === "all") return true;
|
||||||
shouldAckReactionGate({
|
if (ackReactionScope === "direct") return isDirectMessage;
|
||||||
scope: ackReactionScope,
|
const isGroupChat = isGuildMessage || isGroupDm;
|
||||||
isDirect: isDirectMessage,
|
if (ackReactionScope === "group-all") return isGroupChat;
|
||||||
isGroup: isGuildMessage || isGroupDm,
|
if (ackReactionScope === "group-mentions") {
|
||||||
isMentionableGroup: isGuildMessage,
|
if (!isGuildMessage) return false;
|
||||||
requireMention: Boolean(shouldRequireMention),
|
if (!shouldRequireMention) return false;
|
||||||
canDetectMention,
|
if (!canDetectMention) return false;
|
||||||
effectiveWasMentioned,
|
return effectiveWasMentioned;
|
||||||
shouldBypassMention,
|
}
|
||||||
}),
|
return false;
|
||||||
);
|
};
|
||||||
const ackReactionPromise = shouldAckReaction()
|
const ackReactionPromise = shouldAckReaction()
|
||||||
? reactMessageDiscord(message.channelId, message.id, ackReaction, {
|
? reactMessageDiscord(message.channelId, message.id, ackReaction, {
|
||||||
rest: client.rest,
|
rest: client.rest,
|
||||||
@@ -283,23 +288,27 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget,
|
OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget,
|
||||||
});
|
});
|
||||||
|
|
||||||
await recordInboundSession({
|
void recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
updateLastRoute: isDirectMessage
|
}).catch((err) => {
|
||||||
? {
|
logVerbose(`discord: failed updating session meta: ${String(err)}`);
|
||||||
sessionKey: route.mainSessionKey,
|
|
||||||
channel: "discord",
|
|
||||||
to: `user:${author.id}`,
|
|
||||||
accountId: route.accountId,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onRecordError: (err) => {
|
|
||||||
logVerbose(`discord: failed updating session meta: ${String(err)}`);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isDirectMessage) {
|
||||||
|
await updateLastRoute({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.mainSessionKey,
|
||||||
|
deliveryContext: {
|
||||||
|
channel: "discord",
|
||||||
|
to: `user:${author.id}`,
|
||||||
|
accountId: route.accountId,
|
||||||
|
},
|
||||||
|
ctx: ctxPayload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldLogVerbose()) {
|
if (shouldLogVerbose()) {
|
||||||
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
|
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
|
||||||
logVerbose(
|
logVerbose(
|
||||||
@@ -311,7 +320,10 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
? deliverTarget.slice("channel:".length)
|
? deliverTarget.slice("channel:".length)
|
||||||
: message.channelId;
|
: message.channelId;
|
||||||
|
|
||||||
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
|
// Create mutable context for response prefix template interpolation
|
||||||
|
let prefixContext: ResponsePrefixContext = {
|
||||||
|
identityName: resolveIdentityName(cfg, route.agentId),
|
||||||
|
};
|
||||||
const tableMode = resolveMarkdownTableMode({
|
const tableMode = resolveMarkdownTableMode({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
@@ -319,8 +331,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||||
responsePrefix: prefixContext.responsePrefix,
|
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
responsePrefixContextProvider: () => prefixContext,
|
||||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
deliver: async (payload: ReplyPayload) => {
|
deliver: async (payload: ReplyPayload) => {
|
||||||
const replyToId = replyReference.use();
|
const replyToId = replyReference.use();
|
||||||
@@ -341,20 +353,10 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`));
|
runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`));
|
||||||
},
|
},
|
||||||
onReplyStart: createTypingCallbacks({
|
onReplyStart: () => sendTyping({ client, channelId: typingChannelId }),
|
||||||
start: () => sendTyping({ client, channelId: typingChannelId }),
|
|
||||||
onStartError: (err) => {
|
|
||||||
logTypingFailure({
|
|
||||||
log: logVerbose,
|
|
||||||
channel: "discord",
|
|
||||||
target: typingChannelId,
|
|
||||||
error: err,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}).onReplyStart,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { queuedFinal, counts } = await dispatchInboundMessage({
|
const { queuedFinal, counts } = await dispatchReplyFromConfig({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
@@ -366,17 +368,20 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
? !discordConfig.blockStreaming
|
? !discordConfig.blockStreaming
|
||||||
: undefined,
|
: undefined,
|
||||||
onModelSelected: (ctx) => {
|
onModelSelected: (ctx) => {
|
||||||
prefixContext.onModelSelected(ctx);
|
// Mutate the object directly instead of reassigning to ensure the closure sees updates
|
||||||
|
prefixContext.provider = ctx.provider;
|
||||||
|
prefixContext.model = extractShortModelName(ctx.model);
|
||||||
|
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
|
||||||
|
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
markDispatchIdle();
|
markDispatchIdle();
|
||||||
if (!queuedFinal) {
|
if (!queuedFinal) {
|
||||||
if (isGuildMessage) {
|
if (isGuildMessage && historyLimit > 0) {
|
||||||
clearHistoryEntriesIfEnabled({
|
clearHistoryEntries({
|
||||||
historyMap: guildHistories,
|
historyMap: guildHistories,
|
||||||
historyKey: message.channelId,
|
historyKey: message.channelId,
|
||||||
limit: historyLimit,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -387,29 +392,23 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
removeAckReactionAfterReply({
|
if (removeAckAfterReply && ackReactionPromise && ackReaction) {
|
||||||
removeAfterReply: removeAckAfterReply,
|
const ackReactionValue = ackReaction;
|
||||||
ackReactionPromise,
|
void ackReactionPromise.then((didAck) => {
|
||||||
ackReactionValue: ackReaction,
|
if (!didAck) return;
|
||||||
remove: async () => {
|
removeReactionDiscord(message.channelId, message.id, ackReactionValue, {
|
||||||
await removeReactionDiscord(message.channelId, message.id, ackReaction, {
|
|
||||||
rest: client.rest,
|
rest: client.rest,
|
||||||
|
}).catch((err) => {
|
||||||
|
logVerbose(
|
||||||
|
`discord: failed to remove ack reaction from ${message.channelId}/${message.id}: ${String(err)}`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
onError: (err) => {
|
}
|
||||||
logAckFailure({
|
if (isGuildMessage && historyLimit > 0) {
|
||||||
log: logVerbose,
|
clearHistoryEntries({
|
||||||
channel: "discord",
|
|
||||||
target: `${message.channelId}/${message.id}`,
|
|
||||||
error: err,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (isGuildMessage) {
|
|
||||||
clearHistoryEntriesIfEnabled({
|
|
||||||
historyMap: guildHistories,
|
historyMap: guildHistories,
|
||||||
historyKey: message.channelId,
|
historyKey: message.channelId,
|
||||||
limit: historyLimit,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import type { Client } from "@buape/carbon";
|
import type { Client } from "@buape/carbon";
|
||||||
|
|
||||||
|
import { logVerbose } from "../../globals.js";
|
||||||
|
|
||||||
export async function sendTyping(params: { client: Client; channelId: string }) {
|
export async function sendTyping(params: { client: Client; channelId: string }) {
|
||||||
const channel = await params.client.fetchChannel(params.channelId);
|
try {
|
||||||
if (!channel) return;
|
const channel = await params.client.fetchChannel(params.channelId);
|
||||||
if ("triggerTyping" in channel && typeof channel.triggerTyping === "function") {
|
if (!channel) return;
|
||||||
await channel.triggerTyping();
|
if ("triggerTyping" in channel && typeof channel.triggerTyping === "function") {
|
||||||
|
await channel.triggerTyping();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`discord typing cue failed for channel ${params.channelId}: ${String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+200
-260
@@ -2,18 +2,30 @@ import { randomUUID } from "node:crypto";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent";
|
import { resolveSessionAgentId, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
|
||||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
|
||||||
import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../../agents/identity.js";
|
|
||||||
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
||||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||||
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
import { ensureAgentWorkspace } from "../../agents/workspace.js";
|
||||||
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
import { isControlCommandMessage } from "../../auto-reply/command-detection.js";
|
||||||
|
import { normalizeCommandBody } from "../../auto-reply/commands-registry.js";
|
||||||
|
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js";
|
||||||
|
import { buildCommandContext, handleCommands } from "../../auto-reply/reply/commands.js";
|
||||||
|
import { parseInlineDirectives } from "../../auto-reply/reply/directive-handling.js";
|
||||||
|
import { defaultGroupActivation } from "../../auto-reply/reply/groups.js";
|
||||||
|
import { resolveContextTokens } from "../../auto-reply/reply/model-selection.js";
|
||||||
|
import { resolveElevatedPermissions } from "../../auto-reply/reply/reply-elevated.js";
|
||||||
import {
|
import {
|
||||||
extractShortModelName,
|
normalizeElevatedLevel,
|
||||||
type ResponsePrefixContext,
|
normalizeReasoningLevel,
|
||||||
} from "../../auto-reply/reply/response-prefix-template.js";
|
normalizeThinkLevel,
|
||||||
|
normalizeVerboseLevel,
|
||||||
|
} from "../../auto-reply/thinking.js";
|
||||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||||
|
import { agentCommand } from "../../commands/agent.js";
|
||||||
|
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||||
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
|
import { isAcpSessionKey } from "../../routing/session-key.js";
|
||||||
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||||
import {
|
import {
|
||||||
@@ -41,144 +53,7 @@ import {
|
|||||||
} from "../session-utils.js";
|
} from "../session-utils.js";
|
||||||
import { stripEnvelopeFromMessages } from "../chat-sanitize.js";
|
import { stripEnvelopeFromMessages } from "../chat-sanitize.js";
|
||||||
import { formatForLog } from "../ws-log.js";
|
import { formatForLog } from "../ws-log.js";
|
||||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
|
|
||||||
type TranscriptAppendResult = {
|
|
||||||
ok: boolean;
|
|
||||||
messageId?: string;
|
|
||||||
message?: Record<string, unknown>;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveTranscriptPath(params: {
|
|
||||||
sessionId: string;
|
|
||||||
storePath: string | undefined;
|
|
||||||
sessionFile?: string;
|
|
||||||
}): string | null {
|
|
||||||
const { sessionId, storePath, sessionFile } = params;
|
|
||||||
if (sessionFile) return sessionFile;
|
|
||||||
if (!storePath) return null;
|
|
||||||
return path.join(path.dirname(storePath), `${sessionId}.jsonl`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureTranscriptFile(params: { transcriptPath: string; sessionId: string }): {
|
|
||||||
ok: boolean;
|
|
||||||
error?: string;
|
|
||||||
} {
|
|
||||||
if (fs.existsSync(params.transcriptPath)) return { ok: true };
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(path.dirname(params.transcriptPath), { recursive: true });
|
|
||||||
const header = {
|
|
||||||
type: "session",
|
|
||||||
version: CURRENT_SESSION_VERSION,
|
|
||||||
id: params.sessionId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
cwd: process.cwd(),
|
|
||||||
};
|
|
||||||
fs.writeFileSync(params.transcriptPath, `${JSON.stringify(header)}\n`, "utf-8");
|
|
||||||
return { ok: true };
|
|
||||||
} catch (err) {
|
|
||||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendAssistantTranscriptMessage(params: {
|
|
||||||
message: string;
|
|
||||||
label?: string;
|
|
||||||
sessionId: string;
|
|
||||||
storePath: string | undefined;
|
|
||||||
sessionFile?: string;
|
|
||||||
createIfMissing?: boolean;
|
|
||||||
}): TranscriptAppendResult {
|
|
||||||
const transcriptPath = resolveTranscriptPath({
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
storePath: params.storePath,
|
|
||||||
sessionFile: params.sessionFile,
|
|
||||||
});
|
|
||||||
if (!transcriptPath) {
|
|
||||||
return { ok: false, error: "transcript path not resolved" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(transcriptPath)) {
|
|
||||||
if (!params.createIfMissing) {
|
|
||||||
return { ok: false, error: "transcript file not found" };
|
|
||||||
}
|
|
||||||
const ensured = ensureTranscriptFile({
|
|
||||||
transcriptPath,
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
});
|
|
||||||
if (!ensured.ok) {
|
|
||||||
return { ok: false, error: ensured.error ?? "failed to create transcript file" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const messageId = randomUUID().slice(0, 8);
|
|
||||||
const labelPrefix = params.label ? `[${params.label}]\n\n` : "";
|
|
||||||
const messageBody: Record<string, unknown> = {
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text: `${labelPrefix}${params.message}` }],
|
|
||||||
timestamp: now,
|
|
||||||
stopReason: "injected",
|
|
||||||
usage: { input: 0, output: 0, totalTokens: 0 },
|
|
||||||
};
|
|
||||||
const transcriptEntry = {
|
|
||||||
type: "message",
|
|
||||||
id: messageId,
|
|
||||||
timestamp: new Date(now).toISOString(),
|
|
||||||
message: messageBody,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8");
|
|
||||||
} catch (err) {
|
|
||||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, messageId, message: transcriptEntry.message };
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextChatSeq(context: { agentRunSeq: Map<string, number> }, runId: string) {
|
|
||||||
const next = (context.agentRunSeq.get(runId) ?? 0) + 1;
|
|
||||||
context.agentRunSeq.set(runId, next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcastChatFinal(params: {
|
|
||||||
context: Pick<GatewayRequestContext, "broadcast" | "nodeSendToSession" | "agentRunSeq">;
|
|
||||||
runId: string;
|
|
||||||
sessionKey: string;
|
|
||||||
message?: Record<string, unknown>;
|
|
||||||
}) {
|
|
||||||
const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId);
|
|
||||||
const payload = {
|
|
||||||
runId: params.runId,
|
|
||||||
sessionKey: params.sessionKey,
|
|
||||||
seq,
|
|
||||||
state: "final" as const,
|
|
||||||
message: params.message,
|
|
||||||
};
|
|
||||||
params.context.broadcast("chat", payload);
|
|
||||||
params.context.nodeSendToSession(params.sessionKey, "chat", payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcastChatError(params: {
|
|
||||||
context: Pick<GatewayRequestContext, "broadcast" | "nodeSendToSession" | "agentRunSeq">;
|
|
||||||
runId: string;
|
|
||||||
sessionKey: string;
|
|
||||||
errorMessage?: string;
|
|
||||||
}) {
|
|
||||||
const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId);
|
|
||||||
const payload = {
|
|
||||||
runId: params.runId,
|
|
||||||
sessionKey: params.sessionKey,
|
|
||||||
seq,
|
|
||||||
state: "error" as const,
|
|
||||||
errorMessage: params.errorMessage,
|
|
||||||
};
|
|
||||||
params.context.broadcast("chat", payload);
|
|
||||||
params.context.nodeSendToSession(params.sessionKey, "chat", payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const chatHandlers: GatewayRequestHandlers = {
|
export const chatHandlers: GatewayRequestHandlers = {
|
||||||
"chat.history": async ({ params, respond, context }) => {
|
"chat.history": async ({ params, respond, context }) => {
|
||||||
@@ -293,7 +168,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
runIds: res.aborted ? [runId] : [],
|
runIds: res.aborted ? [runId] : [],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
"chat.send": async ({ params, respond, context, client }) => {
|
"chat.send": async ({ params, respond, context }) => {
|
||||||
if (!validateChatSendParams(params)) {
|
if (!validateChatSendParams(params)) {
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
@@ -353,13 +228,20 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { cfg, entry } = loadSessionEntry(p.sessionKey);
|
const { cfg, storePath, entry, canonicalKey, store } = loadSessionEntry(p.sessionKey);
|
||||||
const timeoutMs = resolveAgentTimeoutMs({
|
const timeoutMs = resolveAgentTimeoutMs({
|
||||||
cfg,
|
cfg,
|
||||||
overrideMs: p.timeoutMs,
|
overrideMs: p.timeoutMs,
|
||||||
});
|
});
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const sessionId = entry?.sessionId ?? randomUUID();
|
||||||
|
const sessionEntry = mergeSessionEntry(entry, {
|
||||||
|
sessionId,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
store[canonicalKey] = sessionEntry;
|
||||||
const clientRunId = p.idempotencyKey;
|
const clientRunId = p.idempotencyKey;
|
||||||
|
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
|
||||||
|
|
||||||
const sendPolicy = resolveSendPolicy({
|
const sendPolicy = resolveSendPolicy({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -416,11 +298,21 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
context.chatAbortControllers.set(clientRunId, {
|
context.chatAbortControllers.set(clientRunId, {
|
||||||
controller: abortController,
|
controller: abortController,
|
||||||
sessionId: entry?.sessionId ?? clientRunId,
|
sessionId,
|
||||||
sessionKey: p.sessionKey,
|
sessionKey: p.sessionKey,
|
||||||
startedAtMs: now,
|
startedAtMs: now,
|
||||||
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
|
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
|
||||||
});
|
});
|
||||||
|
context.addChatRun(clientRunId, {
|
||||||
|
sessionKey: p.sessionKey,
|
||||||
|
clientRunId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (storePath) {
|
||||||
|
await updateSessionStore(storePath, (store) => {
|
||||||
|
store[canonicalKey] = sessionEntry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const ackPayload = {
|
const ackPayload = {
|
||||||
runId: clientRunId,
|
runId: clientRunId,
|
||||||
@@ -428,116 +320,170 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
};
|
};
|
||||||
respond(true, ackPayload, undefined, { runId: clientRunId });
|
respond(true, ackPayload, undefined, { runId: clientRunId });
|
||||||
|
|
||||||
const trimmedMessage = parsedMessage.trim();
|
if (isControlCommandMessage(parsedMessage, cfg)) {
|
||||||
const injectThinking = Boolean(
|
try {
|
||||||
p.thinking && trimmedMessage && !trimmedMessage.startsWith("/"),
|
const isFastTestEnv = process.env.CLAWDBOT_TEST_FAST === "1";
|
||||||
);
|
const agentId = resolveSessionAgentId({ sessionKey: p.sessionKey, config: cfg });
|
||||||
const commandBody = injectThinking ? `/think ${p.thinking} ${parsedMessage}` : parsedMessage;
|
const agentCfg = cfg.agents?.defaults;
|
||||||
const clientInfo = client?.connect?.client;
|
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||||
const ctx: MsgContext = {
|
const workspace = await ensureAgentWorkspace({
|
||||||
Body: parsedMessage,
|
dir: workspaceDir,
|
||||||
BodyForAgent: parsedMessage,
|
ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv,
|
||||||
BodyForCommands: commandBody,
|
});
|
||||||
RawBody: parsedMessage,
|
const ctx: MsgContext = {
|
||||||
CommandBody: commandBody,
|
Body: parsedMessage,
|
||||||
SessionKey: p.sessionKey,
|
CommandBody: parsedMessage,
|
||||||
Provider: INTERNAL_MESSAGE_CHANNEL,
|
BodyForCommands: parsedMessage,
|
||||||
Surface: INTERNAL_MESSAGE_CHANNEL,
|
CommandSource: "text",
|
||||||
OriginatingChannel: INTERNAL_MESSAGE_CHANNEL,
|
CommandAuthorized: true,
|
||||||
ChatType: "direct",
|
Provider: INTERNAL_MESSAGE_CHANNEL,
|
||||||
CommandAuthorized: true,
|
Surface: "tui",
|
||||||
MessageSid: clientRunId,
|
From: p.sessionKey,
|
||||||
SenderId: clientInfo?.id,
|
To: INTERNAL_MESSAGE_CHANNEL,
|
||||||
SenderName: clientInfo?.displayName,
|
SessionKey: p.sessionKey,
|
||||||
SenderUsername: clientInfo?.displayName,
|
ChatType: "direct",
|
||||||
};
|
};
|
||||||
|
const command = buildCommandContext({
|
||||||
const agentId = resolveSessionAgentId({
|
ctx,
|
||||||
sessionKey: p.sessionKey,
|
cfg,
|
||||||
config: cfg,
|
agentId,
|
||||||
});
|
sessionKey: p.sessionKey,
|
||||||
let prefixContext: ResponsePrefixContext = {
|
isGroup: false,
|
||||||
identityName: resolveIdentityName(cfg, agentId),
|
triggerBodyNormalized: normalizeCommandBody(parsedMessage),
|
||||||
};
|
commandAuthorized: true,
|
||||||
const finalReplyParts: string[] = [];
|
});
|
||||||
const dispatcher = createReplyDispatcher({
|
const directives = parseInlineDirectives(parsedMessage);
|
||||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
|
const { provider, model } = resolveSessionModelRef(cfg, sessionEntry);
|
||||||
responsePrefixContextProvider: () => prefixContext,
|
const contextTokens = resolveContextTokens({ agentCfg, model });
|
||||||
onError: (err) => {
|
const resolveDefaultThinkingLevel = async () => {
|
||||||
context.logGateway.warn(`webchat dispatch failed: ${formatForLog(err)}`);
|
const configured = agentCfg?.thinkingDefault;
|
||||||
},
|
if (configured) return configured;
|
||||||
deliver: async (payload, info) => {
|
const catalog = await context.loadGatewayModelCatalog();
|
||||||
if (info.kind !== "final") return;
|
return resolveThinkingDefault({ cfg, provider, model, catalog });
|
||||||
const text = payload.text?.trim() ?? "";
|
};
|
||||||
if (!text) return;
|
const resolvedThinkLevel =
|
||||||
finalReplyParts.push(text);
|
normalizeThinkLevel(sessionEntry?.thinkingLevel ?? agentCfg?.thinkingDefault) ??
|
||||||
},
|
(await resolveDefaultThinkingLevel());
|
||||||
});
|
const resolvedVerboseLevel =
|
||||||
|
normalizeVerboseLevel(sessionEntry?.verboseLevel ?? agentCfg?.verboseDefault) ?? "off";
|
||||||
let agentRunStarted = false;
|
const resolvedReasoningLevel =
|
||||||
void dispatchInboundMessage({
|
normalizeReasoningLevel(sessionEntry?.reasoningLevel) ?? "off";
|
||||||
ctx,
|
const resolvedElevatedLevel = normalizeElevatedLevel(
|
||||||
cfg,
|
sessionEntry?.elevatedLevel ?? agentCfg?.elevatedDefault,
|
||||||
dispatcher,
|
);
|
||||||
replyOptions: {
|
const elevated = resolveElevatedPermissions({
|
||||||
runId: clientRunId,
|
cfg,
|
||||||
abortSignal: abortController.signal,
|
agentId,
|
||||||
images: parsedImages.length > 0 ? parsedImages : undefined,
|
ctx,
|
||||||
disableBlockStreaming: true,
|
provider: INTERNAL_MESSAGE_CHANNEL,
|
||||||
onAgentRunStart: () => {
|
});
|
||||||
agentRunStarted = true;
|
const commandResult = await handleCommands({
|
||||||
},
|
ctx,
|
||||||
onModelSelected: (ctx) => {
|
cfg,
|
||||||
prefixContext.provider = ctx.provider;
|
command,
|
||||||
prefixContext.model = extractShortModelName(ctx.model);
|
agentId,
|
||||||
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
|
directives,
|
||||||
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
|
elevated,
|
||||||
},
|
sessionEntry,
|
||||||
},
|
previousSessionEntry: entry,
|
||||||
})
|
sessionStore: store,
|
||||||
.then(() => {
|
sessionKey: p.sessionKey,
|
||||||
if (!agentRunStarted) {
|
storePath,
|
||||||
const combinedReply = finalReplyParts
|
sessionScope: (cfg.session?.scope ?? "per-sender") as "per-sender" | "global",
|
||||||
.map((part) => part.trim())
|
workspaceDir: workspace.dir,
|
||||||
.filter(Boolean)
|
defaultGroupActivation: () => defaultGroupActivation(true),
|
||||||
.join("\n\n")
|
resolvedThinkLevel,
|
||||||
.trim();
|
resolvedVerboseLevel,
|
||||||
let message: Record<string, unknown> | undefined;
|
resolvedReasoningLevel,
|
||||||
if (combinedReply) {
|
resolvedElevatedLevel,
|
||||||
const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(
|
resolveDefaultThinkingLevel,
|
||||||
p.sessionKey,
|
provider,
|
||||||
);
|
model,
|
||||||
const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId;
|
contextTokens,
|
||||||
const appended = appendAssistantTranscriptMessage({
|
isGroup: false,
|
||||||
message: combinedReply,
|
});
|
||||||
sessionId,
|
if (!commandResult.shouldContinue) {
|
||||||
storePath: latestStorePath,
|
const text = commandResult.reply?.text ?? "";
|
||||||
sessionFile: latestEntry?.sessionFile,
|
const message = {
|
||||||
createIfMissing: true,
|
role: "assistant",
|
||||||
});
|
content: text.trim() ? [{ type: "text", text }] : [],
|
||||||
if (appended.ok) {
|
timestamp: Date.now(),
|
||||||
message = appended.message;
|
command: true,
|
||||||
} else {
|
};
|
||||||
context.logGateway.warn(
|
const payload = {
|
||||||
`webchat transcript append failed: ${appended.error ?? "unknown error"}`,
|
|
||||||
);
|
|
||||||
const now = Date.now();
|
|
||||||
message = {
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text: combinedReply }],
|
|
||||||
timestamp: now,
|
|
||||||
stopReason: "injected",
|
|
||||||
usage: { input: 0, output: 0, totalTokens: 0 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
broadcastChatFinal({
|
|
||||||
context,
|
|
||||||
runId: clientRunId,
|
runId: clientRunId,
|
||||||
sessionKey: p.sessionKey,
|
sessionKey: p.sessionKey,
|
||||||
|
seq: 0,
|
||||||
|
state: "final" as const,
|
||||||
message,
|
message,
|
||||||
|
};
|
||||||
|
context.broadcast("chat", payload);
|
||||||
|
context.nodeSendToSession(p.sessionKey, "chat", payload);
|
||||||
|
context.dedupe.set(`chat:${clientRunId}`, {
|
||||||
|
ts: Date.now(),
|
||||||
|
ok: true,
|
||||||
|
payload: { runId: clientRunId, status: "ok" as const },
|
||||||
});
|
});
|
||||||
|
context.chatAbortControllers.delete(clientRunId);
|
||||||
|
context.removeChatRun(clientRunId, clientRunId, p.sessionKey);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const payload = {
|
||||||
|
runId: clientRunId,
|
||||||
|
sessionKey: p.sessionKey,
|
||||||
|
seq: 0,
|
||||||
|
state: "error" as const,
|
||||||
|
errorMessage: formatForLog(err),
|
||||||
|
};
|
||||||
|
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
|
||||||
|
context.broadcast("chat", payload);
|
||||||
|
context.nodeSendToSession(p.sessionKey, "chat", payload);
|
||||||
|
context.dedupe.set(`chat:${clientRunId}`, {
|
||||||
|
ts: Date.now(),
|
||||||
|
ok: false,
|
||||||
|
payload: {
|
||||||
|
runId: clientRunId,
|
||||||
|
status: "error" as const,
|
||||||
|
summary: String(err),
|
||||||
|
},
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
context.chatAbortControllers.delete(clientRunId);
|
||||||
|
context.removeChatRun(clientRunId, clientRunId, p.sessionKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||||
|
const envelopedMessage = formatInboundEnvelope({
|
||||||
|
channel: "WebChat",
|
||||||
|
from: p.sessionKey,
|
||||||
|
timestamp: now,
|
||||||
|
body: parsedMessage,
|
||||||
|
chatType: "direct",
|
||||||
|
previousTimestamp: entry?.updatedAt,
|
||||||
|
envelope: envelopeOptions,
|
||||||
|
});
|
||||||
|
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
|
||||||
|
void agentCommand(
|
||||||
|
{
|
||||||
|
message: envelopedMessage,
|
||||||
|
images: parsedImages.length > 0 ? parsedImages : undefined,
|
||||||
|
sessionId,
|
||||||
|
sessionKey: p.sessionKey,
|
||||||
|
runId: clientRunId,
|
||||||
|
thinking: p.thinking,
|
||||||
|
deliver: p.deliver,
|
||||||
|
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
||||||
|
messageChannel: INTERNAL_MESSAGE_CHANNEL,
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
lane,
|
||||||
|
},
|
||||||
|
defaultRuntime,
|
||||||
|
context.deps,
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
context.dedupe.set(`chat:${clientRunId}`, {
|
context.dedupe.set(`chat:${clientRunId}`, {
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -556,12 +502,6 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
},
|
},
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
broadcastChatError({
|
|
||||||
context,
|
|
||||||
runId: clientRunId,
|
|
||||||
sessionKey: p.sessionKey,
|
|
||||||
errorMessage: String(err),
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
context.chatAbortControllers.delete(clientRunId);
|
context.chatAbortControllers.delete(clientRunId);
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import path from "node:path";
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
import {
|
import {
|
||||||
|
agentCommand,
|
||||||
connectOk,
|
connectOk,
|
||||||
getReplyFromConfig,
|
|
||||||
installGatewayTestHooks,
|
installGatewayTestHooks,
|
||||||
onceMessage,
|
onceMessage,
|
||||||
rpcReq,
|
rpcReq,
|
||||||
@@ -47,7 +47,7 @@ describe("gateway server chat", () => {
|
|||||||
async () => {
|
async () => {
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
const spy = vi.mocked(getReplyFromConfig);
|
const spy = vi.mocked(agentCommand);
|
||||||
const resetSpy = () => {
|
const resetSpy = () => {
|
||||||
spy.mockReset();
|
spy.mockReset();
|
||||||
spy.mockResolvedValue(undefined);
|
spy.mockResolvedValue(undefined);
|
||||||
@@ -122,9 +122,8 @@ describe("gateway server chat", () => {
|
|||||||
let abortInFlight: Promise<unknown> | undefined;
|
let abortInFlight: Promise<unknown> | undefined;
|
||||||
try {
|
try {
|
||||||
const callsBefore = spy.mock.calls.length;
|
const callsBefore = spy.mock.calls.length;
|
||||||
spy.mockImplementationOnce(async (_ctx, opts) => {
|
spy.mockImplementationOnce(async (opts) => {
|
||||||
opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-1");
|
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
||||||
const signal = opts?.abortSignal;
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
if (!signal) return resolve();
|
if (!signal) return resolve();
|
||||||
if (signal.aborted) return resolve();
|
if (signal.aborted) return resolve();
|
||||||
@@ -156,7 +155,7 @@ describe("gateway server chat", () => {
|
|||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (spy.mock.calls.length > callsBefore) return resolve();
|
if (spy.mock.calls.length > callsBefore) return resolve();
|
||||||
if (Date.now() > deadline)
|
if (Date.now() > deadline)
|
||||||
return reject(new Error("timeout waiting for getReplyFromConfig"));
|
return reject(new Error("timeout waiting for agentCommand"));
|
||||||
setTimeout(tick, 5);
|
setTimeout(tick, 5);
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
@@ -178,9 +177,8 @@ describe("gateway server chat", () => {
|
|||||||
sessionStoreSaveDelayMs.value = 120;
|
sessionStoreSaveDelayMs.value = 120;
|
||||||
resetSpy();
|
resetSpy();
|
||||||
try {
|
try {
|
||||||
spy.mockImplementationOnce(async (_ctx, opts) => {
|
spy.mockImplementationOnce(async (opts) => {
|
||||||
opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-save-1");
|
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
||||||
const signal = opts?.abortSignal;
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
if (!signal) return resolve();
|
if (!signal) return resolve();
|
||||||
if (signal.aborted) return resolve();
|
if (signal.aborted) return resolve();
|
||||||
@@ -217,9 +215,8 @@ describe("gateway server chat", () => {
|
|||||||
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } });
|
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } });
|
||||||
resetSpy();
|
resetSpy();
|
||||||
const callsBeforeStop = spy.mock.calls.length;
|
const callsBeforeStop = spy.mock.calls.length;
|
||||||
spy.mockImplementationOnce(async (_ctx, opts) => {
|
spy.mockImplementationOnce(async (opts) => {
|
||||||
opts?.onAgentRunStart?.(opts.runId ?? "idem-stop-1");
|
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
||||||
const signal = opts?.abortSignal;
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
if (!signal) return resolve();
|
if (!signal) return resolve();
|
||||||
if (signal.aborted) return resolve();
|
if (signal.aborted) return resolve();
|
||||||
@@ -264,8 +261,7 @@ describe("gateway server chat", () => {
|
|||||||
const runDone = new Promise<void>((resolve) => {
|
const runDone = new Promise<void>((resolve) => {
|
||||||
resolveRun = resolve;
|
resolveRun = resolve;
|
||||||
});
|
});
|
||||||
spy.mockImplementationOnce(async (_ctx, opts) => {
|
spy.mockImplementationOnce(async () => {
|
||||||
opts?.onAgentRunStart?.(opts.runId ?? "idem-status-1");
|
|
||||||
await runDone;
|
await runDone;
|
||||||
});
|
});
|
||||||
const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
|
const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
|
||||||
@@ -298,9 +294,8 @@ describe("gateway server chat", () => {
|
|||||||
}
|
}
|
||||||
expect(completed).toBe(true);
|
expect(completed).toBe(true);
|
||||||
resetSpy();
|
resetSpy();
|
||||||
spy.mockImplementationOnce(async (_ctx, opts) => {
|
spy.mockImplementationOnce(async (opts) => {
|
||||||
opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-all-1");
|
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
||||||
const signal = opts?.abortSignal;
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
if (!signal) return resolve();
|
if (!signal) return resolve();
|
||||||
if (signal.aborted) return resolve();
|
if (signal.aborted) return resolve();
|
||||||
@@ -364,9 +359,9 @@ describe("gateway server chat", () => {
|
|||||||
const agentStartedP = new Promise<void>((resolve) => {
|
const agentStartedP = new Promise<void>((resolve) => {
|
||||||
agentStartedResolve = resolve;
|
agentStartedResolve = resolve;
|
||||||
});
|
});
|
||||||
spy.mockImplementationOnce(async (_ctx, opts) => {
|
spy.mockImplementationOnce(async (opts) => {
|
||||||
agentStartedResolve?.();
|
agentStartedResolve?.();
|
||||||
const signal = opts?.abortSignal;
|
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
if (!signal) return resolve();
|
if (!signal) return resolve();
|
||||||
if (signal.aborted) return resolve();
|
if (signal.aborted) return resolve();
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { WebSocket } from "ws";
|
|||||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
|
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
|
||||||
import {
|
import {
|
||||||
|
agentCommand,
|
||||||
connectOk,
|
connectOk,
|
||||||
getReplyFromConfig,
|
|
||||||
installGatewayTestHooks,
|
installGatewayTestHooks,
|
||||||
onceMessage,
|
onceMessage,
|
||||||
rpcReq,
|
rpcReq,
|
||||||
@@ -71,7 +71,7 @@ describe("gateway server chat", () => {
|
|||||||
webchatWs.close();
|
webchatWs.close();
|
||||||
webchatWs = undefined;
|
webchatWs = undefined;
|
||||||
|
|
||||||
const spy = vi.mocked(getReplyFromConfig);
|
const spy = vi.mocked(agentCommand);
|
||||||
spy.mockClear();
|
spy.mockClear();
|
||||||
testState.agentConfig = { timeoutSeconds: 123 };
|
testState.agentConfig = { timeoutSeconds: 123 };
|
||||||
const callsBeforeTimeout = spy.mock.calls.length;
|
const callsBeforeTimeout = spy.mock.calls.length;
|
||||||
@@ -83,8 +83,8 @@ describe("gateway server chat", () => {
|
|||||||
expect(timeoutRes.ok).toBe(true);
|
expect(timeoutRes.ok).toBe(true);
|
||||||
|
|
||||||
await waitFor(() => spy.mock.calls.length > callsBeforeTimeout);
|
await waitFor(() => spy.mock.calls.length > callsBeforeTimeout);
|
||||||
const timeoutCall = spy.mock.calls.at(-1)?.[1] as { runId?: string } | undefined;
|
const timeoutCall = spy.mock.calls.at(-1)?.[0] as { timeout?: string } | undefined;
|
||||||
expect(timeoutCall?.runId).toBe("idem-timeout-1");
|
expect(timeoutCall?.timeout).toBe("123");
|
||||||
testState.agentConfig = undefined;
|
testState.agentConfig = undefined;
|
||||||
|
|
||||||
spy.mockClear();
|
spy.mockClear();
|
||||||
@@ -97,8 +97,8 @@ describe("gateway server chat", () => {
|
|||||||
expect(sessionRes.ok).toBe(true);
|
expect(sessionRes.ok).toBe(true);
|
||||||
|
|
||||||
await waitFor(() => spy.mock.calls.length > callsBeforeSession);
|
await waitFor(() => spy.mock.calls.length > callsBeforeSession);
|
||||||
const sessionCall = spy.mock.calls.at(-1)?.[0] as { SessionKey?: string } | undefined;
|
const sessionCall = spy.mock.calls.at(-1)?.[0] as { sessionKey?: string } | undefined;
|
||||||
expect(sessionCall?.SessionKey).toBe("agent:main:subagent:abc");
|
expect(sessionCall?.sessionKey).toBe("agent:main:subagent:abc");
|
||||||
|
|
||||||
const sendPolicyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const sendPolicyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
tempDirs.push(sendPolicyDir);
|
tempDirs.push(sendPolicyDir);
|
||||||
@@ -203,10 +203,10 @@ describe("gateway server chat", () => {
|
|||||||
expect(imgRes.payload?.runId).toBeDefined();
|
expect(imgRes.payload?.runId).toBeDefined();
|
||||||
|
|
||||||
await waitFor(() => spy.mock.calls.length > callsBeforeImage, 8000);
|
await waitFor(() => spy.mock.calls.length > callsBeforeImage, 8000);
|
||||||
const imgOpts = spy.mock.calls.at(-1)?.[1] as
|
const imgCall = spy.mock.calls.at(-1)?.[0] as
|
||||||
| { images?: Array<{ type: string; data: string; mimeType: string }> }
|
| { images?: Array<{ type: string; data: string; mimeType: string }> }
|
||||||
| undefined;
|
| undefined;
|
||||||
expect(imgOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
|
expect(imgCall?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
|
||||||
|
|
||||||
const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
tempDirs.push(historyDir);
|
tempDirs.push(historyDir);
|
||||||
|
|||||||
@@ -166,7 +166,6 @@ const hoisted = vi.hoisted(() => ({
|
|||||||
waitCalls: [] as string[],
|
waitCalls: [] as string[],
|
||||||
waitResults: new Map<string, boolean>(),
|
waitResults: new Map<string, boolean>(),
|
||||||
},
|
},
|
||||||
getReplyFromConfig: vi.fn().mockResolvedValue(undefined),
|
|
||||||
sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
|
sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -198,7 +197,6 @@ export const testTailnetIPv4 = hoisted.testTailnetIPv4;
|
|||||||
export const piSdkMock = hoisted.piSdkMock;
|
export const piSdkMock = hoisted.piSdkMock;
|
||||||
export const cronIsolatedRun = hoisted.cronIsolatedRun;
|
export const cronIsolatedRun = hoisted.cronIsolatedRun;
|
||||||
export const agentCommand = hoisted.agentCommand;
|
export const agentCommand = hoisted.agentCommand;
|
||||||
export const getReplyFromConfig = hoisted.getReplyFromConfig;
|
|
||||||
|
|
||||||
export const testState = {
|
export const testState = {
|
||||||
agentConfig: undefined as Record<string, unknown> | undefined,
|
agentConfig: undefined as Record<string, unknown> | undefined,
|
||||||
@@ -542,9 +540,6 @@ vi.mock("../channels/web/index.js", async () => {
|
|||||||
vi.mock("../commands/agent.js", () => ({
|
vi.mock("../commands/agent.js", () => ({
|
||||||
agentCommand,
|
agentCommand,
|
||||||
}));
|
}));
|
||||||
vi.mock("../auto-reply/reply.js", () => ({
|
|
||||||
getReplyFromConfig,
|
|
||||||
}));
|
|
||||||
vi.mock("../cli/deps.js", async () => {
|
vi.mock("../cli/deps.js", async () => {
|
||||||
const actual = await vi.importActual<typeof import("../cli/deps.js")>("../cli/deps.js");
|
const actual = await vi.importActual<typeof import("../cli/deps.js")>("../cli/deps.js");
|
||||||
const base = actual.createDefaultDeps();
|
const base = actual.createDefaultDeps();
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
import { resolveHumanDelayConfig } from "../../agents/identity.js";
|
import {
|
||||||
|
resolveEffectiveMessagesConfig,
|
||||||
|
resolveHumanDelayConfig,
|
||||||
|
resolveIdentityName,
|
||||||
|
} from "../../agents/identity.js";
|
||||||
|
import {
|
||||||
|
extractShortModelName,
|
||||||
|
type ResponsePrefixContext,
|
||||||
|
} from "../../auto-reply/reply/response-prefix-template.js";
|
||||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||||
import { hasControlCommand } from "../../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../../auto-reply/command-detection.js";
|
||||||
import {
|
import {
|
||||||
@@ -12,26 +20,28 @@ import {
|
|||||||
createInboundDebouncer,
|
createInboundDebouncer,
|
||||||
resolveInboundDebounceMs,
|
resolveInboundDebounceMs,
|
||||||
} from "../../auto-reply/inbound-debounce.js";
|
} from "../../auto-reply/inbound-debounce.js";
|
||||||
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
|
||||||
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
||||||
import {
|
import {
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
clearHistoryEntriesIfEnabled,
|
clearHistoryEntries,
|
||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
recordPendingHistoryEntryIfEnabled,
|
recordPendingHistoryEntry,
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
} from "../../auto-reply/reply/history.js";
|
} from "../../auto-reply/reply/history.js";
|
||||||
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
|
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
|
||||||
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||||
import { logInboundDrop } from "../../channels/logging.js";
|
|
||||||
import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
|
|
||||||
import { recordInboundSession } from "../../channels/session.js";
|
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import {
|
import {
|
||||||
resolveChannelGroupPolicy,
|
resolveChannelGroupPolicy,
|
||||||
resolveChannelGroupRequireMention,
|
resolveChannelGroupRequireMention,
|
||||||
} from "../../config/group-policy.js";
|
} from "../../config/group-policy.js";
|
||||||
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
|
import {
|
||||||
|
readSessionUpdatedAt,
|
||||||
|
recordSessionMetaFromInbound,
|
||||||
|
resolveStorePath,
|
||||||
|
updateLastRoute,
|
||||||
|
} from "../../config/sessions.js";
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||||
import { waitForTransportReady } from "../../infra/transport-ready.js";
|
import { waitForTransportReady } from "../../infra/transport-ready.js";
|
||||||
import { mediaKindFromMime } from "../../media/constants.js";
|
import { mediaKindFromMime } from "../../media/constants.js";
|
||||||
@@ -42,7 +52,7 @@ import {
|
|||||||
} from "../../pairing/pairing-store.js";
|
} from "../../pairing/pairing-store.js";
|
||||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||||
import { truncateUtf16Safe } from "../../utils.js";
|
import { truncateUtf16Safe } from "../../utils.js";
|
||||||
import { resolveControlCommandGate } from "../../channels/command-gating.js";
|
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
||||||
import { resolveIMessageAccount } from "../accounts.js";
|
import { resolveIMessageAccount } from "../accounts.js";
|
||||||
import { createIMessageRpcClient } from "../client.js";
|
import { createIMessageRpcClient } from "../client.js";
|
||||||
import { probeIMessage } from "../probe.js";
|
import { probeIMessage } from "../probe.js";
|
||||||
@@ -373,44 +383,41 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
chatIdentifier,
|
chatIdentifier,
|
||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
const hasControlCommandInMessage = hasControlCommand(messageText, cfg);
|
const commandAuthorized = isGroup
|
||||||
const commandGate = resolveControlCommandGate({
|
? resolveCommandAuthorizedFromAuthorizers({
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
authorizers: [
|
authorizers: [
|
||||||
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
||||||
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
||||||
],
|
],
|
||||||
allowTextCommands: true,
|
})
|
||||||
hasControlCommand: hasControlCommandInMessage,
|
: dmAuthorized;
|
||||||
});
|
if (isGroup && hasControlCommand(messageText, cfg) && !commandAuthorized) {
|
||||||
const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
|
logVerbose(`imessage: drop control command from unauthorized sender ${sender}`);
|
||||||
if (isGroup && commandGate.shouldBlock) {
|
|
||||||
logInboundDrop({
|
|
||||||
log: logVerbose,
|
|
||||||
channel: "imessage",
|
|
||||||
reason: "control command (unauthorized)",
|
|
||||||
target: sender,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const shouldBypassMention =
|
const shouldBypassMention =
|
||||||
isGroup && requireMention && !mentioned && commandAuthorized && hasControlCommandInMessage;
|
isGroup &&
|
||||||
|
requireMention &&
|
||||||
|
!mentioned &&
|
||||||
|
commandAuthorized &&
|
||||||
|
hasControlCommand(messageText);
|
||||||
const effectiveWasMentioned = mentioned || shouldBypassMention;
|
const effectiveWasMentioned = mentioned || shouldBypassMention;
|
||||||
if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) {
|
if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) {
|
||||||
logVerbose(`imessage: skipping group message (no mention)`);
|
logVerbose(`imessage: skipping group message (no mention)`);
|
||||||
recordPendingHistoryEntryIfEnabled({
|
if (historyKey && historyLimit > 0) {
|
||||||
historyMap: groupHistories,
|
recordPendingHistoryEntry({
|
||||||
historyKey: historyKey ?? "",
|
historyMap: groupHistories,
|
||||||
limit: historyLimit,
|
historyKey,
|
||||||
entry: historyKey
|
limit: historyLimit,
|
||||||
? {
|
entry: {
|
||||||
sender: senderNormalized,
|
sender: senderNormalized,
|
||||||
body: bodyText,
|
body: bodyText,
|
||||||
timestamp: createdAt,
|
timestamp: createdAt,
|
||||||
messageId: message.id ? String(message.id) : undefined,
|
messageId: message.id ? String(message.id) : undefined,
|
||||||
}
|
},
|
||||||
: null,
|
});
|
||||||
});
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,7 +454,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
envelope: envelopeOptions,
|
envelope: envelopeOptions,
|
||||||
});
|
});
|
||||||
let combinedBody = body;
|
let combinedBody = body;
|
||||||
if (isGroup && historyKey) {
|
if (isGroup && historyKey && historyLimit > 0) {
|
||||||
combinedBody = buildPendingHistoryContextFromMap({
|
combinedBody = buildPendingHistoryContextFromMap({
|
||||||
historyMap: groupHistories,
|
historyMap: groupHistories,
|
||||||
historyKey,
|
historyKey,
|
||||||
@@ -502,25 +509,30 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
OriginatingTo: imessageTo,
|
OriginatingTo: imessageTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateTarget = (isGroup ? chatTarget : undefined) || sender;
|
void recordSessionMetaFromInbound({
|
||||||
await recordInboundSession({
|
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
updateLastRoute:
|
}).catch((err) => {
|
||||||
!isGroup && updateTarget
|
logVerbose(`imessage: failed updating session meta: ${String(err)}`);
|
||||||
? {
|
|
||||||
sessionKey: route.mainSessionKey,
|
|
||||||
channel: "imessage",
|
|
||||||
to: updateTarget,
|
|
||||||
accountId: route.accountId,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onRecordError: (err) => {
|
|
||||||
logVerbose(`imessage: failed updating session meta: ${String(err)}`);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!isGroup) {
|
||||||
|
const to = (isGroup ? chatTarget : undefined) || sender;
|
||||||
|
if (to) {
|
||||||
|
await updateLastRoute({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.mainSessionKey,
|
||||||
|
deliveryContext: {
|
||||||
|
channel: "imessage",
|
||||||
|
to,
|
||||||
|
accountId: route.accountId,
|
||||||
|
},
|
||||||
|
ctx: ctxPayload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldLogVerbose()) {
|
if (shouldLogVerbose()) {
|
||||||
const preview = truncateUtf16Safe(body, 200).replace(/\n/g, "\\n");
|
const preview = truncateUtf16Safe(body, 200).replace(/\n/g, "\\n");
|
||||||
logVerbose(
|
logVerbose(
|
||||||
@@ -528,11 +540,14 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
|
// Create mutable context for response prefix template interpolation
|
||||||
|
let prefixContext: ResponsePrefixContext = {
|
||||||
|
identityName: resolveIdentityName(cfg, route.agentId),
|
||||||
|
};
|
||||||
|
|
||||||
const dispatcher = createReplyDispatcher({
|
const dispatcher = createReplyDispatcher({
|
||||||
responsePrefix: prefixContext.responsePrefix,
|
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
responsePrefixContextProvider: () => prefixContext,
|
||||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
await deliverReplies({
|
await deliverReplies({
|
||||||
@@ -550,7 +565,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { queuedFinal } = await dispatchInboundMessage({
|
const { queuedFinal } = await dispatchReplyFromConfig({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
@@ -559,21 +574,23 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
typeof accountInfo.config.blockStreaming === "boolean"
|
typeof accountInfo.config.blockStreaming === "boolean"
|
||||||
? !accountInfo.config.blockStreaming
|
? !accountInfo.config.blockStreaming
|
||||||
: undefined,
|
: undefined,
|
||||||
onModelSelected: prefixContext.onModelSelected,
|
onModelSelected: (ctx) => {
|
||||||
|
// Mutate the object directly instead of reassigning to ensure the closure sees updates
|
||||||
|
prefixContext.provider = ctx.provider;
|
||||||
|
prefixContext.model = extractShortModelName(ctx.model);
|
||||||
|
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
|
||||||
|
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!queuedFinal) {
|
if (!queuedFinal) {
|
||||||
if (isGroup && historyKey) {
|
if (isGroup && historyKey && historyLimit > 0) {
|
||||||
clearHistoryEntriesIfEnabled({
|
clearHistoryEntries({ historyMap: groupHistories, historyKey });
|
||||||
historyMap: groupHistories,
|
|
||||||
historyKey,
|
|
||||||
limit: historyLimit,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isGroup && historyKey) {
|
if (isGroup && historyKey && historyLimit > 0) {
|
||||||
clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
|
clearHistoryEntries({ historyMap: groupHistories, historyKey });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -793,4 +793,209 @@ describe("runHeartbeatOnce", () => {
|
|||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips heartbeat when HEARTBEAT.md is effectively empty (saves API calls)", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
const workspaceDir = path.join(tmpDir, "workspace");
|
||||||
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
|
try {
|
||||||
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
|
|
||||||
|
// Create effectively empty HEARTBEAT.md (only header and comments)
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workspaceDir, "HEARTBEAT.md"),
|
||||||
|
"# HEARTBEAT.md\n\n## Tasks\n\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
workspace: workspaceDir,
|
||||||
|
heartbeat: { every: "5m", target: "whatsapp" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
session: { store: storePath },
|
||||||
|
};
|
||||||
|
const sessionKey = resolveMainSessionKey(cfg);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId: "sid",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "whatsapp",
|
||||||
|
lastTo: "+1555",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||||
|
messageId: "m1",
|
||||||
|
toJid: "jid",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await runHeartbeatOnce({
|
||||||
|
cfg,
|
||||||
|
deps: {
|
||||||
|
sendWhatsApp,
|
||||||
|
getQueueSize: () => 0,
|
||||||
|
nowMs: () => 0,
|
||||||
|
webAuthExists: async () => true,
|
||||||
|
hasActiveWebListener: () => true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should skip without making API call
|
||||||
|
expect(res.status).toBe("skipped");
|
||||||
|
if (res.status === "skipped") {
|
||||||
|
expect(res.reason).toBe("empty-heartbeat-file");
|
||||||
|
}
|
||||||
|
expect(replySpy).not.toHaveBeenCalled();
|
||||||
|
expect(sendWhatsApp).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
replySpy.mockRestore();
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs heartbeat when HEARTBEAT.md has actionable content", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
const workspaceDir = path.join(tmpDir, "workspace");
|
||||||
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
|
try {
|
||||||
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
|
|
||||||
|
// Create HEARTBEAT.md with actionable content
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workspaceDir, "HEARTBEAT.md"),
|
||||||
|
"# HEARTBEAT.md\n\n- Check server logs\n- Review pending PRs\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
workspace: workspaceDir,
|
||||||
|
heartbeat: { every: "5m", target: "whatsapp" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
session: { store: storePath },
|
||||||
|
};
|
||||||
|
const sessionKey = resolveMainSessionKey(cfg);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId: "sid",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "whatsapp",
|
||||||
|
lastTo: "+1555",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
replySpy.mockResolvedValue({ text: "Checked logs and PRs" });
|
||||||
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||||
|
messageId: "m1",
|
||||||
|
toJid: "jid",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await runHeartbeatOnce({
|
||||||
|
cfg,
|
||||||
|
deps: {
|
||||||
|
sendWhatsApp,
|
||||||
|
getQueueSize: () => 0,
|
||||||
|
nowMs: () => 0,
|
||||||
|
webAuthExists: async () => true,
|
||||||
|
hasActiveWebListener: () => true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should run and make API call
|
||||||
|
expect(res.status).toBe("ran");
|
||||||
|
expect(replySpy).toHaveBeenCalled();
|
||||||
|
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
replySpy.mockRestore();
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs heartbeat when HEARTBEAT.md does not exist (lets LLM decide)", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
const workspaceDir = path.join(tmpDir, "workspace");
|
||||||
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
|
try {
|
||||||
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
|
// Don't create HEARTBEAT.md - it doesn't exist
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
workspace: workspaceDir,
|
||||||
|
heartbeat: { every: "5m", target: "whatsapp" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
session: { store: storePath },
|
||||||
|
};
|
||||||
|
const sessionKey = resolveMainSessionKey(cfg);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId: "sid",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "whatsapp",
|
||||||
|
lastTo: "+1555",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
|
||||||
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||||
|
messageId: "m1",
|
||||||
|
toJid: "jid",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await runHeartbeatOnce({
|
||||||
|
cfg,
|
||||||
|
deps: {
|
||||||
|
sendWhatsApp,
|
||||||
|
getQueueSize: () => 0,
|
||||||
|
nowMs: () => 0,
|
||||||
|
webAuthExists: async () => true,
|
||||||
|
hasActiveWebListener: () => true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should run (not skip) - let LLM decide since file doesn't exist
|
||||||
|
expect(res.status).toBe("ran");
|
||||||
|
expect(replySpy).toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
replySpy.mockRestore();
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveAgentConfig,
|
||||||
|
resolveAgentWorkspaceDir,
|
||||||
|
resolveDefaultAgentId,
|
||||||
|
} from "../agents/agent-scope.js";
|
||||||
import { resolveUserTimezone } from "../agents/date-time.js";
|
import { resolveUserTimezone } from "../agents/date-time.js";
|
||||||
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
||||||
|
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||||
DEFAULT_HEARTBEAT_EVERY,
|
DEFAULT_HEARTBEAT_EVERY,
|
||||||
|
isHeartbeatContentEffectivelyEmpty,
|
||||||
resolveHeartbeatPrompt as resolveHeartbeatPromptText,
|
resolveHeartbeatPrompt as resolveHeartbeatPromptText,
|
||||||
stripHeartbeatToken,
|
stripHeartbeatToken,
|
||||||
} from "../auto-reply/heartbeat.js";
|
} from "../auto-reply/heartbeat.js";
|
||||||
@@ -440,6 +449,25 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
return { status: "skipped", reason: "requests-in-flight" };
|
return { status: "skipped", reason: "requests-in-flight" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip heartbeat if HEARTBEAT.md exists but has no actionable content.
|
||||||
|
// This saves API calls/costs when the file is effectively empty (only comments/headers).
|
||||||
|
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||||
|
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
||||||
|
try {
|
||||||
|
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
|
||||||
|
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent)) {
|
||||||
|
emitHeartbeatEvent({
|
||||||
|
status: "skipped",
|
||||||
|
reason: "empty-heartbeat-file",
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
return { status: "skipped", reason: "empty-heartbeat-file" };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist or can't be read - proceed with heartbeat.
|
||||||
|
// The LLM prompt says "if it exists" so this is expected behavior.
|
||||||
|
}
|
||||||
|
|
||||||
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
|
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
|
||||||
const previousUpdatedAt = entry?.updatedAt;
|
const previousUpdatedAt = entry?.updatedAt;
|
||||||
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
||||||
|
|||||||
@@ -108,10 +108,8 @@ export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js";
|
|||||||
export {
|
export {
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
clearHistoryEntries,
|
clearHistoryEntries,
|
||||||
clearHistoryEntriesIfEnabled,
|
|
||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
recordPendingHistoryEntry,
|
recordPendingHistoryEntry,
|
||||||
recordPendingHistoryEntryIfEnabled,
|
|
||||||
} from "../auto-reply/reply/history.js";
|
} from "../auto-reply/reply/history.js";
|
||||||
export type { HistoryEntry } from "../auto-reply/reply/history.js";
|
export type { HistoryEntry } from "../auto-reply/reply/history.js";
|
||||||
export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js";
|
export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js";
|
||||||
@@ -119,23 +117,9 @@ export {
|
|||||||
resolveMentionGating,
|
resolveMentionGating,
|
||||||
resolveMentionGatingWithBypass,
|
resolveMentionGatingWithBypass,
|
||||||
} from "../channels/mention-gating.js";
|
} from "../channels/mention-gating.js";
|
||||||
export type {
|
|
||||||
AckReactionGateParams,
|
|
||||||
AckReactionScope,
|
|
||||||
WhatsAppAckReactionMode,
|
|
||||||
} from "../channels/ack-reactions.js";
|
|
||||||
export {
|
|
||||||
removeAckReactionAfterReply,
|
|
||||||
shouldAckReaction,
|
|
||||||
shouldAckReactionForWhatsApp,
|
|
||||||
} from "../channels/ack-reactions.js";
|
|
||||||
export { createTypingCallbacks } from "../channels/typing.js";
|
|
||||||
export { createReplyPrefixContext } from "../channels/reply-prefix.js";
|
|
||||||
export { logAckFailure, logInboundDrop, logTypingFailure } from "../channels/logging.js";
|
|
||||||
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
|
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
|
||||||
export type { NormalizedLocation } from "../channels/location.js";
|
export type { NormalizedLocation } from "../channels/location.js";
|
||||||
export { formatLocationText, toLocationContext } from "../channels/location.js";
|
export { formatLocationText, toLocationContext } from "../channels/location.js";
|
||||||
export { resolveControlCommandGate } from "../channels/command-gating.js";
|
|
||||||
export {
|
export {
|
||||||
resolveBlueBubblesGroupRequireMention,
|
resolveBlueBubblesGroupRequireMention,
|
||||||
resolveDiscordGroupRequireMention,
|
resolveDiscordGroupRequireMention,
|
||||||
@@ -144,7 +128,6 @@ export {
|
|||||||
resolveTelegramGroupRequireMention,
|
resolveTelegramGroupRequireMention,
|
||||||
resolveWhatsAppGroupRequireMention,
|
resolveWhatsAppGroupRequireMention,
|
||||||
} from "../channels/plugins/group-mentions.js";
|
} from "../channels/plugins/group-mentions.js";
|
||||||
export { recordInboundSession } from "../channels/session.js";
|
|
||||||
export {
|
export {
|
||||||
buildChannelKeyCandidates,
|
buildChannelKeyCandidates,
|
||||||
normalizeChannelSlug,
|
normalizeChannelSlug,
|
||||||
|
|||||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user