Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee2cfa5ca1 | ||
|
|
0e1ebc7da1 | ||
|
|
0e3e6f6b70 | ||
|
|
337dd41ce9 | ||
|
|
88336ef4ea | ||
|
|
5ee03c82b4 | ||
|
|
111aeb2c4f | ||
|
|
23c2c638b7 | ||
|
|
6b8299eb33 | ||
|
|
9822a53649 | ||
|
|
81d392a2d7 | ||
|
|
dbcec3ffaf | ||
|
|
50c5231486 | ||
|
|
55d034358d | ||
|
|
374da34936 | ||
|
|
c578fca687 | ||
|
|
dd18765b50 | ||
|
|
3e06fe84dc | ||
|
|
640e19988f | ||
|
|
de33bc70e7 | ||
|
|
0c8ba6599b | ||
|
|
d1e9490f95 | ||
|
|
a9fc2ca0ef | ||
|
|
dd1b08b3e8 | ||
|
|
af1004ebbd | ||
|
|
f3516fb316 | ||
|
|
79d8267413 | ||
|
|
99bf65c539 | ||
|
|
6a4b5fa4b5 | ||
|
|
83511c0c09 | ||
|
|
1fec41b3df | ||
|
|
c532d161c4 | ||
|
|
989543c9c3 | ||
|
|
145adf540f | ||
|
|
953472bf25 | ||
|
|
d9384785a3 | ||
|
|
2f6b5ffdfe | ||
|
|
0e3c9e4a0e | ||
|
|
15311c138a | ||
|
|
dec71dbcf1 | ||
|
|
5a4482412d | ||
|
|
4cf829608c | ||
|
|
8de02e6074 | ||
|
|
d802844bd6 | ||
|
|
e97bcf4dae | ||
|
|
dad8e11f1e | ||
|
|
50fdd514ae | ||
|
|
ee36e12f81 | ||
|
|
1e5569d56a | ||
|
|
3ce1ee84ac | ||
|
|
1ea3ac0a1d | ||
|
|
66b6c9e0e5 | ||
|
|
b5e99dad1f | ||
|
|
af96bac2dd |
No files matched your search
@@ -0,0 +1 @@
|
||||
src/canvas-host/a2ui/a2ui.bundle.js
|
||||
@@ -66,7 +66,7 @@
|
||||
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
||||
|
||||
## Shorthand Commands
|
||||
- `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||
|
||||
### PR Workflow (Review vs Land)
|
||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||
@@ -84,6 +84,7 @@
|
||||
|
||||
## Agent-Specific Notes
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
@@ -115,8 +116,7 @@
|
||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||
- Voice wake forwarding tips:
|
||||
@@ -132,19 +132,3 @@
|
||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||
- Kill the tmux session after publish.
|
||||
|
||||
## Exclamation Mark Escaping Workaround
|
||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
||||
|
||||
```bash
|
||||
# WRONG - will send "Hello\\!" with backslash
|
||||
clawdbot message send --to "+1234" --message 'Hello!'
|
||||
|
||||
# CORRECT - use heredoc to avoid escaping
|
||||
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
|
||||
Hello!
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
This is a Claude Code quirk, not a clawdbot bug.
|
||||
+24
-1
@@ -2,6 +2,26 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.19-1
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
|
||||
|
||||
### Changes
|
||||
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
||||
|
||||
### Fixes
|
||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
||||
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
||||
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
||||
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
||||
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
||||
- Voice call: keep Twilio notify callbacks out of streaming paths, normalize Tailscale serve paths, and honor ASCII signature ordering. (#1213) — thanks @andrew-kurin.
|
||||
|
||||
## 2026.1.18-5
|
||||
|
||||
### Changes
|
||||
@@ -16,9 +36,12 @@ Docs: https://docs.clawd.bot
|
||||
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
||||
- macOS: use label colors for session preview text so previews render in menu subviews.
|
||||
- macOS: suppress usage error text in the menubar cost view.
|
||||
- macOS: fix onboarding action link labels for channels vs skills. (#1197) — thanks @chriseidhof.
|
||||
- Telegram: honor pairing allowlists for native slash commands.
|
||||
- TUI: highlight model search matches and stabilize search ordering.
|
||||
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
|
||||
- Slack: resolve Bolt import interop for Bun + Node. (#1191) — thanks @CoreyH.
|
||||
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
|
||||
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
|
||||
|
||||
## 2026.1.18-4
|
||||
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||
<a href="https://deepwiki.com/clawdbot/clawdbot"><img src="https://img.shields.io/badge/DeepWiki-clawdbot-111111?style=for-the-badge" alt="DeepWiki"></a>
|
||||
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
@@ -64,7 +65,7 @@ clawdbot gateway --port 18789 --verbose
|
||||
# Send a message
|
||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
|
||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
||||
clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
@@ -106,7 +107,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||
@@ -128,7 +129,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
||||
|
||||
### Channels
|
||||
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
|
||||
|
||||
### Apps + nodes
|
||||
@@ -159,7 +160,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import Charts
|
||||
import SwiftUI
|
||||
|
||||
struct CostUsageHistoryMenuView: View {
|
||||
let summary: GatewayCostUsageSummary
|
||||
let width: CGFloat
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.header
|
||||
self.chart
|
||||
self.footer
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(width: max(1, self.width), alignment: .leading)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
let todayKey = CostUsageMenuDateParser.format(Date())
|
||||
let todayEntry = self.summary.daily.first { $0.date == todayKey }
|
||||
let todayCost = CostUsageFormatting.formatUsd(todayEntry?.totalCost) ?? "n/a"
|
||||
let totalCost = CostUsageFormatting.formatUsd(self.summary.totals.totalCost) ?? "n/a"
|
||||
|
||||
return HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Today")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(todayCost)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Last \(self.summary.days)d")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(totalCost)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var chart: some View {
|
||||
let entries = self.summary.daily.compactMap { entry -> (Date, Double)? in
|
||||
guard let date = CostUsageMenuDateParser.parse(entry.date) else { return nil }
|
||||
return (date, entry.totalCost)
|
||||
}
|
||||
|
||||
return Chart(entries, id: \.0) { entry in
|
||||
BarMark(
|
||||
x: .value("Day", entry.0),
|
||||
y: .value("Cost", entry.1))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.cornerRadius(3)
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .day, count: 7)) {
|
||||
AxisGridLine().foregroundStyle(.clear)
|
||||
AxisValueLabel(format: .dateTime.month().day())
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) {
|
||||
AxisGridLine()
|
||||
AxisValueLabel()
|
||||
}
|
||||
}
|
||||
.frame(height: 110)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
if self.summary.totals.missingCostEntries == 0 {
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
return AnyView(
|
||||
Text("Partial: \(self.summary.totals.missingCostEntries) entries missing cost")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary))
|
||||
}
|
||||
}
|
||||
|
||||
private enum CostUsageMenuDateParser {
|
||||
static let formatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone.current
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static func parse(_ value: String) -> Date? {
|
||||
self.formatter.date(from: value)
|
||||
}
|
||||
|
||||
static func format(_ date: Date) -> String {
|
||||
self.formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
private var cachedUsageErrorText: String?
|
||||
private var usageCacheUpdatedAt: Date?
|
||||
private let usageRefreshIntervalSeconds: TimeInterval = 30
|
||||
private var cachedCostSummary: GatewayCostUsageSummary?
|
||||
private var cachedCostErrorText: String?
|
||||
private var costCacheUpdatedAt: Date?
|
||||
private let costRefreshIntervalSeconds: TimeInterval = 45
|
||||
private let nodesStore = NodesStore.shared
|
||||
#if DEBUG
|
||||
private var testControlChannelConnected: Bool?
|
||||
@@ -64,6 +68,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
guard let self else { return }
|
||||
await self.refreshCache(force: forceRefresh)
|
||||
await self.refreshUsageCache(force: forceRefresh)
|
||||
await self.refreshCostUsageCache(force: forceRefresh)
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.inject(into: menu)
|
||||
@@ -200,6 +205,7 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
|
||||
cursor = self.insertCostUsageSection(into: menu, at: cursor, width: width)
|
||||
|
||||
DispatchQueue.main.async { [weak self, weak headerView] in
|
||||
guard let self, let headerView else { return }
|
||||
@@ -344,6 +350,28 @@ extension MenuSessionsInjector {
|
||||
return cursor
|
||||
}
|
||||
|
||||
private func insertCostUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
||||
guard self.isControlChannelConnected else { return cursor }
|
||||
guard let submenu = self.buildCostUsageSubmenu(width: width) else { return cursor }
|
||||
var cursor = cursor
|
||||
|
||||
if cursor > 0, !menu.items[cursor - 1].isSeparatorItem {
|
||||
let separator = NSMenuItem.separator()
|
||||
separator.tag = self.tag
|
||||
menu.insertItem(separator, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
let item = NSMenuItem(title: "Usage cost (30 days)", action: nil, keyEquivalent: "")
|
||||
item.tag = self.tag
|
||||
item.isEnabled = true
|
||||
item.image = NSImage(systemSymbolName: "chart.bar.xaxis", accessibilityDescription: nil)
|
||||
item.submenu = submenu
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
return cursor
|
||||
}
|
||||
|
||||
private var selectedUsageProviderId: String? {
|
||||
guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil }
|
||||
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -393,6 +421,36 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
}
|
||||
|
||||
private func buildCostUsageSubmenu(width: CGFloat) -> NSMenu? {
|
||||
if let error = self.cachedCostErrorText, !error.isEmpty, self.cachedCostSummary == nil {
|
||||
let menu = NSMenu()
|
||||
let item = NSMenuItem(title: error, action: nil, keyEquivalent: "")
|
||||
item.isEnabled = false
|
||||
menu.addItem(item)
|
||||
return menu
|
||||
}
|
||||
|
||||
guard let summary = self.cachedCostSummary else { return nil }
|
||||
guard !summary.daily.isEmpty else { return nil }
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.delegate = self
|
||||
|
||||
let chartView = CostUsageHistoryMenuView(summary: summary, width: width)
|
||||
let hosting = NSHostingView(rootView: AnyView(chartView))
|
||||
let controller = NSHostingController(rootView: AnyView(chartView))
|
||||
let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||
|
||||
let chartItem = NSMenuItem()
|
||||
chartItem.view = hosting
|
||||
chartItem.isEnabled = false
|
||||
chartItem.representedObject = "costUsageChart"
|
||||
menu.addItem(chartItem)
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
private func gatewayEntry() -> NodeInfo? {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
let isConnected = self.isControlChannelConnected
|
||||
@@ -581,6 +639,31 @@ extension MenuSessionsInjector {
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
}
|
||||
|
||||
private func refreshCostUsageCache(force: Bool) async {
|
||||
if !force,
|
||||
let updated = self.costCacheUpdatedAt,
|
||||
Date().timeIntervalSince(updated) < self.costRefreshIntervalSeconds
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedCostSummary = nil
|
||||
self.cachedCostErrorText = nil
|
||||
self.costCacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
self.cachedCostSummary = try await CostUsageLoader.loadSummary()
|
||||
self.cachedCostErrorText = nil
|
||||
} catch {
|
||||
self.cachedCostSummary = nil
|
||||
self.cachedCostErrorText = self.compactUsageError(error)
|
||||
}
|
||||
self.costCacheUpdatedAt = Date()
|
||||
}
|
||||
|
||||
private func compactUsageError(_ error: Error) -> String {
|
||||
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if message.isEmpty { return "Usage unavailable" }
|
||||
|
||||
@@ -78,6 +78,7 @@ extension OnboardingView {
|
||||
title: "Action",
|
||||
subtitle: "Action subtitle",
|
||||
systemImage: "gearshape",
|
||||
buttonTitle: "Action",
|
||||
action: {})
|
||||
_ = view.gatewaySubtitle(for: gateway)
|
||||
_ = view.isSelectedGateway(gateway)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
|
||||
struct GatewayCostUsageTotals: Codable {
|
||||
let input: Int
|
||||
let output: Int
|
||||
let cacheRead: Int
|
||||
let cacheWrite: Int
|
||||
let totalTokens: Int
|
||||
let totalCost: Double
|
||||
let missingCostEntries: Int
|
||||
}
|
||||
|
||||
struct GatewayCostUsageDay: Codable {
|
||||
let date: String
|
||||
let input: Int
|
||||
let output: Int
|
||||
let cacheRead: Int
|
||||
let cacheWrite: Int
|
||||
let totalTokens: Int
|
||||
let totalCost: Double
|
||||
let missingCostEntries: Int
|
||||
}
|
||||
|
||||
struct GatewayCostUsageSummary: Codable {
|
||||
let updatedAt: Double
|
||||
let days: Int
|
||||
let daily: [GatewayCostUsageDay]
|
||||
let totals: GatewayCostUsageTotals
|
||||
}
|
||||
|
||||
enum CostUsageFormatting {
|
||||
static func formatUsd(_ value: Double?) -> String? {
|
||||
guard let value, value.isFinite else { return nil }
|
||||
if value >= 1 { return String(format: "$%.2f", value) }
|
||||
if value >= 0.01 { return String(format: "$%.2f", value) }
|
||||
return String(format: "$%.4f", value)
|
||||
}
|
||||
|
||||
static func formatTokenCount(_ value: Int?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let safe = max(0, value)
|
||||
if safe >= 1_000_000 { return String(format: "%.1fm", Double(safe) / 1_000_000.0) }
|
||||
if safe >= 1000 { return safe >= 10000
|
||||
? String(format: "%.0fk", Double(safe) / 1000.0)
|
||||
: String(format: "%.1fk", Double(safe) / 1000.0)
|
||||
}
|
||||
return String(safe)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum CostUsageLoader {
|
||||
static func loadSummary() async throws -> GatewayCostUsageSummary {
|
||||
let data = try await ControlChannel.shared.request(
|
||||
method: "usage.cost",
|
||||
params: nil,
|
||||
timeoutMs: 7000)
|
||||
return try JSONDecoder().decode(GatewayCostUsageSummary.self, from: data)
|
||||
}
|
||||
}
|
||||
@@ -424,14 +424,18 @@ public struct PollParams: Codable, Sendable {
|
||||
|
||||
public struct AgentParams: Codable, Sendable {
|
||||
public let message: String
|
||||
public let agentid: String?
|
||||
public let to: String?
|
||||
public let replyto: String?
|
||||
public let sessionid: String?
|
||||
public let sessionkey: String?
|
||||
public let thinking: String?
|
||||
public let deliver: Bool?
|
||||
public let attachments: [AnyCodable]?
|
||||
public let channel: String?
|
||||
public let replychannel: String?
|
||||
public let accountid: String?
|
||||
public let replyaccountid: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
@@ -441,14 +445,18 @@ public struct AgentParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
message: String,
|
||||
agentid: String?,
|
||||
to: String?,
|
||||
replyto: String?,
|
||||
sessionid: String?,
|
||||
sessionkey: String?,
|
||||
thinking: String?,
|
||||
deliver: Bool?,
|
||||
attachments: [AnyCodable]?,
|
||||
channel: String?,
|
||||
replychannel: String?,
|
||||
accountid: String?,
|
||||
replyaccountid: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
@@ -457,14 +465,18 @@ public struct AgentParams: Codable, Sendable {
|
||||
spawnedby: String?
|
||||
) {
|
||||
self.message = message
|
||||
self.agentid = agentid
|
||||
self.to = to
|
||||
self.replyto = replyto
|
||||
self.sessionid = sessionid
|
||||
self.sessionkey = sessionkey
|
||||
self.thinking = thinking
|
||||
self.deliver = deliver
|
||||
self.attachments = attachments
|
||||
self.channel = channel
|
||||
self.replychannel = replychannel
|
||||
self.accountid = accountid
|
||||
self.replyaccountid = replyaccountid
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
@@ -474,14 +486,18 @@ public struct AgentParams: Codable, Sendable {
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case message
|
||||
case agentid = "agentId"
|
||||
case to
|
||||
case replyto = "replyTo"
|
||||
case sessionid = "sessionId"
|
||||
case sessionkey = "sessionKey"
|
||||
case thinking
|
||||
case deliver
|
||||
case attachments
|
||||
case channel
|
||||
case replychannel = "replyChannel"
|
||||
case accountid = "accountId"
|
||||
case replyaccountid = "replyAccountId"
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ clawdbot gateway
|
||||
Notes:
|
||||
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
|
||||
- Binding beyond loopback without auth is blocked (safety guardrail).
|
||||
- `SIGUSR1` triggers an in-process restart (useful without a supervisor).
|
||||
- `SIGUSR1` triggers an in-process restart when authorized (enable `commands.restart` or use the gateway tool/config apply/update).
|
||||
- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don’t restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
|
||||
|
||||
### Options
|
||||
|
||||
@@ -89,6 +89,15 @@ clawdbot sandbox recreate --all
|
||||
clawdbot sandbox recreate --all
|
||||
```
|
||||
|
||||
### After changing setupCommand
|
||||
|
||||
```bash
|
||||
clawdbot sandbox recreate --all
|
||||
# or just one agent:
|
||||
clawdbot sandbox recreate --agent family
|
||||
```
|
||||
|
||||
|
||||
### For a specific agent only
|
||||
|
||||
```bash
|
||||
|
||||
@@ -293,6 +293,9 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio
|
||||
}
|
||||
```
|
||||
|
||||
Note: `setupCommand` lives under `sandbox.docker` and runs once on container creation.
|
||||
Per-agent `sandbox.docker.*` overrides are ignored when the resolved scope is `"shared"`.
|
||||
|
||||
**Benefits:**
|
||||
- **Security isolation**: Restrict tools for untrusted agents
|
||||
- **Resource control**: Sandbox specific agents while keeping others on host
|
||||
|
||||
@@ -13,6 +13,7 @@ read_when:
|
||||
## Where it shows up
|
||||
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
|
||||
- `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only).
|
||||
- `/usage cost` in chats: local cost summary aggregated from Clawdbot session logs.
|
||||
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
||||
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||
- macOS menu bar: “Usage” section under Context (only if available).
|
||||
|
||||
@@ -17,6 +17,19 @@ If the file is missing, Clawdbot uses safe-ish defaults (embedded Pi agent + per
|
||||
|
||||
> **New to configuration?** Check out the [Configuration Examples](/gateway/configuration-examples) guide for complete examples with detailed explanations!
|
||||
|
||||
## Strict config validation
|
||||
|
||||
Clawdbot only accepts configurations that fully match the schema.
|
||||
Unknown keys, malformed types, or invalid values cause the Gateway to **refuse to start** for safety.
|
||||
|
||||
When validation fails:
|
||||
- The Gateway does not boot.
|
||||
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot service`, `clawdbot help`).
|
||||
- Run `clawdbot doctor` to see the exact issues.
|
||||
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs.
|
||||
|
||||
Doctor never writes changes unless you explicitly opt into `--fix`/`--yes`.
|
||||
|
||||
## Schema + UI hints
|
||||
|
||||
The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors.
|
||||
@@ -1990,6 +2003,9 @@ cross-session isolation. Use `scope: "session"` for per-session isolation.
|
||||
Legacy: `perSession` is still supported (`true` → `scope: "session"`,
|
||||
`false` → `scope: "shared"`).
|
||||
|
||||
`setupCommand` runs **once** after the container is created (inside the container via `sh -lc`).
|
||||
For package installs, ensure network egress, a writable root FS, and a root user.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
|
||||
@@ -34,7 +34,7 @@ pnpm gateway:watch
|
||||
- Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting.
|
||||
- `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing).
|
||||
- If you run under a supervisor (launchd/systemd/mac app child-process mode), a stop/restart typically sends **SIGTERM**; older builds may surface this as `pnpm` `ELIFECYCLE` exit code **143** (SIGTERM), which is a normal shutdown, not a crash.
|
||||
- **SIGUSR1** triggers an in-process restart (no external supervisor required). This is what the `gateway` agent tool uses.
|
||||
- **SIGUSR1** triggers an in-process restart when authorized (gateway tool/config apply/update, or enable `commands.restart` for manual restarts).
|
||||
- Gateway auth: set `gateway.auth.mode=token` + `gateway.auth.token` (or pass `--token <value>` / `CLAWDBOT_GATEWAY_TOKEN`) to require clients to send `connect.params.auth.token`.
|
||||
- The wizard now generates a token by default, even on loopback.
|
||||
- Port precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
|
||||
|
||||
@@ -116,6 +116,20 @@ Override with `agents.defaults.sandbox.docker.network`.
|
||||
Docker installs and the containerized gateway live here:
|
||||
[Docker](/install/docker)
|
||||
|
||||
## setupCommand (one-time container setup)
|
||||
`setupCommand` runs **once** after the sandbox container is created (not on every run).
|
||||
It executes inside the container via `sh -lc`.
|
||||
|
||||
Paths:
|
||||
- Global: `agents.defaults.sandbox.docker.setupCommand`
|
||||
- Per-agent: `agents.list[].sandbox.docker.setupCommand`
|
||||
|
||||
|
||||
Common pitfalls:
|
||||
- Default `docker.network` is `"none"` (no egress), so package installs will fail.
|
||||
- `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image.
|
||||
- `user` must be root for package installs (omit `user` or set `user: "0:0"`).
|
||||
|
||||
## Tool policy + escape hatches
|
||||
Tool allow/deny policies still apply before sandbox rules. If a tool is denied
|
||||
globally or per-agent, sandboxing doesn’t bring it back.
|
||||
|
||||
@@ -326,6 +326,22 @@ Clawdbot keeps conversation history in memory.
|
||||
|
||||
## Common troubleshooting
|
||||
|
||||
### “Gateway won’t start — configuration invalid”
|
||||
|
||||
Clawdbot now refuses to start when the config contains unknown keys, malformed values, or invalid types.
|
||||
This is intentional for safety.
|
||||
|
||||
Fix it with Doctor:
|
||||
```bash
|
||||
clawdbot doctor
|
||||
clawdbot doctor --fix
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `clawdbot doctor` reports every invalid entry.
|
||||
- `clawdbot doctor --fix` applies migrations/repairs and rewrites the config.
|
||||
- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, and `clawdbot service` still run even if the config is invalid.
|
||||
|
||||
### “All models failed” — what should I check first?
|
||||
|
||||
- **Credentials** present for the provider(s) being tried (auth profiles + env vars).
|
||||
|
||||
@@ -254,6 +254,14 @@ precedence, and troubleshooting.
|
||||
|
||||
### Enable sandboxing
|
||||
|
||||
If you plan to install packages in `setupCommand`, note:
|
||||
- Default `docker.network` is `"none"` (no egress).
|
||||
- `readOnlyRoot: true` blocks package installs.
|
||||
- `user` must be root for `apt-get` (omit `user` or set `user: "0:0"`).
|
||||
Clawdbot auto-recreates containers when `setupCommand` (or docker config) changes
|
||||
unless the container was **recently used** (within ~5 minutes). Hot containers
|
||||
log a warning with the exact `clawdbot sandbox recreate ...` command.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
|
||||
@@ -18,6 +18,9 @@ This allows you to run multiple agents with different security profiles:
|
||||
- Family/work agents with restricted tools
|
||||
- Public-facing agents in sandboxes
|
||||
|
||||
`setupCommand` belongs under `sandbox.docker` (global or per-agent) and runs once
|
||||
when the container is created.
|
||||
|
||||
Auth is per-agent: each agent reads from its own `agentDir` auth store at:
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
summary: "Strict config validation + doctor-only migrations"
|
||||
read_when:
|
||||
- Designing or implementing config validation behavior
|
||||
- Working on config migrations or doctor workflows
|
||||
- Handling plugin config schemas or plugin load gating
|
||||
---
|
||||
# Strict config validation (doctor-only migrations)
|
||||
|
||||
## Goals
|
||||
- **Reject unknown config keys everywhere** (root + nested).
|
||||
- **Reject plugin config without a schema**; don’t load that plugin.
|
||||
- **Remove legacy auto-migration on load**; migrations run via doctor only.
|
||||
- **Auto-run doctor (dry-run) on startup**; if invalid, block non-diagnostic commands.
|
||||
|
||||
## Non-goals
|
||||
- Backward compatibility on load (legacy keys do not auto-migrate).
|
||||
- Silent drops of unrecognized keys.
|
||||
|
||||
## Strict validation rules
|
||||
- Config must match the schema exactly at every level.
|
||||
- Unknown keys are validation errors (no passthrough at root or nested).
|
||||
- `plugins.entries.<id>.config` must be validated by the plugin’s schema.
|
||||
- If a plugin lacks a schema, **reject plugin load** and surface a clear error.
|
||||
|
||||
## Plugin schema enforcement
|
||||
- Each plugin provides a strict schema for its config (no passthrough).
|
||||
- Plugin load flow:
|
||||
1) Resolve plugin schema by plugin id.
|
||||
2) Validate config against the schema.
|
||||
3) If missing schema or invalid config: block plugin load, record error.
|
||||
- Error message includes:
|
||||
- Plugin id
|
||||
- Reason (missing schema / invalid config)
|
||||
- Path(s) that failed validation
|
||||
|
||||
## Doctor flow
|
||||
- Doctor runs **every time** config is loaded (dry-run by default).
|
||||
- If config invalid:
|
||||
- Print a summary + actionable errors.
|
||||
- Instruct: `clawdbot doctor --fix`.
|
||||
- `clawdbot doctor --fix`:
|
||||
- Applies migrations.
|
||||
- Removes unknown keys.
|
||||
- Writes updated config.
|
||||
|
||||
## Command gating (when config is invalid)
|
||||
Allowed (diagnostic-only):
|
||||
- `clawdbot doctor`
|
||||
- `clawdbot logs`
|
||||
- `clawdbot health`
|
||||
- `clawdbot help`
|
||||
- `clawdbot status`
|
||||
- `clawdbot service`
|
||||
|
||||
Everything else must hard-fail with: “Config invalid. Run `clawdbot doctor --fix`.”
|
||||
|
||||
## Error UX format
|
||||
- Single summary header.
|
||||
- Grouped sections:
|
||||
- Unknown keys (full paths)
|
||||
- Legacy keys / migrations needed
|
||||
- Plugin load failures (plugin id + reason + path)
|
||||
|
||||
## Implementation touchpoints
|
||||
- `src/config/zod-schema.ts`: remove root passthrough; strict objects everywhere.
|
||||
- `src/config/zod-schema.providers.ts`: ensure strict channel schemas.
|
||||
- `src/config/validation.ts`: fail on unknown keys; do not apply legacy migrations.
|
||||
- `src/config/io.ts`: remove legacy auto-migrations; always run doctor dry-run.
|
||||
- `src/config/legacy*.ts`: move usage to doctor only.
|
||||
- `src/plugins/*`: add schema registry + gating.
|
||||
- CLI command gating in `src/cli`.
|
||||
|
||||
## Tests
|
||||
- Unknown key rejection (root + nested).
|
||||
- Plugin missing schema → plugin load blocked with clear error.
|
||||
- Invalid config → gateway startup blocked except diagnostic commands.
|
||||
- Doctor dry-run auto; `doctor --fix` writes corrected config.
|
||||
@@ -15,6 +15,11 @@ Use these when a task is clearly tied to a script; otherwise prefer the CLI.
|
||||
- Prefer CLI surfaces when they exist (example: auth monitoring uses `clawdbot models status --check`).
|
||||
- Assume scripts are host‑specific; read them before running on a new machine.
|
||||
|
||||
## Git hooks
|
||||
|
||||
- `scripts/setup-git-hooks.js`: best-effort setup for `core.hooksPath` when inside a git repo.
|
||||
- `scripts/format-staged.js`: pre-commit formatter for staged `src/` and `test/` files.
|
||||
|
||||
## Auth monitoring scripts
|
||||
|
||||
Auth monitoring scripts are documented here:
|
||||
|
||||
@@ -45,6 +45,7 @@ Use these in chat:
|
||||
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
|
||||
- Persists per session (stored as `responseUsage`).
|
||||
- OAuth auth **hides cost** (tokens only).
|
||||
- `/usage cost` → shows a local cost summary from Clawdbot session logs.
|
||||
|
||||
Other surfaces:
|
||||
|
||||
|
||||
@@ -40,6 +40,28 @@ Notes:
|
||||
- `tools.exec.security` (default: `deny`)
|
||||
- `tools.exec.ask` (default: `on-miss`)
|
||||
- `tools.exec.node` (default: unset)
|
||||
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
exec: {
|
||||
pathPrepend: ["~/bin", "/opt/oss/bin"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PATH handling
|
||||
|
||||
- `host=gateway`: uses the Gateway process `PATH`. Daemons install a minimal `PATH`:
|
||||
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
|
||||
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
|
||||
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
|
||||
if the exec call already sets `env.PATH`.
|
||||
|
||||
Per-agent node binding (use the agent list index in config):
|
||||
|
||||
|
||||
+1
-1
@@ -356,7 +356,7 @@ Notes:
|
||||
Restart or apply updates to the running Gateway process (in-place).
|
||||
|
||||
Core actions:
|
||||
- `restart` (sends `SIGUSR1` to the current process; `clawdbot gateway` restart in-place)
|
||||
- `restart` (authorizes + sends `SIGUSR1` for in-process restart; `clawdbot gateway` restart in-place)
|
||||
- `config.get` / `config.schema`
|
||||
- `config.apply` (validate + write config + restart + wake)
|
||||
- `update.run` (run update + restart + wake)
|
||||
|
||||
@@ -111,6 +111,8 @@ Note on sandboxing:
|
||||
- `requires.bins` is checked on the **host** at skill load time.
|
||||
- If an agent is sandboxed, the binary must also exist **inside the container**.
|
||||
Install it via `agents.defaults.sandbox.docker.setupCommand` (or a custom image).
|
||||
`setupCommand` runs once after the container is created.
|
||||
Package installs also require network egress, a writable root FS, and a root user in the sandbox.
|
||||
Example: the `summarize` skill (`skills/summarize/SKILL.md`) needs the `summarize` CLI
|
||||
in the sandbox container to run there.
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ Text + native (when enabled):
|
||||
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
||||
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||
- `/usage off|tokens|full` (per-response usage footer)
|
||||
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
|
||||
- `/stop`
|
||||
- `/restart`
|
||||
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
|
||||
@@ -91,7 +91,7 @@ Text-only:
|
||||
Notes:
|
||||
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
|
||||
- For full provider usage breakdown, use `clawdbot status --usage`.
|
||||
- `/usage` controls the per-response usage footer. It only shows dollar cost when the model uses an API key (OAuth hides cost).
|
||||
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
|
||||
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
||||
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
||||
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { bluebubblesPlugin } from "./src/channel.js";
|
||||
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
|
||||
@@ -8,6 +9,7 @@ const plugin = {
|
||||
id: "bluebubbles",
|
||||
name: "BlueBubbles",
|
||||
description: "BlueBubbles channel plugin (macOS app)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setBlueBubblesRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: bluebubblesPlugin });
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
const DEFAULT_BASE_URL = "http://localhost:3000/v1";
|
||||
const DEFAULT_API_KEY = "n/a";
|
||||
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
||||
@@ -61,6 +63,7 @@ const copilotProxyPlugin = {
|
||||
id: "copilot-proxy",
|
||||
name: "Copilot Proxy",
|
||||
description: "Local Copilot Proxy (VS Code LM) provider plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "copilot-proxy",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { discordPlugin } from "./src/channel.js";
|
||||
import { setDiscordRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
description: "Discord channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setDiscordRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: discordPlugin });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
|
||||
const decode = (s: string) => Buffer.from(s, "base64").toString();
|
||||
@@ -360,6 +361,7 @@ const antigravityPlugin = {
|
||||
id: "google-antigravity-auth",
|
||||
name: "Google Antigravity Auth",
|
||||
description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "google-antigravity",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { loginGeminiCliOAuth } from "./oauth.js";
|
||||
|
||||
const PROVIDER_ID = "google-gemini-cli";
|
||||
@@ -14,6 +16,7 @@ const geminiCliPlugin = {
|
||||
id: "google-gemini-cli-auth",
|
||||
name: "Google Gemini CLI Auth",
|
||||
description: "OAuth flow for Gemini CLI (Google Code Assist)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { imessagePlugin } from "./src/channel.js";
|
||||
import { setIMessageRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "imessage",
|
||||
name: "iMessage",
|
||||
description: "iMessage channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setIMessageRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: imessagePlugin });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { matrixPlugin } from "./src/channel.js";
|
||||
import { setMatrixRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "matrix",
|
||||
name: "Matrix",
|
||||
description: "Matrix channel plugin (matrix-js-sdk)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setMatrixRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: matrixPlugin });
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
const memoryCorePlugin = {
|
||||
id: "memory-core",
|
||||
name: "Memory (Core)",
|
||||
description: "File-backed memory search tools and CLI",
|
||||
kind: "memory",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
api.registerTool(
|
||||
(ctx) => {
|
||||
|
||||
@@ -24,6 +24,16 @@ const EMBEDDING_DIMENSIONS: Record<string, number> = {
|
||||
"text-embedding-3-large": 3072,
|
||||
};
|
||||
|
||||
function assertAllowedKeys(
|
||||
value: Record<string, unknown>,
|
||||
allowed: string[],
|
||||
label: string,
|
||||
) {
|
||||
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
||||
if (unknown.length === 0) return;
|
||||
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
||||
}
|
||||
|
||||
export function vectorDimsForModel(model: string): number {
|
||||
const dims = EMBEDDING_DIMENSIONS[model];
|
||||
if (!dims) {
|
||||
@@ -54,11 +64,13 @@ export const memoryConfigSchema = {
|
||||
throw new Error("memory config required");
|
||||
}
|
||||
const cfg = value as Record<string, unknown>;
|
||||
assertAllowedKeys(cfg, ["embedding", "dbPath", "autoCapture", "autoRecall"], "memory config");
|
||||
|
||||
const embedding = cfg.embedding as Record<string, unknown> | undefined;
|
||||
if (!embedding || typeof embedding.apiKey !== "string") {
|
||||
throw new Error("embedding.apiKey is required");
|
||||
}
|
||||
assertAllowedKeys(embedding, ["apiKey", "model"], "embedding config");
|
||||
|
||||
const model = resolveEmbeddingModel(embedding);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { msteamsPlugin } from "./src/channel.js";
|
||||
import { setMSTeamsRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "msteams",
|
||||
name: "Microsoft Teams",
|
||||
description: "Microsoft Teams channel plugin (Bot Framework)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setMSTeamsRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: msteamsPlugin });
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { loginQwenPortalOAuth } from "./oauth.js";
|
||||
|
||||
const PROVIDER_ID = "qwen-portal";
|
||||
@@ -30,6 +32,7 @@ const qwenPortalPlugin = {
|
||||
id: "qwen-portal-auth",
|
||||
name: "Qwen OAuth",
|
||||
description: "OAuth flow for Qwen (free-tier) models",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { signalPlugin } from "./src/channel.js";
|
||||
import { setSignalRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "signal",
|
||||
name: "Signal",
|
||||
description: "Signal channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setSignalRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: signalPlugin });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { slackPlugin } from "./src/channel.js";
|
||||
import { setSlackRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
description: "Slack channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setSlackRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: slackPlugin });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { telegramPlugin } from "./src/channel.js";
|
||||
import { setTelegramRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "telegram",
|
||||
name: "Telegram",
|
||||
description: "Telegram channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setTelegramRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: telegramPlugin });
|
||||
|
||||
@@ -35,30 +35,36 @@ export type InboundPolicy = z.infer<typeof InboundPolicySchema>;
|
||||
// Provider-Specific Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const TelnyxConfigSchema = z.object({
|
||||
export const TelnyxConfigSchema = z
|
||||
.object({
|
||||
/** Telnyx API v2 key */
|
||||
apiKey: z.string().min(1).optional(),
|
||||
/** Telnyx connection ID (from Call Control app) */
|
||||
connectionId: z.string().min(1).optional(),
|
||||
/** Public key for webhook signature verification */
|
||||
publicKey: z.string().min(1).optional(),
|
||||
});
|
||||
})
|
||||
.strict();
|
||||
export type TelnyxConfig = z.infer<typeof TelnyxConfigSchema>;
|
||||
|
||||
export const TwilioConfigSchema = z.object({
|
||||
export const TwilioConfigSchema = z
|
||||
.object({
|
||||
/** Twilio Account SID */
|
||||
accountSid: z.string().min(1).optional(),
|
||||
/** Twilio Auth Token */
|
||||
authToken: z.string().min(1).optional(),
|
||||
});
|
||||
})
|
||||
.strict();
|
||||
export type TwilioConfig = z.infer<typeof TwilioConfigSchema>;
|
||||
|
||||
export const PlivoConfigSchema = z.object({
|
||||
export const PlivoConfigSchema = z
|
||||
.object({
|
||||
/** Plivo Auth ID (starts with MA/SA) */
|
||||
authId: z.string().min(1).optional(),
|
||||
/** Plivo Auth Token */
|
||||
authToken: z.string().min(1).optional(),
|
||||
});
|
||||
})
|
||||
.strict();
|
||||
export type PlivoConfig = z.infer<typeof PlivoConfigSchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -72,6 +78,7 @@ export const SttConfigSchema = z
|
||||
/** Whisper model to use */
|
||||
model: z.string().min(1).default("whisper-1"),
|
||||
})
|
||||
.strict()
|
||||
.default({ provider: "openai", model: "whisper-1" });
|
||||
export type SttConfig = z.infer<typeof SttConfigSchema>;
|
||||
|
||||
@@ -97,6 +104,7 @@ export const TtsConfigSchema = z
|
||||
*/
|
||||
instructions: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
|
||||
export type TtsConfig = z.infer<typeof TtsConfigSchema>;
|
||||
|
||||
@@ -113,6 +121,7 @@ export const VoiceCallServeConfigSchema = z
|
||||
/** Webhook path */
|
||||
path: z.string().min(1).default("/voice/webhook"),
|
||||
})
|
||||
.strict()
|
||||
.default({ port: 3334, bind: "127.0.0.1", path: "/voice/webhook" });
|
||||
export type VoiceCallServeConfig = z.infer<typeof VoiceCallServeConfigSchema>;
|
||||
|
||||
@@ -128,6 +137,7 @@ export const VoiceCallTailscaleConfigSchema = z
|
||||
/** Path for Tailscale serve/funnel (should usually match serve.path) */
|
||||
path: z.string().min(1).default("/voice/webhook"),
|
||||
})
|
||||
.strict()
|
||||
.default({ mode: "off", path: "/voice/webhook" });
|
||||
export type VoiceCallTailscaleConfig = z.infer<
|
||||
typeof VoiceCallTailscaleConfigSchema
|
||||
@@ -161,6 +171,7 @@ export const VoiceCallTunnelConfigSchema = z
|
||||
*/
|
||||
allowNgrokFreeTier: z.boolean().default(true),
|
||||
})
|
||||
.strict()
|
||||
.default({ provider: "none", allowNgrokFreeTier: true });
|
||||
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
|
||||
|
||||
@@ -183,6 +194,7 @@ export const OutboundConfigSchema = z
|
||||
/** Seconds to wait after TTS before auto-hangup in notify mode */
|
||||
notifyHangupDelaySec: z.number().int().nonnegative().default(3),
|
||||
})
|
||||
.strict()
|
||||
.default({ defaultMode: "notify", notifyHangupDelaySec: 3 });
|
||||
export type OutboundConfig = z.infer<typeof OutboundConfigSchema>;
|
||||
|
||||
@@ -207,6 +219,7 @@ export const VoiceCallStreamingConfigSchema = z
|
||||
/** WebSocket path for media stream connections */
|
||||
streamPath: z.string().min(1).default("/voice/stream"),
|
||||
})
|
||||
.strict()
|
||||
.default({
|
||||
enabled: false,
|
||||
sttProvider: "openai-realtime",
|
||||
@@ -223,7 +236,8 @@ export type VoiceCallStreamingConfig = z.infer<
|
||||
// Main Voice Call Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const VoiceCallConfigSchema = z.object({
|
||||
export const VoiceCallConfigSchema = z
|
||||
.object({
|
||||
/** Enable voice call functionality */
|
||||
enabled: z.boolean().default(false),
|
||||
|
||||
@@ -307,7 +321,8 @@ export const VoiceCallConfigSchema = z.object({
|
||||
|
||||
/** Timeout for response generation in ms (default 30s) */
|
||||
responseTimeoutMs: z.number().int().positive().default(30000),
|
||||
});
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>;
|
||||
|
||||
|
||||
@@ -64,17 +64,24 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
|
||||
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
|
||||
private readonly twimlStorage = new Map<string, string>();
|
||||
/** Track notify-mode calls to avoid streaming on follow-up callbacks. */
|
||||
private readonly notifyCalls = new Set<string>();
|
||||
|
||||
/**
|
||||
* Delete stored TwiML for a given `callId`.
|
||||
*
|
||||
* We keep TwiML in-memory only long enough to satisfy the initial Twilio
|
||||
* webhook request (notify mode). Subsequent webhooks should not reuse it.
|
||||
* webhook request (notify mode). The notify guard is cleared separately.
|
||||
*/
|
||||
private deleteStoredTwiml(callId: string): void {
|
||||
this.twimlStorage.delete(callId);
|
||||
}
|
||||
|
||||
private clearNotifyState(callId: string): void {
|
||||
this.twimlStorage.delete(callId);
|
||||
this.notifyCalls.delete(callId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stored TwiML for a call, addressed by Twilio's provider call SID.
|
||||
*
|
||||
@@ -87,7 +94,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
|
||||
if (!callIdMatch) return;
|
||||
|
||||
this.deleteStoredTwiml(callIdMatch[1]);
|
||||
this.clearNotifyState(callIdMatch[1]);
|
||||
}
|
||||
|
||||
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
||||
@@ -137,7 +144,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
*/
|
||||
private async apiRequest<T = unknown>(
|
||||
endpoint: string,
|
||||
params: Record<string, string>,
|
||||
params: Record<string, string | string[]>,
|
||||
options?: { allowNotFound?: boolean },
|
||||
): Promise<T> {
|
||||
return await twilioApiRequest<T>({
|
||||
@@ -257,12 +264,12 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
case "no-answer":
|
||||
case "failed":
|
||||
if (callIdOverride) {
|
||||
this.deleteStoredTwiml(callIdOverride);
|
||||
this.clearNotifyState(callIdOverride);
|
||||
}
|
||||
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
||||
case "canceled":
|
||||
if (callIdOverride) {
|
||||
this.deleteStoredTwiml(callIdOverride);
|
||||
this.clearNotifyState(callIdOverride);
|
||||
}
|
||||
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
||||
default:
|
||||
@@ -286,6 +293,9 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
if (!ctx) return TwilioProvider.EMPTY_TWIML;
|
||||
|
||||
const params = new URLSearchParams(ctx.rawBody);
|
||||
const type =
|
||||
typeof ctx.query?.type === "string" ? ctx.query.type.trim() : undefined;
|
||||
const isStatusCallback = type === "status";
|
||||
const callStatus = params.get("CallStatus");
|
||||
const direction = params.get("Direction");
|
||||
const callIdFromQuery =
|
||||
@@ -297,13 +307,21 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
|
||||
// Handle initial TwiML request (when Twilio first initiates the call)
|
||||
// Check if we have stored TwiML for this call (notify mode)
|
||||
if (callIdFromQuery) {
|
||||
if (callIdFromQuery && !isStatusCallback) {
|
||||
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
|
||||
if (storedTwiml) {
|
||||
// Clean up after serving (one-time use)
|
||||
this.deleteStoredTwiml(callIdFromQuery);
|
||||
return storedTwiml;
|
||||
}
|
||||
if (this.notifyCalls.has(callIdFromQuery)) {
|
||||
return TwilioProvider.EMPTY_TWIML;
|
||||
}
|
||||
}
|
||||
|
||||
// Status callbacks should not receive TwiML.
|
||||
if (isStatusCallback) {
|
||||
return TwilioProvider.EMPTY_TWIML;
|
||||
}
|
||||
|
||||
// Handle subsequent webhook requests (status callbacks, etc.)
|
||||
@@ -385,16 +403,18 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
// We now serve it from the webhook endpoint instead of sending inline
|
||||
if (input.inlineTwiml) {
|
||||
this.twimlStorage.set(input.callId, input.inlineTwiml);
|
||||
// Keep notify-mode calls out of streaming TwiML for any follow-up callbacks.
|
||||
this.notifyCalls.add(input.callId);
|
||||
}
|
||||
|
||||
// Build request params - always use URL-based TwiML.
|
||||
// Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter.
|
||||
const params: Record<string, string> = {
|
||||
const params: Record<string, string | string[]> = {
|
||||
To: input.to,
|
||||
From: input.from,
|
||||
Url: url.toString(), // TwiML serving endpoint
|
||||
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
|
||||
StatusCallbackEvent: "initiated ringing answered completed",
|
||||
StatusCallbackEvent: ["initiated", "ringing", "answered", "completed"],
|
||||
Timeout: "30",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { twilioApiRequest } from "./api.js";
|
||||
|
||||
describe("twilioApiRequest", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("encodes array params as repeated form fields", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => "",
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
await twilioApiRequest({
|
||||
baseUrl: "https://api.example.com",
|
||||
accountSid: "AC123",
|
||||
authToken: "token",
|
||||
endpoint: "/Calls.json",
|
||||
body: {
|
||||
To: "+15555550123",
|
||||
StatusCallbackEvent: ["initiated", "completed"],
|
||||
},
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] ?? [];
|
||||
const body = init?.body as URLSearchParams | undefined;
|
||||
|
||||
expect(body?.getAll("StatusCallbackEvent")).toEqual([
|
||||
"initiated",
|
||||
"completed",
|
||||
]);
|
||||
expect(body?.get("To")).toBe("+15555550123");
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,34 @@ export async function twilioApiRequest<T = unknown>(params: {
|
||||
accountSid: string;
|
||||
authToken: string;
|
||||
endpoint: string;
|
||||
body: Record<string, string>;
|
||||
body: URLSearchParams | Record<string, string | string[]>;
|
||||
allowNotFound?: boolean;
|
||||
}): Promise<T> {
|
||||
const bodyParams =
|
||||
params.body instanceof URLSearchParams
|
||||
? params.body
|
||||
: Object.entries(params.body).reduce<URLSearchParams>(
|
||||
(acc, [key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
acc.append(key, entry);
|
||||
}
|
||||
} else if (typeof value === "string") {
|
||||
acc.append(key, value);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
new URLSearchParams(),
|
||||
);
|
||||
|
||||
const response = await fetch(`${params.baseUrl}${params.endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams(params.body),
|
||||
// URL-encoded body required by Twilio, with repeated keys for arrays.
|
||||
body: bodyParams,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -26,4 +44,3 @@ export async function twilioApiRequest<T = unknown>(params: {
|
||||
const text = await response.text();
|
||||
return text ? (JSON.parse(text) as T) : (undefined as T);
|
||||
}
|
||||
|
||||
@@ -230,12 +230,14 @@ export async function startTailscaleTunnel(config: {
|
||||
throw new Error("Could not get Tailscale DNS name. Is Tailscale running?");
|
||||
}
|
||||
|
||||
const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
|
||||
// --set-path already mounts the path; keep the target base URL to avoid /path/path.
|
||||
const localUrl = `http://127.0.0.1:${config.port}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
"tailscale",
|
||||
[config.mode, "--bg", "--yes", "--set-path", config.path, localUrl],
|
||||
[config.mode, "--bg", "--yes", "--set-path", path, localUrl],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
@@ -247,7 +249,7 @@ export async function startTailscaleTunnel(config: {
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (code === 0) {
|
||||
const publicUrl = `https://${dnsName}${config.path}`;
|
||||
const publicUrl = `https://${dnsName}${path}`;
|
||||
console.log(
|
||||
`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`,
|
||||
);
|
||||
@@ -256,7 +258,7 @@ export async function startTailscaleTunnel(config: {
|
||||
publicUrl,
|
||||
provider: `tailscale-${config.mode}`,
|
||||
stop: async () => {
|
||||
await stopTailscaleTunnel(config.mode, config.path);
|
||||
await stopTailscaleTunnel(config.mode, path);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -79,7 +79,7 @@ function twilioSignature(params: {
|
||||
let dataToSign = params.url;
|
||||
const sortedParams = Array.from(
|
||||
new URLSearchParams(params.postBody).entries(),
|
||||
).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||||
|
||||
for (const [key, value] of sortedParams) {
|
||||
dataToSign += key + value;
|
||||
@@ -205,4 +205,33 @@ describe("verifyTwilioWebhook", () => {
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("sorts params using ASCII order", () => {
|
||||
const authToken = "test-auth-token";
|
||||
const publicUrl = "https://example.com/voice/webhook";
|
||||
const postBody = "Z=first&a=second";
|
||||
|
||||
const signature = twilioSignature({
|
||||
authToken,
|
||||
url: publicUrl,
|
||||
postBody,
|
||||
});
|
||||
|
||||
const result = verifyTwilioWebhook(
|
||||
{
|
||||
headers: {
|
||||
host: "example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-twilio-signature": signature,
|
||||
},
|
||||
rawBody: postBody,
|
||||
url: "http://local/voice/webhook",
|
||||
method: "POST",
|
||||
},
|
||||
authToken,
|
||||
{ publicUrl },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -23,9 +23,9 @@ export function validateTwilioSignature(
|
||||
// Build the string to sign: URL + sorted params (key+value pairs)
|
||||
let dataToSign = url;
|
||||
|
||||
// Sort params alphabetically and append key+value
|
||||
// Sort params by ASCII order (Twilio spec) and append key+value.
|
||||
const sortedParams = Array.from(params.entries()).sort((a, b) =>
|
||||
a[0].localeCompare(b[0]),
|
||||
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
|
||||
);
|
||||
|
||||
for (const [key, value] of sortedParams) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { whatsappPlugin } from "./src/channel.js";
|
||||
import { setWhatsAppRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "whatsapp",
|
||||
name: "WhatsApp",
|
||||
description: "WhatsApp channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setWhatsAppRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: whatsappPlugin });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { zaloDock, zaloPlugin } from "./src/channel.js";
|
||||
import { handleZaloWebhookRequest } from "./src/monitor.js";
|
||||
@@ -8,6 +9,7 @@ const plugin = {
|
||||
id: "zalo",
|
||||
name: "Zalo",
|
||||
description: "Zalo channel plugin (Bot API)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setZaloRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { zalouserPlugin } from "./src/channel.js";
|
||||
import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
|
||||
@@ -8,6 +9,7 @@ const plugin = {
|
||||
id: "zalouser",
|
||||
name: "Zalo Personal",
|
||||
description: "Zalo personal account messaging via zca-cli",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setZalouserRuntime(api.runtime);
|
||||
// Register channel plugin (for onboarding & gateway)
|
||||
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
[ -z "$ROOT" ] && exit 0
|
||||
node "$ROOT/scripts/format-staged.js"
|
||||
@@ -55,6 +55,9 @@
|
||||
"CHANGELOG.md",
|
||||
"LICENSE",
|
||||
"scripts/postinstall.js",
|
||||
"scripts/format-staged.js",
|
||||
"scripts/setup-git-hooks.js",
|
||||
"git-hooks/**",
|
||||
"dist/terminal/**",
|
||||
"dist/routing/**",
|
||||
"dist/utils/**",
|
||||
@@ -161,6 +164,7 @@
|
||||
"chalk": "^5.6.2",
|
||||
"chokidar": "^5.0.0",
|
||||
"chromium-bidi": "12.0.1",
|
||||
"cli-highlight": "^2.1.11",
|
||||
"commander": "^14.0.2",
|
||||
"croner": "^9.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
|
||||
Generated
+3
@@ -76,6 +76,9 @@ importers:
|
||||
chromium-bidi:
|
||||
specifier: 12.0.1
|
||||
version: 12.0.1(devtools-protocol@0.0.1561482)
|
||||
cli-highlight:
|
||||
specifier: ^2.1.11
|
||||
version: 2.1.11
|
||||
commander:
|
||||
specifier: ^14.0.2
|
||||
version: 14.0.2
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const OXFMT_EXTENSIONS = new Set([
|
||||
".cjs",
|
||||
".js",
|
||||
".json",
|
||||
".jsonc",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".ts",
|
||||
".tsx",
|
||||
]);
|
||||
|
||||
function getRepoRoot() {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
return path.resolve(here, "..");
|
||||
}
|
||||
|
||||
function runGitCommand(args, options = {}) {
|
||||
return spawnSync("git", args, {
|
||||
cwd: options.cwd,
|
||||
encoding: "utf-8",
|
||||
stdio: options.stdio ?? "pipe",
|
||||
});
|
||||
}
|
||||
|
||||
function splitNullDelimited(value) {
|
||||
if (!value) return [];
|
||||
const text = String(value);
|
||||
return text.split("\0").filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeGitPath(filePath) {
|
||||
return filePath.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function filterOxfmtTargets(paths) {
|
||||
return paths
|
||||
.map(normalizeGitPath)
|
||||
.filter((filePath) =>
|
||||
(filePath.startsWith("src/") || filePath.startsWith("test/")) &&
|
||||
OXFMT_EXTENSIONS.has(path.posix.extname(filePath)),
|
||||
);
|
||||
}
|
||||
|
||||
function findPartiallyStagedFiles(stagedFiles, unstagedFiles) {
|
||||
const unstaged = new Set(unstagedFiles.map(normalizeGitPath));
|
||||
return stagedFiles.filter((filePath) => unstaged.has(normalizeGitPath(filePath)));
|
||||
}
|
||||
|
||||
function filterOutPartialTargets(targets, partialTargets) {
|
||||
if (partialTargets.length === 0) return targets;
|
||||
const partial = new Set(partialTargets.map(normalizeGitPath));
|
||||
return targets.filter((filePath) => !partial.has(normalizeGitPath(filePath)));
|
||||
}
|
||||
|
||||
function resolveOxfmtCommand(repoRoot) {
|
||||
const binName = process.platform === "win32" ? "oxfmt.cmd" : "oxfmt";
|
||||
const local = path.join(repoRoot, "node_modules", ".bin", binName);
|
||||
if (fs.existsSync(local)) {
|
||||
return { command: local, args: [] };
|
||||
}
|
||||
|
||||
const result = spawnSync("oxfmt", ["--version"], { stdio: "ignore" });
|
||||
if (result.status === 0) {
|
||||
return { command: "oxfmt", args: [] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getGitPaths(args, repoRoot) {
|
||||
const result = runGitCommand(args, { cwd: repoRoot });
|
||||
if (result.status !== 0) return [];
|
||||
return splitNullDelimited(result.stdout ?? "");
|
||||
}
|
||||
|
||||
function formatFiles(repoRoot, oxfmt, files) {
|
||||
const result = spawnSync(oxfmt.command, ["--write", ...oxfmt.args, ...files], {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function stageFiles(repoRoot, files) {
|
||||
if (files.length === 0) return true;
|
||||
const result = runGitCommand(["add", "--", ...files], { cwd: repoRoot, stdio: "inherit" });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const repoRoot = getRepoRoot();
|
||||
const staged = getGitPaths([
|
||||
"diff",
|
||||
"--cached",
|
||||
"--name-only",
|
||||
"-z",
|
||||
"--diff-filter=ACMR",
|
||||
], repoRoot);
|
||||
const targets = filterOxfmtTargets(staged);
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const unstaged = getGitPaths(["diff", "--name-only", "-z"], repoRoot);
|
||||
const partial = findPartiallyStagedFiles(targets, unstaged);
|
||||
if (partial.length > 0) {
|
||||
process.stderr.write("[pre-commit] Skipping partially staged files:\n");
|
||||
for (const filePath of partial) {
|
||||
process.stderr.write(`- ${filePath}\n`);
|
||||
}
|
||||
process.stderr.write("Stage full files to format them automatically.\n");
|
||||
}
|
||||
|
||||
const filteredTargets = filterOutPartialTargets(targets, partial);
|
||||
if (filteredTargets.length === 0) return;
|
||||
|
||||
const oxfmt = resolveOxfmtCommand(repoRoot);
|
||||
if (!oxfmt) {
|
||||
process.stderr.write("[pre-commit] oxfmt not found; skipping format.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formatFiles(repoRoot, oxfmt, filteredTargets)) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stageFiles(repoRoot, filteredTargets)) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
filterOxfmtTargets,
|
||||
filterOutPartialTargets,
|
||||
findPartiallyStagedFiles,
|
||||
getRepoRoot,
|
||||
normalizeGitPath,
|
||||
resolveOxfmtCommand,
|
||||
splitNullDelimited,
|
||||
};
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main();
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { setupGitHooks } from "./setup-git-hooks.js";
|
||||
|
||||
function detectPackageManager(ua = process.env.npm_config_user_agent ?? "") {
|
||||
// Examples:
|
||||
@@ -252,6 +253,7 @@ function main() {
|
||||
process.chdir(repoRoot);
|
||||
|
||||
ensureExecutable(path.join(repoRoot, "dist", "entry.js"));
|
||||
setupGitHooks({ repoRoot });
|
||||
|
||||
if (!shouldApplyPnpmPatchedDependenciesFallback()) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const DEFAULT_HOOKS_PATH = "git-hooks";
|
||||
const PRE_COMMIT_HOOK = "pre-commit";
|
||||
|
||||
function getRepoRoot() {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
return path.resolve(here, "..");
|
||||
}
|
||||
|
||||
function runGitCommand(args, options = {}) {
|
||||
return spawnSync("git", args, {
|
||||
cwd: options.cwd,
|
||||
encoding: "utf-8",
|
||||
stdio: options.stdio ?? "pipe",
|
||||
});
|
||||
}
|
||||
|
||||
function ensureExecutable(targetPath) {
|
||||
if (process.platform === "win32") return;
|
||||
if (!fs.existsSync(targetPath)) return;
|
||||
try {
|
||||
const mode = fs.statSync(targetPath).mode & 0o777;
|
||||
if (mode & 0o100) return;
|
||||
fs.chmodSync(targetPath, 0o755);
|
||||
} catch (err) {
|
||||
console.warn(`[setup-git-hooks] chmod failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isGitAvailable({ repoRoot = getRepoRoot(), runGit = runGitCommand } = {}) {
|
||||
const result = runGit(["--version"], { cwd: repoRoot, stdio: "ignore" });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function isGitRepo({ repoRoot = getRepoRoot(), runGit = runGitCommand } = {}) {
|
||||
const result = runGit(["rev-parse", "--is-inside-work-tree"], {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
if (result.status !== 0) return false;
|
||||
return String(result.stdout ?? "").trim() === "true";
|
||||
}
|
||||
|
||||
function setHooksPath({
|
||||
repoRoot = getRepoRoot(),
|
||||
hooksPath = DEFAULT_HOOKS_PATH,
|
||||
runGit = runGitCommand,
|
||||
} = {}) {
|
||||
const result = runGit(["config", "core.hooksPath", hooksPath], {
|
||||
cwd: repoRoot,
|
||||
stdio: "ignore",
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function setupGitHooks({
|
||||
repoRoot = getRepoRoot(),
|
||||
hooksPath = DEFAULT_HOOKS_PATH,
|
||||
runGit = runGitCommand,
|
||||
} = {}) {
|
||||
if (!isGitAvailable({ repoRoot, runGit })) {
|
||||
return { ok: false, reason: "git-missing" };
|
||||
}
|
||||
|
||||
if (!isGitRepo({ repoRoot, runGit })) {
|
||||
return { ok: false, reason: "not-repo" };
|
||||
}
|
||||
|
||||
if (!setHooksPath({ repoRoot, hooksPath, runGit })) {
|
||||
return { ok: false, reason: "config-failed" };
|
||||
}
|
||||
|
||||
ensureExecutable(path.join(repoRoot, hooksPath, PRE_COMMIT_HOOK));
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_HOOKS_PATH,
|
||||
PRE_COMMIT_HOOK,
|
||||
ensureExecutable,
|
||||
getRepoRoot,
|
||||
isGitAvailable,
|
||||
isGitRepo,
|
||||
runGitCommand,
|
||||
setHooksPath,
|
||||
setupGitHooks,
|
||||
};
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
setupGitHooks();
|
||||
}
|
||||
@@ -55,6 +55,7 @@ function run(cmd, args) {
|
||||
cwd: uiDir,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) process.exit(1);
|
||||
@@ -67,6 +68,7 @@ function runSync(cmd, args, envOverride) {
|
||||
cwd: uiDir,
|
||||
stdio: "inherit",
|
||||
env: envOverride ?? process.env,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
if (result.signal) process.exit(1);
|
||||
if ((result.status ?? 1) !== 0) process.exit(result.status ?? 1);
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
---
|
||||
name: session-logs
|
||||
description: Search and analyze your own conversation history from session log files using jq.
|
||||
metadata: {"clawdbot":{"emoji":"📜","requires":{"bins":["jq"]}}}
|
||||
description: Search and analyze your own session logs (older/parent conversations) using jq.
|
||||
metadata: {"clawdbot":{"emoji":"📜","requires":{"bins":["jq","rg"]}}}
|
||||
---
|
||||
|
||||
# session-logs
|
||||
|
||||
Search your complete conversation history stored in session JSONL files. Use this when you need to recall something not in your memory files.
|
||||
Search your complete conversation history stored in session JSONL files. Use this when a user references older/parent conversations or asks what was said before.
|
||||
|
||||
## Trigger
|
||||
|
||||
Use this skill when the user asks about prior chats, parent conversations, or historical context that isn’t in memory files.
|
||||
|
||||
## Location
|
||||
|
||||
Session logs live at: `~/.clawdbot/agents/main/sessions/`
|
||||
Session logs live at: `~/.clawdbot/agents/<agentId>/sessions/` (use the `agent=<id>` value from the system prompt Runtime line).
|
||||
|
||||
- **`sessions.json`** - Index mapping session keys to session IDs
|
||||
- **`<session-id>.jsonl`** - Full conversation transcript per session
|
||||
@@ -21,14 +25,14 @@ Each `.jsonl` file contains messages with:
|
||||
- `type`: "session" (metadata) or "message"
|
||||
- `timestamp`: ISO timestamp
|
||||
- `message.role`: "user", "assistant", or "toolResult"
|
||||
- `message.content[]`: Text, thinking, or tool calls
|
||||
- `message.content[]`: Text, thinking, or tool calls (filter `type=="text"` for human-readable content)
|
||||
- `message.usage.cost.total`: Cost per response
|
||||
|
||||
## Common Queries
|
||||
|
||||
### List all sessions by date and size
|
||||
```bash
|
||||
for f in ~/.clawdbot/agents/main/sessions/*.jsonl; do
|
||||
for f in ~/.clawdbot/agents/<agentId>/sessions/*.jsonl; do
|
||||
date=$(head -1 "$f" | jq -r '.timestamp' | cut -dT -f1)
|
||||
size=$(ls -lh "$f" | awk '{print $5}')
|
||||
echo "$date $size $(basename $f)"
|
||||
@@ -37,19 +41,19 @@ done | sort -r
|
||||
|
||||
### Find sessions from a specific day
|
||||
```bash
|
||||
for f in ~/.clawdbot/agents/main/sessions/*.jsonl; do
|
||||
for f in ~/.clawdbot/agents/<agentId>/sessions/*.jsonl; do
|
||||
head -1 "$f" | jq -r '.timestamp' | grep -q "2026-01-06" && echo "$f"
|
||||
done
|
||||
```
|
||||
|
||||
### Extract user messages from a session
|
||||
```bash
|
||||
jq -r 'select(.message.role == "user") | .message.content[0].text' <session>.jsonl
|
||||
jq -r 'select(.message.role == "user") | .message.content[]? | select(.type == "text") | .text' <session>.jsonl
|
||||
```
|
||||
|
||||
### Search for keyword in assistant responses
|
||||
```bash
|
||||
jq -r 'select(.message.role == "assistant") | .message.content[]? | select(.type == "text") | .text' <session>.jsonl | grep -i "keyword"
|
||||
jq -r 'select(.message.role == "assistant") | .message.content[]? | select(.type == "text") | .text' <session>.jsonl | rg -i "keyword"
|
||||
```
|
||||
|
||||
### Get total cost for a session
|
||||
@@ -59,7 +63,7 @@ jq -s '[.[] | .message.usage.cost.total // 0] | add' <session>.jsonl
|
||||
|
||||
### Daily cost summary
|
||||
```bash
|
||||
for f in ~/.clawdbot/agents/main/sessions/*.jsonl; do
|
||||
for f in ~/.clawdbot/agents/<agentId>/sessions/*.jsonl; do
|
||||
date=$(head -1 "$f" | jq -r '.timestamp' | cut -dT -f1)
|
||||
cost=$(jq -s '[.[] | .message.usage.cost.total // 0] | add' "$f")
|
||||
echo "$date $cost"
|
||||
@@ -84,7 +88,7 @@ jq -r '.message.content[]? | select(.type == "toolCall") | .name' <session>.json
|
||||
|
||||
### Search across ALL sessions for a phrase
|
||||
```bash
|
||||
grep -l "phrase" ~/.clawdbot/agents/main/sessions/*.jsonl
|
||||
rg -l "phrase" ~/.clawdbot/agents/<agentId>/sessions/*.jsonl
|
||||
```
|
||||
|
||||
## Tips
|
||||
@@ -93,3 +97,9 @@ grep -l "phrase" ~/.clawdbot/agents/main/sessions/*.jsonl
|
||||
- Large sessions can be several MB - use `head`/`tail` for sampling
|
||||
- The `sessions.json` index maps chat providers (discord, whatsapp, etc.) to session IDs
|
||||
- Deleted sessions have `.deleted.<timestamp>` suffix
|
||||
|
||||
## Fast text-only hint (low noise)
|
||||
|
||||
```bash
|
||||
jq -r 'select(.type=="message") | .message.content[]? | select(.type=="text") | .text' ~/.clawdbot/agents/<agentId>/sessions/<id>.jsonl | rg 'keyword'
|
||||
```
|
||||
@@ -22,8 +22,7 @@ import { getApiKeyForModel } from "./model-auth.js";
|
||||
import { normalizeProviderId, parseModelRef } from "./model-selection.js";
|
||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||
|
||||
const LIVE =
|
||||
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
|
||||
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
|
||||
const SETUP_TOKEN_RAW = process.env.CLAWDBOT_LIVE_SETUP_TOKEN?.trim() ?? "";
|
||||
const SETUP_TOKEN_VALUE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_VALUE?.trim() ?? "";
|
||||
const SETUP_TOKEN_PROFILE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
@@ -89,6 +90,7 @@ export type ExecToolDefaults = {
|
||||
security?: ExecSecurity;
|
||||
ask?: ExecAsk;
|
||||
node?: string;
|
||||
pathPrepend?: string[];
|
||||
agentId?: string;
|
||||
backgroundMs?: number;
|
||||
timeoutSec?: number;
|
||||
@@ -207,6 +209,47 @@ function normalizeNotifyOutput(value: string) {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizePathPrepend(entries?: string[]) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (typeof entry !== "string") continue;
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function mergePathPrepend(existing: string | undefined, prepend: string[]) {
|
||||
if (prepend.length === 0) return existing;
|
||||
const partsExisting = (existing ?? "")
|
||||
.split(path.delimiter)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const merged: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const part of [...prepend, ...partsExisting]) {
|
||||
if (seen.has(part)) continue;
|
||||
seen.add(part);
|
||||
merged.push(part);
|
||||
}
|
||||
return merged.join(path.delimiter);
|
||||
}
|
||||
|
||||
function applyPathPrepend(
|
||||
env: Record<string, string>,
|
||||
prepend: string[],
|
||||
options?: { requireExisting?: boolean },
|
||||
) {
|
||||
if (prepend.length === 0) return;
|
||||
if (options?.requireExisting && !env.PATH) return;
|
||||
const merged = mergePathPrepend(env.PATH, prepend);
|
||||
if (merged) env.PATH = merged;
|
||||
}
|
||||
|
||||
function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "failed") {
|
||||
if (!session.backgrounded || !session.notifyOnExit || session.exitNotified) return;
|
||||
const sessionKey = session.sessionKey?.trim();
|
||||
@@ -240,6 +283,7 @@ export function createExecTool(
|
||||
typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0
|
||||
? defaults.timeoutSec
|
||||
: 1800;
|
||||
const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend);
|
||||
const notifyOnExit = defaults?.notifyOnExit !== false;
|
||||
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
|
||||
|
||||
@@ -379,6 +423,7 @@ export function createExecTool(
|
||||
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
|
||||
})
|
||||
: mergedEnv;
|
||||
applyPathPrepend(env, defaultPathPrepend);
|
||||
|
||||
if (host === "node") {
|
||||
if (security === "deny") {
|
||||
@@ -417,6 +462,10 @@ export function createExecTool(
|
||||
);
|
||||
}
|
||||
const argv = buildNodeShellCommand(params.command, nodeInfo?.platform);
|
||||
const nodeEnv = params.env ? { ...params.env } : undefined;
|
||||
if (nodeEnv) {
|
||||
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
|
||||
}
|
||||
const invokeParams: Record<string, unknown> = {
|
||||
nodeId,
|
||||
command: "system.run",
|
||||
@@ -424,7 +473,7 @@ export function createExecTool(
|
||||
command: argv,
|
||||
rawCommand: params.command,
|
||||
cwd: workdir,
|
||||
env: params.env,
|
||||
env: nodeEnv,
|
||||
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
|
||||
agentId: defaults?.agentId,
|
||||
sessionKey: defaults?.sessionKey,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js";
|
||||
import { getFinishedSession, resetProcessRegistryForTests } from "./bash-process-registry.js";
|
||||
@@ -275,6 +277,34 @@ describe("exec notifyOnExit", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec PATH handling", () => {
|
||||
const originalPath = process.env.PATH;
|
||||
const originalShell = process.env.SHELL;
|
||||
|
||||
beforeEach(() => {
|
||||
if (!isWin) process.env.SHELL = "/bin/bash";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PATH = originalPath;
|
||||
if (!isWin) process.env.SHELL = originalShell;
|
||||
});
|
||||
|
||||
it("prepends configured path entries", async () => {
|
||||
const basePath = isWin ? "C:\\Windows\\System32" : "/usr/bin";
|
||||
const prepend = isWin ? ["C:\\custom\\bin", "C:\\oss\\bin"] : ["/custom/bin", "/opt/oss/bin"];
|
||||
process.env.PATH = basePath;
|
||||
|
||||
const tool = createExecTool({ pathPrepend: prepend });
|
||||
const result = await tool.execute("call1", {
|
||||
command: isWin ? "Write-Output $env:PATH" : "echo $PATH",
|
||||
});
|
||||
|
||||
const text = normalizeText(result.content.find((c) => c.type === "text")?.text);
|
||||
expect(text).toBe([...prepend, basePath].join(path.delimiter));
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDockerExecArgs", () => {
|
||||
it("prepends custom PATH after login shell sourcing to preserve both custom and system tools", () => {
|
||||
const args = buildDockerExecArgs({
|
||||
|
||||
@@ -22,6 +22,16 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
|
||||
const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2000) => {
|
||||
const start = Date.now();
|
||||
while (getCount() < count) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`timed out waiting for ${count} calls`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
};
|
||||
|
||||
describe("sessions tools", () => {
|
||||
it("uses number (not integer) in tool schemas for Gemini compatibility", () => {
|
||||
const tools = createClawdbotTools();
|
||||
@@ -240,8 +250,9 @@ describe("sessions tools", () => {
|
||||
runId: "run-1",
|
||||
delivery: { status: "pending", mode: "announce" },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await waitForCalls(() => calls.filter((call) => call.method === "agent").length, 4);
|
||||
await waitForCalls(() => calls.filter((call) => call.method === "agent.wait").length, 4);
|
||||
await waitForCalls(() => calls.filter((call) => call.method === "chat.history").length, 4);
|
||||
|
||||
const waitPromise = tool.execute("call6", {
|
||||
sessionKey: "main",
|
||||
@@ -255,8 +266,9 @@ describe("sessions tools", () => {
|
||||
delivery: { status: "pending", mode: "announce" },
|
||||
});
|
||||
expect(typeof (waited.details as { runId?: string }).runId).toBe("string");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await waitForCalls(() => calls.filter((call) => call.method === "agent").length, 8);
|
||||
await waitForCalls(() => calls.filter((call) => call.method === "agent.wait").length, 8);
|
||||
await waitForCalls(() => calls.filter((call) => call.method === "chat.history").length, 8);
|
||||
|
||||
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||
const waitCalls = calls.filter((call) => call.method === "agent.wait");
|
||||
|
||||
+75
@@ -157,4 +157,79 @@ describe("clawdbot-tools: subagents", () => {
|
||||
// Session should be deleted since cleanup=delete
|
||||
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
});
|
||||
|
||||
it("sessions_spawn announces with requester accountId", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
let agentCallCount = 0;
|
||||
let childRunId: string | undefined;
|
||||
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: unknown };
|
||||
calls.push(request);
|
||||
if (request.method === "agent") {
|
||||
agentCallCount += 1;
|
||||
const runId = `run-${agentCallCount}`;
|
||||
const params = request.params as { lane?: string; sessionKey?: string } | undefined;
|
||||
if (params?.lane === "subagent") {
|
||||
childRunId = runId;
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
status: "accepted",
|
||||
acceptedAt: 4000 + agentCallCount,
|
||||
};
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
|
||||
}
|
||||
if (request.method === "sessions.delete" || request.method === "sessions.patch") {
|
||||
return { ok: true };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
agentAccountId: "kev",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) throw new Error("missing sessions_spawn tool");
|
||||
|
||||
const result = await tool.execute("call2", {
|
||||
task: "do thing",
|
||||
runTimeoutSeconds: 1,
|
||||
cleanup: "keep",
|
||||
});
|
||||
expect(result.details).toMatchObject({
|
||||
status: "accepted",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
if (!childRunId) throw new Error("missing child runId");
|
||||
emitAgentEvent({
|
||||
runId: childRunId,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "end",
|
||||
startedAt: 1000,
|
||||
endedAt: 2000,
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||
expect(agentCalls).toHaveLength(2);
|
||||
const announceParams = agentCalls[1]?.params as
|
||||
| { accountId?: string; channel?: string; deliver?: boolean }
|
||||
| undefined;
|
||||
expect(announceParams?.deliver).toBe(true);
|
||||
expect(announceParams?.channel).toBe("whatsapp");
|
||||
expect(announceParams?.accountId).toBe("kev");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import type { ThinkLevel } from "../auto-reply/thinking.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { shouldLogVerbose } from "../globals.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveClawdbotDocsPath } from "./docs-path.js";
|
||||
@@ -102,6 +102,7 @@ export async function runCliAgent(params: {
|
||||
tools: [],
|
||||
contextFiles,
|
||||
modelDisplay,
|
||||
agentId: sessionAgentId,
|
||||
});
|
||||
|
||||
const { sessionId: cliSessionIdToSend, isNew } = resolveSessionIdToSend({
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { CliBackendConfig } from "../../config/types.js";
|
||||
import { runExec } from "../../process/exec.js";
|
||||
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
|
||||
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
|
||||
import { buildSystemPromptParams } from "../system-prompt-params.js";
|
||||
import { buildAgentSystemPrompt } from "../system-prompt.js";
|
||||
|
||||
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
|
||||
@@ -172,10 +172,19 @@ export function buildSystemPrompt(params: {
|
||||
tools: AgentTool[];
|
||||
contextFiles?: EmbeddedContextFile[];
|
||||
modelDisplay: string;
|
||||
agentId?: string;
|
||||
}) {
|
||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
runtime: {
|
||||
host: "clawdbot",
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
arch: os.arch(),
|
||||
node: process.version,
|
||||
model: params.modelDisplay,
|
||||
},
|
||||
});
|
||||
return buildAgentSystemPrompt({
|
||||
workspaceDir: params.workspaceDir,
|
||||
defaultThinkLevel: params.defaultThinkLevel,
|
||||
@@ -184,13 +193,7 @@ export function buildSystemPrompt(params: {
|
||||
reasoningTagHint: false,
|
||||
heartbeatPrompt: params.heartbeatPrompt,
|
||||
docsPath: params.docsPath,
|
||||
runtimeInfo: {
|
||||
host: "clawdbot",
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
arch: os.arch(),
|
||||
node: process.version,
|
||||
model: params.modelDisplay,
|
||||
},
|
||||
runtimeInfo,
|
||||
toolNames: params.tools.map((tool) => tool.name),
|
||||
modelAliasLines: buildModelAliasLines(params.config),
|
||||
userTimezone,
|
||||
|
||||
@@ -3,8 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
|
||||
const GEMINI_KEY = process.env.GEMINI_API_KEY ?? "";
|
||||
const LIVE =
|
||||
isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
|
||||
const LIVE = isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
|
||||
|
||||
const describeLive = LIVE && GEMINI_KEY ? describe : describe.skip;
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@ import { isTruthyEnvValue } from "../infra/env.js";
|
||||
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
|
||||
const MINIMAX_BASE_URL = process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic";
|
||||
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
|
||||
const LIVE =
|
||||
isTruthyEnvValue(process.env.MINIMAX_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
|
||||
const LIVE = isTruthyEnvValue(process.env.MINIMAX_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
|
||||
|
||||
const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip;
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ import { getApiKeyForModel } from "./model-auth.js";
|
||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||
import { isRateLimitErrorMessage } from "./pi-embedded-helpers/errors.js";
|
||||
|
||||
const LIVE =
|
||||
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
|
||||
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
|
||||
const DIRECT_ENABLED = Boolean(process.env.CLAWDBOT_LIVE_MODELS?.trim());
|
||||
const REQUIRE_PROFILE_KEYS = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS);
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@ import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { applyExtraParamsToAgent } from "./pi-embedded-runner.js";
|
||||
|
||||
const OPENAI_KEY = process.env.OPENAI_API_KEY ?? "";
|
||||
const LIVE =
|
||||
isTruthyEnvValue(process.env.OPENAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
|
||||
const LIVE = isTruthyEnvValue(process.env.OPENAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
|
||||
|
||||
const describeLive = LIVE && OPENAI_KEY ? describe : describe.skip;
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { createSubsystemLogger } from "../../logging.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
|
||||
export const log = createSubsystemLogger("agent/embedded");
|
||||
@@ -64,7 +64,7 @@ import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manage
|
||||
import { prepareSessionManagerForRun } from "../session-manager-init.js";
|
||||
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
|
||||
import { splitSdkTools } from "../tool-split.js";
|
||||
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../../date-time.js";
|
||||
import { buildSystemPromptParams } from "../../system-prompt-params.js";
|
||||
import { describeUnknownError, mapThinkingLevel } from "../utils.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
|
||||
import { isTimeoutError } from "../../failover-error.js";
|
||||
@@ -196,25 +196,25 @@ export async function runEmbeddedAttempt(
|
||||
return level ? { level, channel: "Telegram" } : undefined;
|
||||
})()
|
||||
: undefined;
|
||||
const runtimeInfo = {
|
||||
host: machineName,
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
arch: os.arch(),
|
||||
node: process.version,
|
||||
model: `${params.provider}/${params.modelId}`,
|
||||
channel: runtimeChannel,
|
||||
capabilities: runtimeCapabilities,
|
||||
};
|
||||
|
||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
||||
const reasoningTagHint = isReasoningTagProvider(params.provider);
|
||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
});
|
||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
||||
const reasoningTagHint = isReasoningTagProvider(params.provider);
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
runtime: {
|
||||
host: machineName,
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
arch: os.arch(),
|
||||
node: process.version,
|
||||
model: `${params.provider}/${params.modelId}`,
|
||||
channel: runtimeChannel,
|
||||
capabilities: runtimeCapabilities,
|
||||
},
|
||||
});
|
||||
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
||||
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
||||
const docsPath = await resolveClawdbotDocsPath({
|
||||
|
||||
@@ -23,6 +23,7 @@ export function buildEmbeddedSystemPrompt(params: {
|
||||
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||
promptMode?: PromptMode;
|
||||
runtimeInfo: {
|
||||
agentId?: string;
|
||||
host: string;
|
||||
os: string;
|
||||
arch: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
|
||||
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import type { InlineCodeState } from "../markdown/code-spans.js";
|
||||
import { buildCodeSpanIndex, createInlineCodeState } from "../markdown/code-spans.js";
|
||||
import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { __testing } from "./compaction-safeguard.js";
|
||||
|
||||
const { collectToolFailures, formatToolFailuresSection } = __testing;
|
||||
|
||||
describe("compaction-safeguard tool failures", () => {
|
||||
it("formats tool failures with meta and summary", () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "exec",
|
||||
isError: true,
|
||||
details: { status: "failed", exitCode: 1 },
|
||||
content: [{ type: "text", text: "ENOENT: missing file" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-2",
|
||||
toolName: "read",
|
||||
isError: false,
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const failures = collectToolFailures(messages);
|
||||
expect(failures).toHaveLength(1);
|
||||
|
||||
const section = formatToolFailuresSection(failures);
|
||||
expect(section).toContain("## Tool Failures");
|
||||
expect(section).toContain("exec (status=failed exitCode=1): ENOENT: missing file");
|
||||
});
|
||||
|
||||
it("dedupes by toolCallId and handles empty output", () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "exec",
|
||||
isError: true,
|
||||
details: { exitCode: 2 },
|
||||
content: [],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "exec",
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "ignored" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const failures = collectToolFailures(messages);
|
||||
expect(failures).toHaveLength(1);
|
||||
|
||||
const section = formatToolFailuresSection(failures);
|
||||
expect(section).toContain("exec (exitCode=2): failed");
|
||||
});
|
||||
|
||||
it("caps the number of failures and adds overflow line", () => {
|
||||
const messages: AgentMessage[] = Array.from({ length: 9 }, (_, idx) => ({
|
||||
role: "toolResult",
|
||||
toolCallId: `call-${idx}`,
|
||||
toolName: "exec",
|
||||
isError: true,
|
||||
content: [{ type: "text", text: `error ${idx}` }],
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
|
||||
const failures = collectToolFailures(messages);
|
||||
const section = formatToolFailuresSection(failures);
|
||||
expect(section).toContain("## Tool Failures");
|
||||
expect(section).toContain("...and 1 more");
|
||||
});
|
||||
|
||||
it("omits section when there are no tool failures", () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "ok",
|
||||
toolName: "exec",
|
||||
isError: false,
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const failures = collectToolFailures(messages);
|
||||
const section = formatToolFailuresSection(failures);
|
||||
expect(section).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,101 @@ const FALLBACK_SUMMARY =
|
||||
const TURN_PREFIX_INSTRUCTIONS =
|
||||
"This summary covers the prefix of a split turn. Focus on the original request," +
|
||||
" early progress, and any details needed to understand the retained suffix.";
|
||||
const MAX_TOOL_FAILURES = 8;
|
||||
const MAX_TOOL_FAILURE_CHARS = 240;
|
||||
|
||||
type ToolFailure = {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
summary: string;
|
||||
meta?: string;
|
||||
};
|
||||
|
||||
function normalizeFailureText(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function truncateFailureText(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
return `${text.slice(0, Math.max(0, maxChars - 3))}...`;
|
||||
}
|
||||
|
||||
function formatToolFailureMeta(details: unknown): string | undefined {
|
||||
if (!details || typeof details !== "object") return undefined;
|
||||
const record = details as Record<string, unknown>;
|
||||
const status = typeof record.status === "string" ? record.status : undefined;
|
||||
const exitCode =
|
||||
typeof record.exitCode === "number" && Number.isFinite(record.exitCode)
|
||||
? record.exitCode
|
||||
: undefined;
|
||||
const parts: string[] = [];
|
||||
if (status) parts.push(`status=${status}`);
|
||||
if (exitCode !== undefined) parts.push(`exitCode=${exitCode}`);
|
||||
return parts.length > 0 ? parts.join(" ") : undefined;
|
||||
}
|
||||
|
||||
function extractToolResultText(content: unknown): string {
|
||||
if (!Array.isArray(content)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const rec = block as { type?: unknown; text?: unknown };
|
||||
if (rec.type === "text" && typeof rec.text === "string") {
|
||||
parts.push(rec.text);
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function collectToolFailures(messages: AgentMessage[]): ToolFailure[] {
|
||||
const failures: ToolFailure[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message || typeof message !== "object") continue;
|
||||
const role = (message as { role?: unknown }).role;
|
||||
if (role !== "toolResult") continue;
|
||||
const toolResult = message as {
|
||||
toolCallId?: unknown;
|
||||
toolName?: unknown;
|
||||
content?: unknown;
|
||||
details?: unknown;
|
||||
isError?: unknown;
|
||||
};
|
||||
if (toolResult.isError !== true) continue;
|
||||
const toolCallId =
|
||||
typeof toolResult.toolCallId === "string" ? toolResult.toolCallId : "";
|
||||
if (!toolCallId || seen.has(toolCallId)) continue;
|
||||
seen.add(toolCallId);
|
||||
|
||||
const toolName =
|
||||
typeof toolResult.toolName === "string" && toolResult.toolName.trim()
|
||||
? toolResult.toolName
|
||||
: "tool";
|
||||
const rawText = extractToolResultText(toolResult.content);
|
||||
const meta = formatToolFailureMeta(toolResult.details);
|
||||
const normalized = normalizeFailureText(rawText);
|
||||
const summary = truncateFailureText(
|
||||
normalized || (meta ? "failed" : "failed (no output)"),
|
||||
MAX_TOOL_FAILURE_CHARS,
|
||||
);
|
||||
failures.push({ toolCallId, toolName, summary, meta });
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
function formatToolFailuresSection(failures: ToolFailure[]): string {
|
||||
if (failures.length === 0) return "";
|
||||
const lines = failures.slice(0, MAX_TOOL_FAILURES).map((failure) => {
|
||||
const meta = failure.meta ? ` (${failure.meta})` : "";
|
||||
return `- ${failure.toolName}${meta}: ${failure.summary}`;
|
||||
});
|
||||
if (failures.length > MAX_TOOL_FAILURES) {
|
||||
lines.push(`- ...and ${failures.length - MAX_TOOL_FAILURES} more`);
|
||||
}
|
||||
return `\n\n## Tool Failures\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
function computeFileLists(fileOps: FileOperations): {
|
||||
readFiles: string[];
|
||||
@@ -103,7 +198,12 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
const { preparation, customInstructions, signal } = event;
|
||||
const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps);
|
||||
const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles);
|
||||
const fallbackSummary = `${FALLBACK_SUMMARY}${fileOpsSummary}`;
|
||||
const toolFailures = collectToolFailures([
|
||||
...preparation.messagesToSummarize,
|
||||
...preparation.turnPrefixMessages,
|
||||
]);
|
||||
const toolFailureSection = formatToolFailuresSection(toolFailures);
|
||||
const fallbackSummary = `${FALLBACK_SUMMARY}${toolFailureSection}${fileOpsSummary}`;
|
||||
|
||||
const model = ctx.model;
|
||||
if (!model) {
|
||||
@@ -162,6 +262,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
|
||||
}
|
||||
|
||||
summary += toolFailureSection;
|
||||
summary += fileOpsSummary;
|
||||
|
||||
return {
|
||||
@@ -189,3 +290,8 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
collectToolFailures,
|
||||
formatToolFailuresSection,
|
||||
} as const;
|
||||
@@ -81,6 +81,7 @@ function resolveExecConfig(cfg: ClawdbotConfig | undefined) {
|
||||
security: globalExec?.security,
|
||||
ask: globalExec?.ask,
|
||||
node: globalExec?.node,
|
||||
pathPrepend: globalExec?.pathPrepend,
|
||||
backgroundMs: globalExec?.backgroundMs,
|
||||
timeoutSec: globalExec?.timeoutSec,
|
||||
cleanupMs: globalExec?.cleanupMs,
|
||||
@@ -207,6 +208,7 @@ export function createClawdbotCodingTools(options?: {
|
||||
security: options?.exec?.security ?? execConfig.security,
|
||||
ask: options?.exec?.ask ?? execConfig.ask,
|
||||
node: options?.exec?.node ?? execConfig.node,
|
||||
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
|
||||
agentId,
|
||||
cwd: options?.workspaceDir,
|
||||
allowBackground,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import type { SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js";
|
||||
|
||||
type SandboxHashInput = {
|
||||
docker: SandboxDockerConfig;
|
||||
workspaceAccess: SandboxWorkspaceAccess;
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir: string;
|
||||
};
|
||||
|
||||
function isPrimitive(value: unknown): value is string | number | boolean | bigint | symbol | null {
|
||||
return value === null || (typeof value !== "object" && typeof value !== "function");
|
||||
}
|
||||
function normalizeForHash(value: unknown): unknown {
|
||||
if (value === undefined) return undefined;
|
||||
if (Array.isArray(value)) {
|
||||
const normalized = value.map(normalizeForHash).filter((item): item is unknown => item !== undefined);
|
||||
const primitives = normalized.filter(isPrimitive);
|
||||
if (primitives.length === normalized.length) {
|
||||
return [...primitives].sort((a, b) => String(a).localeCompare(String(b)));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const [key, entryValue] of entries) {
|
||||
const next = normalizeForHash(entryValue);
|
||||
if (next !== undefined) normalized[key] = next;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function computeSandboxConfigHash(input: SandboxHashInput): string {
|
||||
const payload = normalizeForHash(input);
|
||||
const raw = JSON.stringify(payload);
|
||||
return crypto.createHash("sha1").update(raw).digest("hex");
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
|
||||
import { updateRegistry } from "./registry.js";
|
||||
import { resolveSandboxScopeKey, slugifySessionKey } from "./shared.js";
|
||||
import { readRegistry, updateRegistry } from "./registry.js";
|
||||
import { computeSandboxConfigHash } from "./config-hash.js";
|
||||
import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js";
|
||||
import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js";
|
||||
|
||||
const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
export function execDocker(args: string[], opts?: { allowFailure?: boolean }) {
|
||||
return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => {
|
||||
const child = spawn("docker", args, {
|
||||
@@ -99,12 +103,16 @@ export function buildSandboxCreateArgs(params: {
|
||||
scopeKey: string;
|
||||
createdAtMs?: number;
|
||||
labels?: Record<string, string>;
|
||||
configHash?: string;
|
||||
}) {
|
||||
const createdAtMs = params.createdAtMs ?? Date.now();
|
||||
const args = ["create", "--name", params.name];
|
||||
args.push("--label", "clawdbot.sandbox=1");
|
||||
args.push("--label", `clawdbot.sessionKey=${params.scopeKey}`);
|
||||
args.push("--label", `clawdbot.createdAtMs=${createdAtMs}`);
|
||||
if (params.configHash) {
|
||||
args.push("--label", `clawdbot.configHash=${params.configHash}`);
|
||||
}
|
||||
for (const [key, value] of Object.entries(params.labels ?? {})) {
|
||||
if (key && value) args.push("--label", `${key}=${value}`);
|
||||
}
|
||||
@@ -161,6 +169,7 @@ async function createSandboxContainer(params: {
|
||||
workspaceAccess: SandboxWorkspaceAccess;
|
||||
agentWorkspaceDir: string;
|
||||
scopeKey: string;
|
||||
configHash?: string;
|
||||
}) {
|
||||
const { name, cfg, workspaceDir, scopeKey } = params;
|
||||
await ensureDockerImage(cfg.image);
|
||||
@@ -169,6 +178,7 @@ async function createSandboxContainer(params: {
|
||||
name,
|
||||
cfg,
|
||||
scopeKey,
|
||||
configHash: params.configHash,
|
||||
});
|
||||
args.push("--workdir", cfg.workdir);
|
||||
const mainMountSuffix =
|
||||
@@ -191,6 +201,28 @@ async function createSandboxContainer(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function readContainerConfigHash(containerName: string): Promise<string | null> {
|
||||
const result = await execDocker(
|
||||
["inspect", "-f", '{{ index .Config.Labels "clawdbot.configHash" }}', containerName],
|
||||
{ allowFailure: true },
|
||||
);
|
||||
if (result.code !== 0) return null;
|
||||
const raw = result.stdout.trim();
|
||||
if (!raw || raw === "<no value>") return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function formatSandboxRecreateHint(params: { scope: SandboxConfig["scope"]; sessionKey: string }) {
|
||||
if (params.scope === "session") {
|
||||
return `clawdbot sandbox recreate --session ${params.sessionKey}`;
|
||||
}
|
||||
if (params.scope === "agent") {
|
||||
const agentId = resolveSandboxAgentId(params.sessionKey) ?? "main";
|
||||
return `clawdbot sandbox recreate --agent ${agentId}`;
|
||||
}
|
||||
return "clawdbot sandbox recreate --all";
|
||||
}
|
||||
|
||||
export async function ensureSandboxContainer(params: {
|
||||
sessionKey: string;
|
||||
workspaceDir: string;
|
||||
@@ -201,8 +233,51 @@ export async function ensureSandboxContainer(params: {
|
||||
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey);
|
||||
const name = `${params.cfg.docker.containerPrefix}${slug}`;
|
||||
const containerName = name.slice(0, 63);
|
||||
const expectedHash = computeSandboxConfigHash({
|
||||
docker: params.cfg.docker,
|
||||
workspaceAccess: params.cfg.workspaceAccess,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
});
|
||||
const now = Date.now();
|
||||
const state = await dockerContainerState(containerName);
|
||||
if (!state.exists) {
|
||||
let hasContainer = state.exists;
|
||||
let running = state.running;
|
||||
let currentHash: string | null = null;
|
||||
let hashMismatch = false;
|
||||
let registryEntry:
|
||||
| {
|
||||
lastUsedAtMs: number;
|
||||
configHash?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (hasContainer) {
|
||||
const registry = await readRegistry();
|
||||
registryEntry = registry.entries.find((entry) => entry.containerName === containerName);
|
||||
currentHash = await readContainerConfigHash(containerName);
|
||||
if (!currentHash) {
|
||||
currentHash =
|
||||
registryEntry?.configHash ?? null;
|
||||
}
|
||||
hashMismatch = !currentHash || currentHash !== expectedHash;
|
||||
if (hashMismatch) {
|
||||
const lastUsedAtMs = registryEntry?.lastUsedAtMs;
|
||||
const isHot =
|
||||
running &&
|
||||
(typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS);
|
||||
if (isHot) {
|
||||
const hint = formatSandboxRecreateHint({ scope: params.cfg.scope, sessionKey: scopeKey });
|
||||
defaultRuntime.log(
|
||||
`Sandbox config changed for ${containerName} (recently used). Recreate to apply: ${hint}`,
|
||||
);
|
||||
} else {
|
||||
await execDocker(["rm", "-f", containerName], { allowFailure: true });
|
||||
hasContainer = false;
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasContainer) {
|
||||
await createSandboxContainer({
|
||||
name: containerName,
|
||||
cfg: params.cfg.docker,
|
||||
@@ -210,17 +285,18 @@ export async function ensureSandboxContainer(params: {
|
||||
workspaceAccess: params.cfg.workspaceAccess,
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
scopeKey,
|
||||
configHash: expectedHash,
|
||||
});
|
||||
} else if (!state.running) {
|
||||
} else if (!running) {
|
||||
await execDocker(["start", containerName]);
|
||||
}
|
||||
const now = Date.now();
|
||||
await updateRegistry({
|
||||
containerName,
|
||||
sessionKey: scopeKey,
|
||||
createdAtMs: now,
|
||||
lastUsedAtMs: now,
|
||||
image: params.cfg.docker.image,
|
||||
configHash: hashMismatch && running ? currentHash ?? undefined : expectedHash,
|
||||
});
|
||||
return containerName;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export type SandboxRegistryEntry = {
|
||||
createdAtMs: number;
|
||||
lastUsedAtMs: number;
|
||||
image: string;
|
||||
configHash?: string;
|
||||
};
|
||||
|
||||
type SandboxRegistry = {
|
||||
@@ -56,6 +57,7 @@ export async function updateRegistry(entry: SandboxRegistryEntry) {
|
||||
...entry,
|
||||
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
|
||||
image: existing?.image ?? entry.image,
|
||||
configHash: entry.configHash ?? existing?.configHash,
|
||||
});
|
||||
await writeRegistry({ entries: next });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
function resolvePowerShellPath(): string {
|
||||
const systemRoot = process.env.SystemRoot || process.env.WINDIR;
|
||||
if (systemRoot) {
|
||||
const candidate = path.join(
|
||||
systemRoot,
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return "powershell.exe";
|
||||
}
|
||||
|
||||
export function getShellConfig(): { shell: string; args: string[] } {
|
||||
if (process.platform === "win32") {
|
||||
// Use PowerShell instead of cmd.exe on Windows.
|
||||
@@ -8,7 +25,7 @@ export function getShellConfig(): { shell: string; args: string[] } {
|
||||
// When Node.js spawns cmd.exe with piped stdio, these utilities produce no output.
|
||||
// PowerShell properly captures and redirects their output to stdout.
|
||||
return {
|
||||
shell: "powershell.exe",
|
||||
shell: resolvePowerShellPath(),
|
||||
args: ["-NoProfile", "-NonInteractive", "-Command"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
import chokidar, { type FSWatcher } from "chokidar";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { createSubsystemLogger } from "../../logging.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
|
||||
|
||||
type SkillsChangeEvent = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { createSubsystemLogger } from "../../logging.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
|
||||
import { resolveBundledSkillsDir } from "./bundled-dir.js";
|
||||
import { shouldIncludeSkill } from "./config.js";
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
formatUserTime,
|
||||
resolveUserTimeFormat,
|
||||
resolveUserTimezone,
|
||||
type ResolvedTimeFormat,
|
||||
} from "./date-time.js";
|
||||
|
||||
export type RuntimeInfoInput = {
|
||||
agentId?: string;
|
||||
host: string;
|
||||
os: string;
|
||||
arch: string;
|
||||
node: string;
|
||||
model: string;
|
||||
channel?: string;
|
||||
capabilities?: string[];
|
||||
};
|
||||
|
||||
export type SystemPromptRuntimeParams = {
|
||||
runtimeInfo: RuntimeInfoInput;
|
||||
userTimezone: string;
|
||||
userTime?: string;
|
||||
userTimeFormat?: ResolvedTimeFormat;
|
||||
};
|
||||
|
||||
export function buildSystemPromptParams(params: {
|
||||
config?: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
runtime: Omit<RuntimeInfoInput, "agentId">;
|
||||
}): SystemPromptRuntimeParams {
|
||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
||||
return {
|
||||
runtimeInfo: {
|
||||
agentId: params.agentId,
|
||||
...params.runtime,
|
||||
},
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentSystemPrompt } from "./system-prompt.js";
|
||||
import { buildAgentSystemPrompt, buildRuntimeLine } from "./system-prompt.js";
|
||||
|
||||
describe("buildAgentSystemPrompt", () => {
|
||||
it("includes owner numbers when provided", () => {
|
||||
@@ -252,6 +252,22 @@ describe("buildAgentSystemPrompt", () => {
|
||||
expect(prompt).toContain("capabilities=inlineButtons");
|
||||
});
|
||||
|
||||
it("includes agent id in runtime when provided", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
runtimeInfo: {
|
||||
agentId: "work",
|
||||
host: "host",
|
||||
os: "macOS",
|
||||
arch: "arm64",
|
||||
node: "v20",
|
||||
model: "anthropic/claude",
|
||||
},
|
||||
});
|
||||
|
||||
expect(prompt).toContain("agent=work");
|
||||
});
|
||||
|
||||
it("includes reasoning visibility hint", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
@@ -263,6 +279,31 @@ describe("buildAgentSystemPrompt", () => {
|
||||
expect(prompt).toContain("/status shows Reasoning");
|
||||
});
|
||||
|
||||
it("builds runtime line with agent and channel details", () => {
|
||||
const line = buildRuntimeLine(
|
||||
{
|
||||
agentId: "work",
|
||||
host: "host",
|
||||
os: "macOS",
|
||||
arch: "arm64",
|
||||
node: "v20",
|
||||
model: "anthropic/claude",
|
||||
},
|
||||
"telegram",
|
||||
["inlineButtons"],
|
||||
"low",
|
||||
);
|
||||
|
||||
expect(line).toContain("agent=work");
|
||||
expect(line).toContain("host=host");
|
||||
expect(line).toContain("os=macOS (arm64)");
|
||||
expect(line).toContain("node=v20");
|
||||
expect(line).toContain("model=anthropic/claude");
|
||||
expect(line).toContain("channel=telegram");
|
||||
expect(line).toContain("capabilities=inlineButtons");
|
||||
expect(line).toContain("thinking=low");
|
||||
});
|
||||
|
||||
it("describes sandboxed runtime and elevated when allowed", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
|
||||
+36
-21
@@ -149,6 +149,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||
promptMode?: PromptMode;
|
||||
runtimeInfo?: {
|
||||
agentId?: string;
|
||||
host?: string;
|
||||
os?: string;
|
||||
arch?: string;
|
||||
@@ -358,6 +359,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
"Default: do not narrate routine, low-risk tool calls (just call the tool).",
|
||||
"Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks.",
|
||||
"Keep narration brief and value-dense; avoid repeating obvious steps.",
|
||||
"Use plain human language for narration unless in a technical context.",
|
||||
"",
|
||||
"## Clawdbot CLI Quick Reference",
|
||||
"Clawdbot is controlled via subcommands. Do not invent commands.",
|
||||
@@ -545,27 +547,40 @@ export function buildAgentSystemPrompt(params: {
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"## Runtime",
|
||||
`Runtime: ${[
|
||||
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
|
||||
runtimeInfo?.os
|
||||
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
|
||||
: runtimeInfo?.arch
|
||||
? `arch=${runtimeInfo.arch}`
|
||||
: "",
|
||||
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
|
||||
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
|
||||
runtimeChannel ? `channel=${runtimeChannel}` : "",
|
||||
runtimeChannel
|
||||
? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}`
|
||||
: "",
|
||||
`thinking=${params.defaultThinkLevel ?? "off"}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ")}`,
|
||||
`Reasoning: ${reasoningLevel} (hidden unless on/stream). Toggle /reasoning; /status shows Reasoning when enabled.`,
|
||||
);
|
||||
lines.push("## Runtime", buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel), `Reasoning: ${reasoningLevel} (hidden unless on/stream). Toggle /reasoning; /status shows Reasoning when enabled.`);
|
||||
|
||||
return lines.filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
export function buildRuntimeLine(
|
||||
runtimeInfo?: {
|
||||
agentId?: string;
|
||||
host?: string;
|
||||
os?: string;
|
||||
arch?: string;
|
||||
node?: string;
|
||||
model?: string;
|
||||
},
|
||||
runtimeChannel?: string,
|
||||
runtimeCapabilities: string[] = [],
|
||||
defaultThinkLevel?: ThinkLevel,
|
||||
): string {
|
||||
return `Runtime: ${[
|
||||
runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "",
|
||||
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
|
||||
runtimeInfo?.os
|
||||
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
|
||||
: runtimeInfo?.arch
|
||||
? `arch=${runtimeInfo.arch}`
|
||||
: "",
|
||||
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
|
||||
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
|
||||
runtimeChannel ? `channel=${runtimeChannel}` : "",
|
||||
runtimeChannel
|
||||
? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}`
|
||||
: "",
|
||||
`thinking=${defaultThinkLevel ?? "off"}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ")}`;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js";
|
||||
|
||||
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
|
||||
|
||||
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
||||
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { createSubsystemLogger } from "../../logging.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
|
||||
import { AGENT_LANE_NESTED } from "../lanes.js";
|
||||
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
|
||||
|
||||
@@ -25,7 +25,7 @@ function normalizeWhitespace(value: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function htmlToMarkdown(html: string): { text: string; title?: string } {
|
||||
export function htmlToMarkdown(html: string): { text: string; title?: string } {
|
||||
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
const title = titleMatch ? normalizeWhitespace(stripTags(titleMatch[1])) : undefined;
|
||||
let text = html
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "./web-shared.js";
|
||||
import {
|
||||
extractReadableContent,
|
||||
htmlToMarkdown,
|
||||
markdownToText,
|
||||
truncateText,
|
||||
type ExtractMode,
|
||||
@@ -28,6 +29,7 @@ export { extractReadableContent } from "./web-fetch-utils.js";
|
||||
const EXTRACT_MODES = ["markdown", "text"] as const;
|
||||
|
||||
const DEFAULT_FETCH_MAX_CHARS = 50_000;
|
||||
const DEFAULT_ERROR_MAX_CHARS = 4_000;
|
||||
const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
|
||||
const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
|
||||
const DEFAULT_FETCH_USER_AGENT =
|
||||
@@ -142,6 +144,30 @@ function resolveMaxChars(value: unknown, fallback: number): number {
|
||||
return Math.max(100, Math.floor(parsed));
|
||||
}
|
||||
|
||||
function looksLikeHtml(value: string): boolean {
|
||||
const trimmed = value.trimStart();
|
||||
if (!trimmed) return false;
|
||||
const head = trimmed.slice(0, 256).toLowerCase();
|
||||
return head.startsWith("<!doctype html") || head.startsWith("<html");
|
||||
}
|
||||
|
||||
function formatWebFetchErrorDetail(params: {
|
||||
detail: string;
|
||||
contentType?: string | null;
|
||||
maxChars: number;
|
||||
}): string {
|
||||
const { detail, contentType, maxChars } = params;
|
||||
if (!detail) return "";
|
||||
let text = detail;
|
||||
const contentTypeLower = contentType?.toLowerCase();
|
||||
if (contentTypeLower?.includes("text/html") || looksLikeHtml(detail)) {
|
||||
const rendered = htmlToMarkdown(detail);
|
||||
const withTitle = rendered.title ? `${rendered.title}\n${rendered.text}` : rendered.text;
|
||||
text = markdownToText(withTitle);
|
||||
}
|
||||
const truncated = truncateText(text.trim(), maxChars);
|
||||
return truncated.text;
|
||||
}
|
||||
export async function fetchFirecrawlContent(params: {
|
||||
url: string;
|
||||
extractMode: ExtractMode;
|
||||
@@ -329,7 +355,12 @@ async function runWebFetch(params: {
|
||||
writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||
return payload;
|
||||
}
|
||||
const detail = await readResponseText(res);
|
||||
const rawDetail = await readResponseText(res);
|
||||
const detail = formatWebFetchErrorDetail({
|
||||
detail: rawDetail,
|
||||
contentType: res.headers.get("content-type"),
|
||||
maxChars: DEFAULT_ERROR_MAX_CHARS,
|
||||
});
|
||||
throw new Error(`Web fetch failed (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,20 @@ function firecrawlError(): MockResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function errorHtmlResponse(
|
||||
html: string,
|
||||
status = 404,
|
||||
url = "https://example.com/",
|
||||
contentType: string | null = "text/html; charset=utf-8",
|
||||
): MockResponse {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
url,
|
||||
headers: contentType ? makeHeaders({ "content-type": contentType }) : makeHeaders({}),
|
||||
text: async () => html,
|
||||
};
|
||||
}
|
||||
function requestUrl(input: RequestInfo): string {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.toString();
|
||||
@@ -182,4 +196,64 @@ describe("web_fetch extraction fallbacks", () => {
|
||||
expect(details.extractor).toBe("firecrawl");
|
||||
expect(details.text).toContain("firecrawl fallback");
|
||||
});
|
||||
it("strips and truncates HTML from error responses", async () => {
|
||||
const long = "x".repeat(12_000);
|
||||
const html =
|
||||
"<!doctype html><html><head><title>Not Found</title></head><body><h1>Not Found</h1><p>" +
|
||||
long +
|
||||
"</p></body></html>";
|
||||
const mockFetch = vi.fn((input: RequestInfo) =>
|
||||
Promise.resolve(errorHtmlResponse(html, 404, requestUrl(input), "Text/HTML; charset=utf-8")),
|
||||
);
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebFetchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } },
|
||||
},
|
||||
},
|
||||
},
|
||||
sandboxed: false,
|
||||
});
|
||||
|
||||
let message = "";
|
||||
try {
|
||||
await tool?.execute?.("call", { url: "https://example.com/missing" });
|
||||
} catch (error) {
|
||||
message = (error as Error).message;
|
||||
}
|
||||
|
||||
expect(message).toContain("Web fetch failed (404):");
|
||||
expect(message).toContain("Not Found");
|
||||
expect(message).not.toContain("<html");
|
||||
expect(message.length).toBeLessThan(5_000);
|
||||
});
|
||||
|
||||
it("strips HTML errors when content-type is missing", async () => {
|
||||
const html =
|
||||
"<!DOCTYPE HTML><html><head><title>Oops</title></head><body><h1>Oops</h1></body></html>";
|
||||
const mockFetch = vi.fn((input: RequestInfo) =>
|
||||
Promise.resolve(errorHtmlResponse(html, 500, requestUrl(input), null)),
|
||||
);
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebFetchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } },
|
||||
},
|
||||
},
|
||||
},
|
||||
sandboxed: false,
|
||||
});
|
||||
|
||||
await expect(tool?.execute?.("call", { url: "https://example.com/oops" })).rejects.toThrow(
|
||||
/Web fetch failed \(500\):.*Oops/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -59,14 +59,14 @@ describe("commands registry args", () => {
|
||||
name: "mode",
|
||||
description: "mode",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full"],
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||
expect(menu?.arg.name).toBe("mode");
|
||||
expect(menu?.choices).toEqual(["off", "tokens", "full"]);
|
||||
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
|
||||
});
|
||||
|
||||
it("does not show menus when arg already provided", () => {
|
||||
@@ -82,7 +82,7 @@ describe("commands registry args", () => {
|
||||
name: "mode",
|
||||
description: "mode",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full"],
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -141,7 +141,7 @@ describe("commands registry args", () => {
|
||||
name: "mode",
|
||||
description: "on or off",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full"],
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -233,14 +233,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
defineChatCommand({
|
||||
key: "usage",
|
||||
nativeName: "usage",
|
||||
description: "Toggle per-response usage line.",
|
||||
description: "Usage footer or cost summary.",
|
||||
textAlias: "/usage",
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "off, tokens, or full",
|
||||
description: "off, tokens, full, or cost",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full"],
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import {
|
||||
emitAgentEvent,
|
||||
registerAgentRunContext,
|
||||
} from "../../infra/agent-events.js";
|
||||
import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
isMarkdownCapableMessageChannel,
|
||||
@@ -32,20 +29,11 @@ import type { TemplateContext } from "../templating.js";
|
||||
import type { VerboseLevel } from "../thinking.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import {
|
||||
buildThreadingToolContext,
|
||||
resolveEnforceFinalTag,
|
||||
} from "./agent-runner-utils.js";
|
||||
import {
|
||||
createBlockReplyPayloadKey,
|
||||
type BlockReplyPipeline,
|
||||
} from "./block-reply-pipeline.js";
|
||||
import { buildThreadingToolContext, resolveEnforceFinalTag } from "./agent-runner-utils.js";
|
||||
import { createBlockReplyPayloadKey, type BlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
import { parseReplyDirectives } from "./reply-directives.js";
|
||||
import {
|
||||
applyReplyTagsToPayload,
|
||||
isRenderablePayload,
|
||||
} from "./reply-payloads.js";
|
||||
import { applyReplyTagsToPayload, isRenderablePayload } from "./reply-payloads.js";
|
||||
import type { TypingSignaler } from "./typing-mode.js";
|
||||
|
||||
export type AgentRunLoopResult =
|
||||
@@ -108,12 +96,9 @@ export async function runAgentTurnWithFallback(params: {
|
||||
while (true) {
|
||||
try {
|
||||
const allowPartialStream = !(
|
||||
params.followupRun.run.reasoningLevel === "stream" &&
|
||||
params.opts?.onReasoningStream
|
||||
params.followupRun.run.reasoningLevel === "stream" && params.opts?.onReasoningStream
|
||||
);
|
||||
const normalizeStreamingText = (
|
||||
payload: ReplyPayload,
|
||||
): { text?: string; skip: boolean } => {
|
||||
const normalizeStreamingText = (payload: ReplyPayload): { text?: string; skip: boolean } => {
|
||||
if (!allowPartialStream) return { skip: true };
|
||||
let text = payload.text;
|
||||
if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
@@ -137,9 +122,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
if (!sanitized.trim()) return { skip: true };
|
||||
return { text: sanitized, skip: false };
|
||||
};
|
||||
const handlePartialForTyping = async (
|
||||
payload: ReplyPayload,
|
||||
): Promise<string | undefined> => {
|
||||
const handlePartialForTyping = async (payload: ReplyPayload): Promise<string | undefined> => {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
if (skip || !text) return undefined;
|
||||
await params.typingSignals.signalTextDelta(text);
|
||||
@@ -174,10 +157,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
startedAt,
|
||||
},
|
||||
});
|
||||
const cliSessionId = getCliSessionId(
|
||||
params.getActiveSessionEntry(),
|
||||
provider,
|
||||
);
|
||||
const cliSessionId = getCliSessionId(params.getActiveSessionEntry(), provider);
|
||||
return runCliAgent({
|
||||
sessionId: params.followupRun.run.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -227,8 +207,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
return runEmbeddedPiAgent({
|
||||
sessionId: params.followupRun.run.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider:
|
||||
params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
agentAccountId: params.sessionCtx.AccountId,
|
||||
// Provider threading context for tool auto-injection
|
||||
...buildThreadingToolContext({
|
||||
@@ -244,10 +223,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
prompt: params.commandBody,
|
||||
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
|
||||
ownerNumbers: params.followupRun.run.ownerNumbers,
|
||||
enforceFinalTag: resolveEnforceFinalTag(
|
||||
params.followupRun.run,
|
||||
provider,
|
||||
),
|
||||
enforceFinalTag: resolveEnforceFinalTag(params.followupRun.run, provider),
|
||||
provider,
|
||||
model,
|
||||
authProfileId,
|
||||
@@ -264,9 +240,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
params.sessionCtx.Provider,
|
||||
);
|
||||
if (!channel) return "markdown";
|
||||
return isMarkdownCapableMessageChannel(channel)
|
||||
? "markdown"
|
||||
: "plain";
|
||||
return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
|
||||
})(),
|
||||
bashElevated: params.followupRun.run.bashElevated,
|
||||
timeoutMs: params.followupRun.run.timeoutMs,
|
||||
@@ -276,11 +250,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
onPartialReply: allowPartialStream
|
||||
? async (payload) => {
|
||||
const textForTyping = await handlePartialForTyping(payload);
|
||||
if (
|
||||
!params.opts?.onPartialReply ||
|
||||
textForTyping === undefined
|
||||
)
|
||||
return;
|
||||
if (!params.opts?.onPartialReply || textForTyping === undefined) return;
|
||||
await params.opts.onPartialReply({
|
||||
text: textForTyping,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
@@ -291,8 +261,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
await params.typingSignals.signalMessageStart();
|
||||
},
|
||||
onReasoningStream:
|
||||
params.typingSignals.shouldStartOnReasoning ||
|
||||
params.opts?.onReasoningStream
|
||||
params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream
|
||||
? async (payload) => {
|
||||
await params.typingSignals.signalReasoningDelta();
|
||||
await params.opts?.onReasoningStream?.({
|
||||
@@ -305,16 +274,14 @@ export async function runAgentTurnWithFallback(params: {
|
||||
// Trigger typing when tools start executing.
|
||||
// Must await to ensure typing indicator starts before tool summaries are emitted.
|
||||
if (evt.stream === "tool") {
|
||||
const phase =
|
||||
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
if (phase === "start" || phase === "update") {
|
||||
await params.typingSignals.signalToolStart();
|
||||
}
|
||||
}
|
||||
// Track auto-compaction completion
|
||||
if (evt.stream === "compaction") {
|
||||
const phase =
|
||||
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
autoCompactionCompleted = true;
|
||||
@@ -338,22 +305,14 @@ export async function runAgentTurnWithFallback(params: {
|
||||
params.sessionCtx.MessageSid,
|
||||
);
|
||||
// Let through payloads with audioAsVoice flag even if empty (need to track it)
|
||||
if (
|
||||
!isRenderablePayload(taggedPayload) &&
|
||||
!payload.audioAsVoice
|
||||
)
|
||||
return;
|
||||
const parsed = parseReplyDirectives(
|
||||
taggedPayload.text ?? "",
|
||||
{
|
||||
currentMessageId: params.sessionCtx.MessageSid,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
},
|
||||
);
|
||||
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
|
||||
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
|
||||
currentMessageId: params.sessionCtx.MessageSid,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
});
|
||||
const cleaned = parsed.text || undefined;
|
||||
const hasRenderableMedia =
|
||||
Boolean(taggedPayload.mediaUrl) ||
|
||||
(taggedPayload.mediaUrls?.length ?? 0) > 0;
|
||||
Boolean(taggedPayload.mediaUrl) || (taggedPayload.mediaUrls?.length ?? 0) > 0;
|
||||
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
|
||||
if (
|
||||
!cleaned &&
|
||||
@@ -367,35 +326,25 @@ export async function runAgentTurnWithFallback(params: {
|
||||
const blockPayload: ReplyPayload = params.applyReplyToMode({
|
||||
...taggedPayload,
|
||||
text: cleaned,
|
||||
audioAsVoice: Boolean(
|
||||
parsed.audioAsVoice || payload.audioAsVoice,
|
||||
),
|
||||
audioAsVoice: Boolean(parsed.audioAsVoice || payload.audioAsVoice),
|
||||
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
|
||||
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
|
||||
replyToCurrent:
|
||||
taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
||||
replyToCurrent: taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
||||
});
|
||||
|
||||
void params.typingSignals
|
||||
.signalTextDelta(cleaned ?? taggedPayload.text)
|
||||
.catch((err) => {
|
||||
logVerbose(
|
||||
`block reply typing signal failed: ${String(err)}`,
|
||||
);
|
||||
logVerbose(`block reply typing signal failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
// Use pipeline if available (block streaming enabled), otherwise send directly
|
||||
if (
|
||||
params.blockStreamingEnabled &&
|
||||
params.blockReplyPipeline
|
||||
) {
|
||||
if (params.blockStreamingEnabled && params.blockReplyPipeline) {
|
||||
params.blockReplyPipeline.enqueue(blockPayload);
|
||||
} else {
|
||||
// Send directly when flushing before tool execution (no streaming).
|
||||
// Track sent key to avoid duplicate in final payloads.
|
||||
directlySentBlockKeys.add(
|
||||
createBlockReplyPayloadKey(blockPayload),
|
||||
);
|
||||
directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload));
|
||||
await params.opts?.onBlockReply?.(blockPayload);
|
||||
}
|
||||
}
|
||||
@@ -456,9 +405,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
};
|
||||
}
|
||||
if (embeddedError?.kind === "role_ordering") {
|
||||
const didReset = await params.resetSessionAfterRoleOrderingConflict(
|
||||
embeddedError.message,
|
||||
);
|
||||
const didReset = await params.resetSessionAfterRoleOrderingConflict(embeddedError.message);
|
||||
if (didReset) {
|
||||
return {
|
||||
kind: "final",
|
||||
@@ -476,10 +423,8 @@ export async function runAgentTurnWithFallback(params: {
|
||||
isContextOverflowError(message) ||
|
||||
/context.*overflow|too large|context window/i.test(message);
|
||||
const isCompactionFailure = isCompactionFailureError(message);
|
||||
const isSessionCorruption =
|
||||
/function call turn comes immediately after/i.test(message);
|
||||
const isRoleOrderingError =
|
||||
/incorrect role information|roles must alternate/i.test(message);
|
||||
const isSessionCorruption = /function call turn comes immediately after/i.test(message);
|
||||
const isRoleOrderingError = /incorrect role information|roles must alternate/i.test(message);
|
||||
|
||||
if (
|
||||
isCompactionFailure &&
|
||||
@@ -495,8 +440,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
};
|
||||
}
|
||||
if (isRoleOrderingError) {
|
||||
const didReset =
|
||||
await params.resetSessionAfterRoleOrderingConflict(message);
|
||||
const didReset = await params.resetSessionAfterRoleOrderingConflict(message);
|
||||
if (didReset) {
|
||||
return {
|
||||
kind: "final",
|
||||
@@ -523,8 +467,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
try {
|
||||
// Delete transcript file if it exists
|
||||
if (corruptedSessionId) {
|
||||
const transcriptPath =
|
||||
resolveSessionTranscriptPath(corruptedSessionId);
|
||||
const transcriptPath = resolveSessionTranscriptPath(corruptedSessionId);
|
||||
try {
|
||||
fs.unlinkSync(transcriptPath);
|
||||
} catch {
|
||||
@@ -574,7 +517,6 @@ export async function runAgentTurnWithFallback(params: {
|
||||
fallbackModel,
|
||||
didLogHeartbeatStrip,
|
||||
autoCompactionCompleted,
|
||||
directlySentBlockKeys:
|
||||
directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
|
||||
directlySentBlockKeys: directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
|
||||
};
|
||||
}
|
||||
+1
-2
@@ -196,8 +196,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
durationMs: 1,
|
||||
error: {
|
||||
kind: "context_overflow",
|
||||
message:
|
||||
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
|
||||
message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveSessionAgentIds } from "../../agents/agent-scope.js";
|
||||
import { resolveBootstrapMaxChars } from "../../agents/pi-embedded-helpers.js";
|
||||
import { createClawdbotCodingTools } from "../../agents/pi-tools.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
||||
@@ -5,6 +6,7 @@ import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
||||
import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
||||
import { buildAgentSystemPrompt } from "../../agents/system-prompt.js";
|
||||
import { buildSystemPromptReport } from "../../agents/system-prompt-report.js";
|
||||
import { buildSystemPromptParams } from "../../agents/system-prompt-params.js";
|
||||
import { buildToolSummaryMap } from "../../agents/tool-summaries.js";
|
||||
import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js";
|
||||
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
|
||||
@@ -87,13 +89,21 @@ async function resolveContextReport(
|
||||
})();
|
||||
const toolSummaries = buildToolSummaryMap(tools);
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
const runtimeInfo = {
|
||||
host: "unknown",
|
||||
os: "unknown",
|
||||
arch: "unknown",
|
||||
node: process.version,
|
||||
model: `${params.provider}/${params.model}`,
|
||||
};
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.cfg,
|
||||
});
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||
config: params.cfg,
|
||||
agentId: sessionAgentId,
|
||||
runtime: {
|
||||
host: "unknown",
|
||||
os: "unknown",
|
||||
arch: "unknown",
|
||||
node: process.version,
|
||||
model: `${params.provider}/${params.model}`,
|
||||
},
|
||||
});
|
||||
const sandboxInfo = sandboxRuntime.sandboxed
|
||||
? {
|
||||
enabled: true,
|
||||
@@ -116,8 +126,9 @@ async function resolveContextReport(
|
||||
toolNames,
|
||||
toolSummaries,
|
||||
modelAliasLines: [],
|
||||
userTimezone: "",
|
||||
userTime: "",
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
contextFiles: injectedFiles,
|
||||
skillsPrompt,
|
||||
heartbeatPrompt: undefined,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../inf
|
||||
import { parseActivationCommand } from "../group-activation.js";
|
||||
import { parseSendPolicyCommand } from "../send-policy.js";
|
||||
import { normalizeUsageDisplay, resolveResponseUsageMode } from "../thinking.js";
|
||||
import { loadCostUsageSummary, loadSessionCostSummary } from "../../infra/session-cost-usage.js";
|
||||
import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
|
||||
import {
|
||||
formatAbortReplyText,
|
||||
isAbortTrigger,
|
||||
@@ -141,10 +143,48 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
|
||||
|
||||
const rawArgs = normalized === "/usage" ? "" : normalized.slice("/usage".length).trim();
|
||||
const requested = rawArgs ? normalizeUsageDisplay(rawArgs) : undefined;
|
||||
if (rawArgs.toLowerCase().startsWith("cost")) {
|
||||
const sessionSummary = await loadSessionCostSummary({
|
||||
sessionId: params.sessionEntry?.sessionId,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionFile: params.sessionEntry?.sessionFile,
|
||||
config: params.cfg,
|
||||
});
|
||||
const summary = await loadCostUsageSummary({ days: 30, config: params.cfg });
|
||||
|
||||
const sessionCost = formatUsd(sessionSummary?.totalCost);
|
||||
const sessionTokens = sessionSummary?.totalTokens
|
||||
? formatTokenCount(sessionSummary.totalTokens)
|
||||
: undefined;
|
||||
const sessionMissing = sessionSummary?.missingCostEntries ?? 0;
|
||||
const sessionSuffix = sessionMissing > 0 ? " (partial)" : "";
|
||||
const sessionLine =
|
||||
sessionCost || sessionTokens
|
||||
? `Session ${sessionCost ?? "n/a"}${sessionSuffix}${sessionTokens ? ` · ${sessionTokens} tokens` : ""}`
|
||||
: "Session n/a";
|
||||
|
||||
const todayKey = new Date().toLocaleDateString("en-CA");
|
||||
const todayEntry = summary.daily.find((entry) => entry.date === todayKey);
|
||||
const todayCost = formatUsd(todayEntry?.totalCost);
|
||||
const todayMissing = todayEntry?.missingCostEntries ?? 0;
|
||||
const todaySuffix = todayMissing > 0 ? " (partial)" : "";
|
||||
const todayLine = `Today ${todayCost ?? "n/a"}${todaySuffix}`;
|
||||
|
||||
const last30Cost = formatUsd(summary.totals.totalCost);
|
||||
const last30Missing = summary.totals.missingCostEntries;
|
||||
const last30Suffix = last30Missing > 0 ? " (partial)" : "";
|
||||
const last30Line = `Last 30d ${last30Cost ?? "n/a"}${last30Suffix}`;
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `💸 Usage cost\n${sessionLine}\n${todayLine}\n${last30Line}` },
|
||||
};
|
||||
}
|
||||
|
||||
if (rawArgs && !requested) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Usage: /usage off|tokens|full" },
|
||||
reply: { text: "⚙️ Usage: /usage off|tokens|full|cost" },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from "node:path";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import { ensurePortAvailable } from "../infra/ports.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { CONFIG_DIR } from "../utils.js";
|
||||
import { getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
|
||||
import { appendCdpPath } from "./cdp.helpers.js";
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
|
||||
const LIVE =
|
||||
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
|
||||
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
|
||||
const CDP_URL = process.env.CLAWDBOT_LIVE_BROWSER_CDP_URL?.trim() || "";
|
||||
const describeLive = LIVE && CDP_URL ? describe : describe.skip;
|
||||
|
||||
|
||||
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user